Unified · Split
| @@ -1181,6 +1181,62 @@ describe("ProviderCommandReactor", () => { · 1180 unmodified lines | ||
| 1181 | 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 | 1240 | it("restarts an existing Codex thread on a compatible requested instance", async () => { |
| 1185 | 1241 | const harness = await createHarness(); |
| 1186 | 1242 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -1873,6 +1873,97 @@ describe("ProviderRuntimeIngestion", () => { · 1872 unmodified lines | ||
| 1873 | 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 | 1967 | it("flushes and completes buffered assistant text when an approval request opens", async () => { |
| 1877 | 1968 | const harness = await createHarness(); |
| 1878 | 1969 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -1343,6 +1343,7 @@ const make = Effect.gen(function* () { · 1342 unmodified lines | ||
| 1343 | 1343 | case "turn.started": |
| 1344 | 1344 | return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; |
| 1345 | 1345 | case "turn.completed": |
| 1346 | + case "turn.aborted": | |
| 1346 | 1347 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { |
| 1347 | 1348 | return false; |
| 1348 | 1349 | } |
| @@ -1367,7 +1368,8 @@ const make = Effect.gen(function* () { · 18 unmodified lines | ||
| 1367 | 1368 | event.type === "session.exited" || |
| 1368 | 1369 | event.type === "thread.started" || |
| 1369 | 1370 | event.type === "turn.started" || |
| 1370 | − event.type === "turn.completed" | |
| 1371 | + event.type === "turn.completed" || | |
| 1372 | + event.type === "turn.aborted" | |
| 1371 | 1373 | ) { |
| 1372 | 1374 | const status = (() => { |
| 1373 | 1375 | switch (event.type) { |
| @@ -1383,6 +1385,8 @@ const make = Effect.gen(function* () { · 9 unmodified lines | ||
| 1383 | 1385 | return normalizeRuntimeTurnState(event.payload.state) === "failed" |
| 1384 | 1386 | ? "error" |
| 1385 | 1387 | : "ready"; |
| 1388 | + case "turn.aborted": | |
| 1389 | + return "ready"; | |
| 1386 | 1390 | case "session.started": |
| 1387 | 1391 | case "thread.started": |
| 1388 | 1392 | // Provider thread/session start notifications can arrive during an |
| @@ -1393,7 +1397,9 @@ const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 1393 | 1397 | const nextActiveTurnId = |
| 1394 | 1398 | event.type === "turn.started" |
| 1395 | 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 | 1403 | ? null |
| 1398 | 1404 | : event.type === "session.state.changed" && |
| 1399 | 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 | 1644 | const detailedThread = yield* getLoadedThreadDetail(); |
| 1639 | 1645 | const messages = detailedThread?.messages ?? []; |
| 1640 | 1646 | const proposedPlans = detailedThread?.proposedPlans ?? []; |
| @@ -0,0 +1,131 @@ | ||
| 1 | +import { | |
| 2 | + DEFAULT_HERMES_MODEL, | |
| 3 | + HERMES_DRIVER_KIND, | |
| 4 | + HermesSettings, | |
| 5 | + TextGenerationError, | |
| 6 | + type ServerProvider, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import * as Crypto from "effect/Crypto"; | |
| 9 | +import * as DateTime from "effect/DateTime"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as Schema from "effect/Schema"; | |
| 12 | +import * as Stream from "effect/Stream"; | |
| 13 | + | |
| 14 | +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | |
| 15 | +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; | |
| 16 | +import { | |
| 17 | + defaultProviderContinuationIdentity, | |
| 18 | + type ProviderDriver, | |
| 19 | + type ProviderInstance, | |
| 20 | +} from "../ProviderDriver.ts"; | |
| 21 | +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 22 | +import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; | |
| 23 | + | |
| 24 | +const decodeHermesSettings = Schema.decodeSync(HermesSettings); | |
| 25 | + | |
| 26 | +export type HermesDriverEnv = Crypto.Crypto; | |
| 27 | + | |
| 28 | +const unsupportedTextGeneration = ( | |
| 29 | + operation: | |
| 30 | + | "generateCommitMessage" | |
| 31 | + | "generatePrContent" | |
| 32 | + | "generateBranchName" | |
| 33 | + | "generateThreadTitle", | |
| 34 | +) => | |
| 35 | + Effect.fail( | |
| 36 | + new TextGenerationError({ | |
| 37 | + operation, | |
| 38 | + detail: "Hermes gateway instances do not support utility text generation.", | |
| 39 | + }), | |
| 40 | + ); | |
| 41 | + | |
| 42 | +const makeTextGeneration = (): TextGenerationShape => ({ | |
| 43 | + generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"), | |
| 44 | + generatePrContent: () => unsupportedTextGeneration("generatePrContent"), | |
| 45 | + generateBranchName: () => unsupportedTextGeneration("generateBranchName"), | |
| 46 | + generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"), | |
| 47 | +}); | |
| 48 | + | |
| 49 | +export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { | |
| 50 | + driverKind: HERMES_DRIVER_KIND, | |
| 51 | + metadata: { | |
| 52 | + displayName: "Hermes", | |
| 53 | + supportsMultipleInstances: true, | |
| 54 | + }, | |
| 55 | + configSchema: HermesSettings, | |
| 56 | + defaultConfig: () => decodeHermesSettings({}), | |
| 57 | + create: ({ instanceId, displayName, accentColor, enabled }) => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const broker = yield* HermesGatewayBroker; | |
| 60 | + const adapter = yield* makeHermesAdapter({ instanceId }); | |
| 61 | + const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 62 | + driverKind: HERMES_DRIVER_KIND, | |
| 63 | + instanceId, | |
| 64 | + }); | |
| 65 | + const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 66 | + provider: HERMES_DRIVER_KIND, | |
| 67 | + packageName: null, | |
| 68 | + }); | |
| 69 | + const getSnapshot = Effect.gen(function* () { | |
| 70 | + const connected = yield* broker.isConnected(instanceId); | |
| 71 | + const status = yield* broker | |
| 72 | + .getInstanceStatus(instanceId) | |
| 73 | + .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined))); | |
| 74 | + return { | |
| 75 | + instanceId, | |
| 76 | + driver: HERMES_DRIVER_KIND, | |
| 77 | + ...(displayName ? { displayName } : {}), | |
| 78 | + ...(accentColor ? { accentColor } : {}), | |
| 79 | + continuation: { groupKey: continuationIdentity.continuationKey }, | |
| 80 | + showInteractionModeToggle: false, | |
| 81 | + requiresNewThreadForModelChange: true, | |
| 82 | + enabled, | |
| 83 | + installed: true, | |
| 84 | + version: status?.hermesVersion ?? null, | |
| 85 | + status: !enabled ? "disabled" : connected ? "ready" : "warning", | |
| 86 | + auth: { | |
| 87 | + status: connected ? "authenticated" : "unauthenticated", | |
| 88 | + type: "gateway", | |
| 89 | + label: status?.nickname ?? displayName ?? "Hermes", | |
| 90 | + }, | |
| 91 | + checkedAt: DateTime.formatIso(DateTime.nowUnsafe()), | |
| 92 | + ...(!connected && enabled | |
| 93 | + ? { message: "Hermes is offline. Reconnect its T3 Code gateway plugin." } | |
| 94 | + : {}), | |
| 95 | + availability: "available", | |
| 96 | + models: [ | |
| 97 | + { | |
| 98 | + slug: DEFAULT_HERMES_MODEL, | |
| 99 | + name: "Hermes", | |
| 100 | + isCustom: false, | |
| 101 | + isDefault: true, | |
| 102 | + capabilities: null, | |
| 103 | + }, | |
| 104 | + ], | |
| 105 | + slashCommands: [], | |
| 106 | + skills: [], | |
| 107 | + } satisfies ServerProvider; | |
| 108 | + }); | |
| 109 | + const snapshot = { | |
| 110 | + maintenanceCapabilities, | |
| 111 | + getSnapshot, | |
| 112 | + refresh: getSnapshot, | |
| 113 | + streamChanges: broker.streamStatuses.pipe( | |
| 114 | + Stream.filter((status) => status.instanceId === instanceId), | |
| 115 | + Stream.mapEffect(() => getSnapshot), | |
| 116 | + ), | |
| 117 | + }; | |
| 118 | + | |
| 119 | + return { | |
| 120 | + instanceId, | |
| 121 | + driverKind: HERMES_DRIVER_KIND, | |
| 122 | + continuationIdentity, | |
| 123 | + displayName, | |
| 124 | + accentColor, | |
| 125 | + enabled, | |
| 126 | + snapshot, | |
| 127 | + adapter, | |
| 128 | + textGeneration: makeTextGeneration(), | |
| 129 | + } satisfies ProviderInstance; | |
| 130 | + }), | |
| 131 | +}; | |
| @@ -0,0 +1,171 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import { | |
| 4 | + HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 5 | + HermesGatewayRequestId, | |
| 6 | + HermesGatewaySessionId, | |
| 7 | + ProviderInstanceId, | |
| 8 | + ThreadId, | |
| 9 | + type HermesGatewayT3ToPluginMessage, | |
| 10 | +} from "@t3tools/contracts"; | |
| 11 | +import * as Effect from "effect/Effect"; | |
| 12 | +import * as Fiber from "effect/Fiber"; | |
| 13 | +import * as Option from "effect/Option"; | |
| 14 | +import * as PubSub from "effect/PubSub"; | |
| 15 | +import * as Stream from "effect/Stream"; | |
| 16 | + | |
| 17 | +import { | |
| 18 | + HermesGatewayBroker, | |
| 19 | + type HermesGatewayBrokerShape, | |
| 20 | + type HermesGatewayEnvelope, | |
| 21 | +} from "../Services/HermesGatewayBroker.ts"; | |
| 22 | +import { | |
| 23 | + makeHermesAdapter, | |
| 24 | + sanitizeHermesItemData, | |
| 25 | + sanitizeHermesRequestArgs, | |
| 26 | + shouldProjectHermesTurnStarted, | |
| 27 | +} from "./HermesAdapter.ts"; | |
| 28 | + | |
| 29 | +it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => { | |
| 30 | + assert.deepEqual( | |
| 31 | + sanitizeHermesItemData("command_execution", { | |
| 32 | + command: "git status", | |
| 33 | + cwd: "/workspace", | |
| 34 | + arbitrarySecret: "drop-me", | |
| 35 | + result: { unbounded: true }, | |
| 36 | + }), | |
| 37 | + { command: "git status", cwd: "/workspace" }, | |
| 38 | + ); | |
| 39 | + assert.deepEqual( | |
| 40 | + sanitizeHermesRequestArgs("command_execution_approval", { | |
| 41 | + command: "git push", | |
| 42 | + arbitrarySecret: "drop-me", | |
| 43 | + }), | |
| 44 | + { command: "git push" }, | |
| 45 | + ); | |
| 46 | + const steerRequestId = HermesGatewayRequestId.make("steer-request"); | |
| 47 | + const startRequestId = HermesGatewayRequestId.make("start-request"); | |
| 48 | + const pendingSteerRequestIds = new Set([steerRequestId]); | |
| 49 | + assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId)); | |
| 50 | + assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId)); | |
| 51 | + assert.isFalse(pendingSteerRequestIds.has(steerRequestId)); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const instanceId = ProviderInstanceId.make("hermes_remote"); | |
| 57 | + const threadId = ThreadId.make("thread-1"); | |
| 58 | + const sessionId = HermesGatewaySessionId.make("session-1"); | |
| 59 | + const sent: Array<HermesGatewayT3ToPluginMessage> = []; | |
| 60 | + const brokerEvents = yield* PubSub.unbounded<HermesGatewayEnvelope>(); | |
| 61 | + const broker: HermesGatewayBrokerShape = { | |
| 62 | + createEnrollment: () => Effect.die(new Error("unused")), | |
| 63 | + getInstanceStatus: () => Effect.die(new Error("unused")), | |
| 64 | + listInstances: Effect.succeed([]), | |
| 65 | + revokeInstance: () => Effect.die(new Error("unused")), | |
| 66 | + registerConnection: () => Effect.die(new Error("unused")), | |
| 67 | + receive: () => Effect.void, | |
| 68 | + disconnect: () => Effect.void, | |
| 69 | + request: (_instanceId, message) => { | |
| 70 | + sent.push(message); | |
| 71 | + if (message.type === "session.ensure") { | |
| 72 | + return Effect.succeed({ | |
| 73 | + type: "session.ready", | |
| 74 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 75 | + requestId: message.requestId, | |
| 76 | + threadId: message.threadId, | |
| 77 | + sessionId, | |
| 78 | + resumed: false, | |
| 79 | + }); | |
| 80 | + } | |
| 81 | + if (message.type === "turn.start" || message.type === "turn.steer") { | |
| 82 | + return Effect.succeed({ | |
| 83 | + type: "turn.started", | |
| 84 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 85 | + requestId: message.requestId, | |
| 86 | + threadId: message.threadId, | |
| 87 | + sessionId: message.sessionId, | |
| 88 | + turnId: message.turnId, | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + return Effect.die(new Error(`unexpected request ${message.type}`)); | |
| 92 | + }, | |
| 93 | + send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), | |
| 94 | + isConnected: () => Effect.succeed(true), | |
| 95 | + stream: Stream.fromPubSub(brokerEvents), | |
| 96 | + streamStatuses: Stream.empty, | |
| 97 | + }; | |
| 98 | + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( | |
| 99 | + Effect.provideService(HermesGatewayBroker, broker), | |
| 100 | + ); | |
| 101 | + yield* Effect.yieldNow; | |
| 102 | + const session = yield* adapter.startSession({ | |
| 103 | + threadId, | |
| 104 | + providerInstanceId: instanceId, | |
| 105 | + cwd: "/must/not/leak", | |
| 106 | + runtimeMode: "full-access", | |
| 107 | + }); | |
| 108 | + assert.equal(session.cwd, "/must/not/leak"); | |
| 109 | + const sessionEnsure = sent.find((message) => message.type === "session.ensure"); | |
| 110 | + assert.isFalse(sessionEnsure !== undefined && "cwd" in sessionEnsure); | |
| 111 | + | |
| 112 | + const original = " /help keep all whitespace \n"; | |
| 113 | + yield* adapter.sendTurn({ threadId, input: original }); | |
| 114 | + const turnStart = sent.find((message) => message.type === "turn.start"); | |
| 115 | + assert.equal(turnStart?.type === "turn.start" ? turnStart.text : undefined, original); | |
| 116 | + | |
| 117 | + if (!turnStart || turnStart.type !== "turn.start") { | |
| 118 | + return yield* Effect.die(new Error("turn.start was not sent")); | |
| 119 | + } | |
| 120 | + const startEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | |
| 121 | + Effect.forkChild({ startImmediately: true }), | |
| 122 | + ); | |
| 123 | + yield* PubSub.publish(brokerEvents, { | |
| 124 | + instanceId, | |
| 125 | + message: { | |
| 126 | + type: "turn.started", | |
| 127 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 128 | + requestId: turnStart.requestId, | |
| 129 | + threadId, | |
| 130 | + sessionId, | |
| 131 | + turnId: turnStart.turnId, | |
| 132 | + }, | |
| 133 | + }); | |
| 134 | + const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber)); | |
| 135 | + assert.equal(startEvent?.type, "turn.started"); | |
| 136 | + | |
| 137 | + yield* adapter.sendTurn({ threadId, input: "follow up" }); | |
| 138 | + const turnSteer = sent.find((message) => message.type === "turn.steer"); | |
| 139 | + if (!turnSteer || turnSteer.type !== "turn.steer") { | |
| 140 | + return yield* Effect.die(new Error("turn.steer was not sent")); | |
| 141 | + } | |
| 142 | + const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | |
| 143 | + Effect.forkChild({ startImmediately: true }), | |
| 144 | + ); | |
| 145 | + yield* PubSub.publish(brokerEvents, { | |
| 146 | + instanceId, | |
| 147 | + message: { | |
| 148 | + type: "turn.started", | |
| 149 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 150 | + requestId: turnSteer.requestId, | |
| 151 | + threadId, | |
| 152 | + sessionId, | |
| 153 | + turnId: turnSteer.turnId, | |
| 154 | + }, | |
| 155 | + }); | |
| 156 | + yield* PubSub.publish(brokerEvents, { | |
| 157 | + instanceId, | |
| 158 | + message: { | |
| 159 | + type: "content.delta", | |
| 160 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 161 | + threadId, | |
| 162 | + sessionId, | |
| 163 | + turnId: turnSteer.turnId, | |
| 164 | + streamKind: "assistant_text", | |
| 165 | + delta: "continued", | |
| 166 | + }, | |
| 167 | + }); | |
| 168 | + const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber)); | |
| 169 | + assert.equal(nextEvent?.type, "content.delta"); | |
| 170 | + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 171 | +); | |
| @@ -0,0 +1,562 @@ | ||
| 1 | +import { | |
| 2 | + EventId, | |
| 3 | + HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 4 | + HermesGatewayRequestId, | |
| 5 | + HermesGatewayResumeCursor, | |
| 6 | + HermesGatewaySessionId, | |
| 7 | + ProviderDriverKind, | |
| 8 | + RuntimeItemId, | |
| 9 | + RuntimeRequestId, | |
| 10 | + ThreadId, | |
| 11 | + TurnId, | |
| 12 | + type HermesGatewayPluginToT3Message, | |
| 13 | + type ProviderInstanceId, | |
| 14 | + type ProviderRuntimeEvent, | |
| 15 | + type ProviderSession, | |
| 16 | +} from "@t3tools/contracts"; | |
| 17 | +import * as Crypto from "effect/Crypto"; | |
| 18 | +import * as DateTime from "effect/DateTime"; | |
| 19 | +import * as Effect from "effect/Effect"; | |
| 20 | +import * as PubSub from "effect/PubSub"; | |
| 21 | +import * as Schema from "effect/Schema"; | |
| 22 | +import * as Stream from "effect/Stream"; | |
| 23 | + | |
| 24 | +import { | |
| 25 | + ProviderAdapterRequestError, | |
| 26 | + ProviderAdapterSessionNotFoundError, | |
| 27 | + ProviderAdapterValidationError, | |
| 28 | +} from "../Errors.ts"; | |
| 29 | +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 30 | +import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; | |
| 31 | + | |
| 32 | +const PROVIDER = ProviderDriverKind.make("hermes"); | |
| 33 | +const isResumeCursor = Schema.is(HermesGatewayResumeCursor); | |
| 34 | + | |
| 35 | +type HermesAdapterShape = ProviderAdapterShape< | |
| 36 | + ProviderAdapterRequestError | ProviderAdapterSessionNotFoundError | ProviderAdapterValidationError | |
| 37 | +>; | |
| 38 | + | |
| 39 | +interface SessionContext { | |
| 40 | + readonly hermesSessionId: HermesGatewaySessionId; | |
| 41 | + readonly turns: Array<{ readonly id: TurnId; readonly items: Array<unknown> }>; | |
| 42 | + session: ProviderSession; | |
| 43 | +} | |
| 44 | + | |
| 45 | +type PluginMessage = Exclude<HermesGatewayPluginToT3Message, { readonly type: "connection.hello" }>; | |
| 46 | + | |
| 47 | +const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); | |
| 48 | +const MAX_PERSISTED_GATEWAY_FIELD_CHARS = 4_096; | |
| 49 | + | |
| 50 | +const boundedString = (value: unknown) => | |
| 51 | + typeof value === "string" && value.length <= MAX_PERSISTED_GATEWAY_FIELD_CHARS | |
| 52 | + ? value | |
| 53 | + : undefined; | |
| 54 | + | |
| 55 | +const asRecord = (value: unknown) => | |
| 56 | + typeof value === "object" && value !== null | |
| 57 | + ? (value as Readonly<Record<string, unknown>>) | |
| 58 | + : undefined; | |
| 59 | + | |
| 60 | +export const sanitizeHermesItemData = ( | |
| 61 | + itemType: Extract<PluginMessage, { readonly type: "item.started" }>["itemType"], | |
| 62 | + value: unknown, | |
| 63 | +) => { | |
| 64 | + const record = asRecord(value); | |
| 65 | + if (!record) return undefined; | |
| 66 | + const fields = | |
| 67 | + itemType === "command_execution" | |
| 68 | + ? ["command", "cwd"] | |
| 69 | + : itemType === "file_change" | |
| 70 | + ? ["path"] | |
| 71 | + : itemType === "web_search" | |
| 72 | + ? ["query"] | |
| 73 | + : itemType === "image_view" | |
| 74 | + ? ["path"] | |
| 75 | + : itemType === "mcp_tool_call" | |
| 76 | + ? ["server", "operation"] | |
| 77 | + : []; | |
| 78 | + const sanitized = Object.fromEntries( | |
| 79 | + fields.flatMap((field) => { | |
| 80 | + const value = boundedString(record[field]); | |
| 81 | + return value === undefined ? [] : [[field, value]]; | |
| 82 | + }), | |
| 83 | + ); | |
| 84 | + return Object.keys(sanitized).length > 0 ? sanitized : undefined; | |
| 85 | +}; | |
| 86 | + | |
| 87 | +export const sanitizeHermesRequestArgs = ( | |
| 88 | + requestType: Extract<PluginMessage, { readonly type: "request.opened" }>["requestType"], | |
| 89 | + value: unknown, | |
| 90 | +) => { | |
| 91 | + const record = asRecord(value); | |
| 92 | + if (!record) return undefined; | |
| 93 | + const fields = | |
| 94 | + requestType === "command_execution_approval" || requestType === "exec_command_approval" | |
| 95 | + ? ["command", "cwd"] | |
| 96 | + : requestType === "file_read_approval" || | |
| 97 | + requestType === "file_change_approval" || | |
| 98 | + requestType === "apply_patch_approval" | |
| 99 | + ? ["path"] | |
| 100 | + : []; | |
| 101 | + const sanitized = Object.fromEntries( | |
| 102 | + fields.flatMap((field) => { | |
| 103 | + const value = boundedString(record[field]); | |
| 104 | + return value === undefined ? [] : [[field, value]]; | |
| 105 | + }), | |
| 106 | + ); | |
| 107 | + return Object.keys(sanitized).length > 0 ? sanitized : undefined; | |
| 108 | +}; | |
| 109 | + | |
| 110 | +export const shouldProjectHermesTurnStarted = ( | |
| 111 | + pendingSteerRequestIds: Set<HermesGatewayRequestId>, | |
| 112 | + incomingRequestId: HermesGatewayRequestId, | |
| 113 | +) => !pendingSteerRequestIds.delete(incomingRequestId); | |
| 114 | + | |
| 115 | +export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: { | |
| 116 | + readonly instanceId: ProviderInstanceId; | |
| 117 | +}) { | |
| 118 | + const crypto = yield* Crypto.Crypto; | |
| 119 | + const broker = yield* HermesGatewayBroker; | |
| 120 | + const events = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | |
| 121 | + const sessions = new Map<ThreadId, SessionContext>(); | |
| 122 | + const pendingSteerRequestIds = new Set<HermesGatewayRequestId>(); | |
| 123 | + | |
| 124 | + const randomId = crypto.randomUUIDv4.pipe( | |
| 125 | + Effect.mapError( | |
| 126 | + (cause) => | |
| 127 | + new ProviderAdapterRequestError({ | |
| 128 | + provider: PROVIDER, | |
| 129 | + method: "crypto/randomUUIDv4", | |
| 130 | + detail: "Failed to generate a Hermes runtime identifier.", | |
| 131 | + cause, | |
| 132 | + }), | |
| 133 | + ), | |
| 134 | + ); | |
| 135 | + const requestId = randomId.pipe( | |
| 136 | + Effect.map((value) => HermesGatewayRequestId.make(`t3-${value}`)), | |
| 137 | + ); | |
| 138 | + const eventBase = (message: { | |
| 139 | + readonly threadId: ThreadId; | |
| 140 | + readonly turnId?: string | undefined; | |
| 141 | + readonly itemId?: string | undefined; | |
| 142 | + readonly requestId?: string | undefined; | |
| 143 | + }) => | |
| 144 | + randomId.pipe( | |
| 145 | + Effect.map((value) => ({ | |
| 146 | + eventId: EventId.make(value), | |
| 147 | + provider: PROVIDER, | |
| 148 | + providerInstanceId: input.instanceId, | |
| 149 | + threadId: message.threadId, | |
| 150 | + createdAt: nowIso(), | |
| 151 | + ...(message.turnId ? { turnId: TurnId.make(message.turnId) } : {}), | |
| 152 | + ...(message.itemId ? { itemId: RuntimeItemId.make(message.itemId) } : {}), | |
| 153 | + ...(message.requestId ? { requestId: RuntimeRequestId.make(message.requestId) } : {}), | |
| 154 | + })), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); | |
| 158 | + | |
| 159 | + const findContext = (threadId: ThreadId) => | |
| 160 | + Effect.gen(function* (): Effect.fn.Return<SessionContext, ProviderAdapterSessionNotFoundError> { | |
| 161 | + const context = sessions.get(threadId); | |
| 162 | + if (!context) { | |
| 163 | + return yield* new ProviderAdapterSessionNotFoundError({ | |
| 164 | + provider: PROVIDER, | |
| 165 | + threadId, | |
| 166 | + }); | |
| 167 | + } | |
| 168 | + return context; | |
| 169 | + }); | |
| 170 | + | |
| 171 | + const updateSession = (context: SessionContext, patch: Partial<ProviderSession>) => { | |
| 172 | + context.session = { | |
| 173 | + ...context.session, | |
| 174 | + ...patch, | |
| 175 | + updatedAt: nowIso(), | |
| 176 | + }; | |
| 177 | + }; | |
| 178 | + | |
| 179 | + const toRuntimeEvent = (message: PluginMessage) => | |
| 180 | + Effect.gen(function* (): Effect.fn.Return< | |
| 181 | + ProviderRuntimeEvent | undefined, | |
| 182 | + ProviderAdapterRequestError | |
| 183 | + > { | |
| 184 | + if (!("threadId" in message)) return undefined; | |
| 185 | + const context = sessions.get(message.threadId); | |
| 186 | + const base = yield* eventBase(message); | |
| 187 | + switch (message.type) { | |
| 188 | + case "session.ready": | |
| 189 | + return undefined; | |
| 190 | + case "turn.started": { | |
| 191 | + const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted( | |
| 192 | + pendingSteerRequestIds, | |
| 193 | + message.requestId, | |
| 194 | + ); | |
| 195 | + if (context) { | |
| 196 | + updateSession(context, { | |
| 197 | + status: "running", | |
| 198 | + activeTurnId: TurnId.make(message.turnId), | |
| 199 | + }); | |
| 200 | + if (!context.turns.some((turn) => turn.id === message.turnId)) { | |
| 201 | + context.turns.push({ id: TurnId.make(message.turnId), items: [] }); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + if (isSteeringAcknowledgement) return undefined; | |
| 205 | + return { | |
| 206 | + ...base, | |
| 207 | + type: "turn.started", | |
| 208 | + payload: {}, | |
| 209 | + }; | |
| 210 | + } | |
| 211 | + case "content.delta": | |
| 212 | + return { | |
| 213 | + ...base, | |
| 214 | + type: "content.delta", | |
| 215 | + payload: { | |
| 216 | + streamKind: message.streamKind, | |
| 217 | + delta: message.delta, | |
| 218 | + ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), | |
| 219 | + }, | |
| 220 | + }; | |
| 221 | + case "item.started": | |
| 222 | + case "item.updated": | |
| 223 | + case "item.completed": { | |
| 224 | + const data = sanitizeHermesItemData(message.itemType, message.data); | |
| 225 | + const payload = { | |
| 226 | + itemType: message.itemType, | |
| 227 | + ...(message.status ? { status: message.status } : {}), | |
| 228 | + ...(message.title ? { title: message.title } : {}), | |
| 229 | + ...(message.detail ? { detail: message.detail } : {}), | |
| 230 | + ...(data ? { data } : {}), | |
| 231 | + }; | |
| 232 | + const turn = context?.turns.find((entry) => entry.id === message.turnId); | |
| 233 | + if (turn && message.type === "item.completed") turn.items.push(payload); | |
| 234 | + return { ...base, type: message.type, payload }; | |
| 235 | + } | |
| 236 | + case "request.opened": { | |
| 237 | + const args = sanitizeHermesRequestArgs(message.requestType, message.args); | |
| 238 | + return { | |
| 239 | + ...base, | |
| 240 | + type: "request.opened", | |
| 241 | + payload: { | |
| 242 | + requestType: message.requestType, | |
| 243 | + ...(message.detail ? { detail: message.detail } : {}), | |
| 244 | + ...(args ? { args } : {}), | |
| 245 | + }, | |
| 246 | + }; | |
| 247 | + } | |
| 248 | + case "request.resolved": | |
| 249 | + return { | |
| 250 | + ...base, | |
| 251 | + type: "request.resolved", | |
| 252 | + payload: { | |
| 253 | + requestType: message.requestType, | |
| 254 | + ...(message.decision ? { decision: message.decision } : {}), | |
| 255 | + }, | |
| 256 | + }; | |
| 257 | + case "user-input.requested": | |
| 258 | + return { | |
| 259 | + ...base, | |
| 260 | + type: "user-input.requested", | |
| 261 | + payload: { questions: message.questions }, | |
| 262 | + }; | |
| 263 | + case "user-input.resolved": | |
| 264 | + return { | |
| 265 | + ...base, | |
| 266 | + type: "user-input.resolved", | |
| 267 | + payload: { answers: message.answers }, | |
| 268 | + }; | |
| 269 | + case "turn.completed": | |
| 270 | + if (context) { | |
| 271 | + updateSession(context, { status: message.state === "failed" ? "error" : "ready" }); | |
| 272 | + const { activeTurnId: _activeTurnId, ...session } = context.session; | |
| 273 | + context.session = session; | |
| 274 | + } | |
| 275 | + return { | |
| 276 | + ...base, | |
| 277 | + type: "turn.completed", | |
| 278 | + payload: { | |
| 279 | + state: message.state, | |
| 280 | + ...(message.stopReason !== undefined ? { stopReason: message.stopReason } : {}), | |
| 281 | + ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}), | |
| 282 | + }, | |
| 283 | + }; | |
| 284 | + case "turn.aborted": | |
| 285 | + if (context) { | |
| 286 | + const { activeTurnId: _activeTurnId, ...session } = context.session; | |
| 287 | + context.session = { ...session, status: "ready", updatedAt: nowIso() }; | |
| 288 | + } | |
| 289 | + return { | |
| 290 | + ...base, | |
| 291 | + type: "turn.aborted", | |
| 292 | + payload: { reason: message.reason }, | |
| 293 | + }; | |
| 294 | + case "session.exited": | |
| 295 | + if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" }); | |
| 296 | + return { | |
| 297 | + ...base, | |
| 298 | + type: "session.exited", | |
| 299 | + payload: { | |
| 300 | + ...(message.reason ? { reason: message.reason } : {}), | |
| 301 | + recoverable: message.recoverable, | |
| 302 | + exitKind: message.recoverable ? "error" : "graceful", | |
| 303 | + }, | |
| 304 | + }; | |
| 305 | + default: | |
| 306 | + return undefined; | |
| 307 | + } | |
| 308 | + }); | |
| 309 | + | |
| 310 | + yield* broker.stream.pipe( | |
| 311 | + Stream.filter((envelope) => envelope.instanceId === input.instanceId), | |
| 312 | + Stream.runForEach((envelope) => | |
| 313 | + toRuntimeEvent(envelope.message).pipe( | |
| 314 | + Effect.flatMap((event) => (event ? emit(event) : Effect.void)), | |
| 315 | + ), | |
| 316 | + ), | |
| 317 | + Effect.forkScoped, | |
| 318 | + ); | |
| 319 | + | |
| 320 | + const startSession: HermesAdapterShape["startSession"] = Effect.fn("startSession")( | |
| 321 | + function* (sessionInput) { | |
| 322 | + const connected = yield* broker.isConnected(input.instanceId); | |
| 323 | + if (!connected) { | |
| 324 | + return yield* new ProviderAdapterRequestError({ | |
| 325 | + provider: PROVIDER, | |
| 326 | + method: "session.ensure", | |
| 327 | + detail: `Hermes gateway instance '${input.instanceId}' is offline.`, | |
| 328 | + }); | |
| 329 | + } | |
| 330 | + if (sessionInput.providerInstanceId && sessionInput.providerInstanceId !== input.instanceId) { | |
| 331 | + return yield* new ProviderAdapterValidationError({ | |
| 332 | + provider: PROVIDER, | |
| 333 | + operation: "startSession", | |
| 334 | + issue: `Session targets instance '${sessionInput.providerInstanceId}', expected '${input.instanceId}'.`, | |
| 335 | + }); | |
| 336 | + } | |
| 337 | + const resume = | |
| 338 | + sessionInput.resumeCursor === undefined | |
| 339 | + ? undefined | |
| 340 | + : isResumeCursor(sessionInput.resumeCursor) | |
| 341 | + ? sessionInput.resumeCursor | |
| 342 | + : undefined; | |
| 343 | + if (sessionInput.resumeCursor !== undefined && !resume) { | |
| 344 | + return yield* new ProviderAdapterValidationError({ | |
| 345 | + provider: PROVIDER, | |
| 346 | + operation: "startSession", | |
| 347 | + issue: "The Hermes resume cursor is invalid or from an unsupported protocol version.", | |
| 348 | + }); | |
| 349 | + } | |
| 350 | + const response = yield* broker.request(input.instanceId, { | |
| 351 | + type: "session.ensure", | |
| 352 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 353 | + requestId: yield* requestId, | |
| 354 | + threadId: sessionInput.threadId, | |
| 355 | + ...(resume ? { resumeSessionId: resume.sessionId } : {}), | |
| 356 | + }); | |
| 357 | + if (response.type !== "session.ready" || response.threadId !== sessionInput.threadId) { | |
| 358 | + return yield* new ProviderAdapterRequestError({ | |
| 359 | + provider: PROVIDER, | |
| 360 | + method: "session.ensure", | |
| 361 | + detail: | |
| 362 | + response.type === "protocol.error" | |
| 363 | + ? response.message | |
| 364 | + : `Expected session.ready, received '${response.type}'.`, | |
| 365 | + }); | |
| 366 | + } | |
| 367 | + const createdAt = nowIso(); | |
| 368 | + const session = { | |
| 369 | + provider: PROVIDER, | |
| 370 | + providerInstanceId: input.instanceId, | |
| 371 | + status: "ready", | |
| 372 | + runtimeMode: sessionInput.runtimeMode, | |
| 373 | + ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}), | |
| 374 | + ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}), | |
| 375 | + threadId: sessionInput.threadId, | |
| 376 | + resumeCursor: { | |
| 377 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 378 | + sessionId: response.sessionId, | |
| 379 | + } satisfies HermesGatewayResumeCursor, | |
| 380 | + createdAt, | |
| 381 | + updatedAt: createdAt, | |
| 382 | + } satisfies ProviderSession; | |
| 383 | + sessions.set(sessionInput.threadId, { | |
| 384 | + session, | |
| 385 | + hermesSessionId: response.sessionId, | |
| 386 | + turns: [], | |
| 387 | + }); | |
| 388 | + yield* emit({ | |
| 389 | + ...(yield* eventBase({ threadId: sessionInput.threadId })), | |
| 390 | + type: "session.started", | |
| 391 | + payload: { | |
| 392 | + message: response.resumed ? "Hermes session resumed" : "Hermes session started", | |
| 393 | + resume: session.resumeCursor, | |
| 394 | + }, | |
| 395 | + }); | |
| 396 | + yield* emit({ | |
| 397 | + ...(yield* eventBase({ threadId: sessionInput.threadId })), | |
| 398 | + type: "thread.started", | |
| 399 | + payload: { providerThreadId: response.sessionId }, | |
| 400 | + }); | |
| 401 | + return session; | |
| 402 | + }, | |
| 403 | + ); | |
| 404 | + | |
| 405 | + const sendTurn: HermesAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (turnInput) { | |
| 406 | + const context = yield* findContext(turnInput.threadId); | |
| 407 | + if (turnInput.attachments && turnInput.attachments.length > 0) { | |
| 408 | + return yield* new ProviderAdapterValidationError({ | |
| 409 | + provider: PROVIDER, | |
| 410 | + operation: "sendTurn", | |
| 411 | + issue: "Hermes gateway attachments are not supported yet.", | |
| 412 | + }); | |
| 413 | + } | |
| 414 | + const text = turnInput.input; | |
| 415 | + if (!text || text.trim().length === 0) { | |
| 416 | + return yield* new ProviderAdapterValidationError({ | |
| 417 | + provider: PROVIDER, | |
| 418 | + operation: "sendTurn", | |
| 419 | + issue: "Hermes turns require text input.", | |
| 420 | + }); | |
| 421 | + } | |
| 422 | + const activeTurnId = context.session.activeTurnId; | |
| 423 | + const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); | |
| 424 | + const outboundRequestId = yield* requestId; | |
| 425 | + if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId); | |
| 426 | + const response = yield* broker | |
| 427 | + .request(input.instanceId, { | |
| 428 | + type: activeTurnId ? "turn.steer" : "turn.start", | |
| 429 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 430 | + requestId: outboundRequestId, | |
| 431 | + threadId: turnInput.threadId, | |
| 432 | + sessionId: context.hermesSessionId, | |
| 433 | + turnId, | |
| 434 | + text, | |
| 435 | + }) | |
| 436 | + .pipe( | |
| 437 | + Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))), | |
| 438 | + ); | |
| 439 | + if (response.type !== "turn.started") { | |
| 440 | + pendingSteerRequestIds.delete(outboundRequestId); | |
| 441 | + return yield* new ProviderAdapterRequestError({ | |
| 442 | + provider: PROVIDER, | |
| 443 | + method: activeTurnId ? "turn.steer" : "turn.start", | |
| 444 | + detail: | |
| 445 | + response.type === "protocol.error" | |
| 446 | + ? response.message | |
| 447 | + : `Expected turn.started, received '${response.type}'.`, | |
| 448 | + }); | |
| 449 | + } | |
| 450 | + updateSession(context, { status: "running", activeTurnId: turnId }); | |
| 451 | + return { | |
| 452 | + threadId: turnInput.threadId, | |
| 453 | + turnId, | |
| 454 | + resumeCursor: context.session.resumeCursor, | |
| 455 | + }; | |
| 456 | + }); | |
| 457 | + | |
| 458 | + const adapter: HermesAdapterShape = { | |
| 459 | + provider: PROVIDER, | |
| 460 | + capabilities: { sessionModelSwitch: "unsupported" }, | |
| 461 | + startSession, | |
| 462 | + sendTurn, | |
| 463 | + interruptTurn: (threadId, selectedTurnId) => | |
| 464 | + Effect.gen(function* () { | |
| 465 | + const context = yield* findContext(threadId); | |
| 466 | + const turnId = selectedTurnId ?? context.session.activeTurnId; | |
| 467 | + if (!turnId) return; | |
| 468 | + yield* broker.send(input.instanceId, { | |
| 469 | + type: "turn.interrupt", | |
| 470 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 471 | + requestId: yield* requestId, | |
| 472 | + threadId, | |
| 473 | + sessionId: context.hermesSessionId, | |
| 474 | + turnId, | |
| 475 | + }); | |
| 476 | + }), | |
| 477 | + respondToRequest: (threadId, selectedRequestId, decision) => | |
| 478 | + Effect.gen(function* () { | |
| 479 | + const context = yield* findContext(threadId); | |
| 480 | + if (!context.session.activeTurnId) { | |
| 481 | + return yield* new ProviderAdapterValidationError({ | |
| 482 | + provider: PROVIDER, | |
| 483 | + operation: "respondToRequest", | |
| 484 | + issue: "The Hermes session has no active turn.", | |
| 485 | + }); | |
| 486 | + } | |
| 487 | + yield* broker.send(input.instanceId, { | |
| 488 | + type: "approval.respond", | |
| 489 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 490 | + requestId: HermesGatewayRequestId.make(selectedRequestId), | |
| 491 | + threadId, | |
| 492 | + sessionId: context.hermesSessionId, | |
| 493 | + turnId: context.session.activeTurnId, | |
| 494 | + decision, | |
| 495 | + }); | |
| 496 | + }), | |
| 497 | + respondToUserInput: (threadId, selectedRequestId, answers) => | |
| 498 | + Effect.gen(function* () { | |
| 499 | + const context = yield* findContext(threadId); | |
| 500 | + if (!context.session.activeTurnId) { | |
| 501 | + return yield* new ProviderAdapterValidationError({ | |
| 502 | + provider: PROVIDER, | |
| 503 | + operation: "respondToUserInput", | |
| 504 | + issue: "The Hermes session has no active turn.", | |
| 505 | + }); | |
| 506 | + } | |
| 507 | + yield* broker.send(input.instanceId, { | |
| 508 | + type: "user-input.respond", | |
| 509 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 510 | + requestId: HermesGatewayRequestId.make(selectedRequestId), | |
| 511 | + threadId, | |
| 512 | + sessionId: context.hermesSessionId, | |
| 513 | + turnId: context.session.activeTurnId, | |
| 514 | + answers, | |
| 515 | + }); | |
| 516 | + }), | |
| 517 | + stopSession: (threadId) => | |
| 518 | + Effect.gen(function* () { | |
| 519 | + const context = yield* findContext(threadId); | |
| 520 | + if (yield* broker.isConnected(input.instanceId)) { | |
| 521 | + yield* broker.send(input.instanceId, { | |
| 522 | + type: "session.stop", | |
| 523 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 524 | + requestId: yield* requestId, | |
| 525 | + threadId, | |
| 526 | + sessionId: context.hermesSessionId, | |
| 527 | + }); | |
| 528 | + } | |
| 529 | + updateSession(context, { status: "closed" }); | |
| 530 | + sessions.delete(threadId); | |
| 531 | + }), | |
| 532 | + listSessions: () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => session)), | |
| 533 | + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), | |
| 534 | + readThread: (threadId) => | |
| 535 | + findContext(threadId).pipe( | |
| 536 | + Effect.map((context) => ({ | |
| 537 | + threadId, | |
| 538 | + turns: context.turns, | |
| 539 | + })), | |
| 540 | + ), | |
| 541 | + rollbackThread: (threadId) => | |
| 542 | + findContext(threadId).pipe( | |
| 543 | + Effect.flatMap(() => | |
| 544 | + Effect.fail( | |
| 545 | + new ProviderAdapterValidationError({ | |
| 546 | + provider: PROVIDER, | |
| 547 | + operation: "rollbackThread", | |
| 548 | + issue: "Hermes gateway thread rollback is not supported.", | |
| 549 | + }), | |
| 550 | + ), | |
| 551 | + ), | |
| 552 | + ), | |
| 553 | + stopAll: () => | |
| 554 | + Effect.forEach(Array.from(sessions.keys()), (threadId) => adapter.stopSession(threadId), { | |
| 555 | + discard: true, | |
| 556 | + }), | |
| 557 | + streamEvents: Stream.fromPubSub(events), | |
| 558 | + }; | |
| 559 | + | |
| 560 | + yield* Effect.addFinalizer(() => adapter.stopAll().pipe(Effect.ignore)); | |
| 561 | + return adapter; | |
| 562 | +}); | |
| @@ -0,0 +1,544 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import { | |
| 4 | + HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 5 | + HermesGatewayCredential, | |
| 6 | + HermesGatewayRequestId, | |
| 7 | + HermesGatewaySessionId, | |
| 8 | + ProviderInstanceId, | |
| 9 | + ThreadId, | |
| 10 | + type HermesGatewayConnectionHello, | |
| 11 | + type HermesGatewayT3ToPluginMessage, | |
| 12 | +} from "@t3tools/contracts"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | +import * as Fiber from "effect/Fiber"; | |
| 15 | +import * as Context from "effect/Context"; | |
| 16 | +import * as Deferred from "effect/Deferred"; | |
| 17 | +import * as Layer from "effect/Layer"; | |
| 18 | +import * as Option from "effect/Option"; | |
| 19 | +import * as Stream from "effect/Stream"; | |
| 20 | + | |
| 21 | +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 22 | +import * as ServerSettings from "../../serverSettings.ts"; | |
| 23 | +import { HermesDriver } from "../Drivers/HermesDriver.ts"; | |
| 24 | +import type { ProviderInstance } from "../ProviderDriver.ts"; | |
| 25 | +import { | |
| 26 | + HermesGatewayBroker, | |
| 27 | + type HermesGatewayTransport, | |
| 28 | +} from "../Services/HermesGatewayBroker.ts"; | |
| 29 | +import { HermesGatewayBrokerLive, makeHermesGatewayBroker } from "./HermesGatewayBroker.ts"; | |
| 30 | + | |
| 31 | +const instanceId = ProviderInstanceId.make("hermes_remote"); | |
| 32 | +const otherInstanceId = ProviderInstanceId.make("hermes_other"); | |
| 33 | +class HermesTestInstance extends Context.Service<HermesTestInstance, ProviderInstance>()( | |
| 34 | + "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance", | |
| 35 | +) {} | |
| 36 | + | |
| 37 | +const capabilities = { | |
| 38 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 39 | + streaming: true, | |
| 40 | + activity: true, | |
| 41 | + approvals: true, | |
| 42 | + userInput: true, | |
| 43 | + attachments: false, | |
| 44 | +} as const; | |
| 45 | + | |
| 46 | +const makeSecretStore = () => { | |
| 47 | + const values = new Map<string, Uint8Array>(); | |
| 48 | + const service: ServerSecretStore.ServerSecretStore["Service"] = { | |
| 49 | + get: (name) => Effect.succeed(Option.fromUndefinedOr(values.get(name))), | |
| 50 | + set: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), | |
| 51 | + create: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), | |
| 52 | + getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), | |
| 53 | + remove: (name) => Effect.sync(() => values.delete(name)).pipe(Effect.asVoid), | |
| 54 | + }; | |
| 55 | + return service; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const makeBroker = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => | |
| 59 | + makeHermesGatewayBroker.pipe( | |
| 60 | + Effect.provide( | |
| 61 | + ServerSettings.layerTest({ | |
| 62 | + providerInstances: { | |
| 63 | + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, | |
| 64 | + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, | |
| 65 | + }, | |
| 66 | + }), | |
| 67 | + ), | |
| 68 | + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 69 | + Effect.provide(NodeServices.layer), | |
| 70 | + ); | |
| 71 | + | |
| 72 | +const hello = ( | |
| 73 | + authentication: HermesGatewayConnectionHello["authentication"], | |
| 74 | + protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 75 | +): HermesGatewayConnectionHello => ({ | |
| 76 | + type: "connection.hello", | |
| 77 | + requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), | |
| 78 | + protocolVersion, | |
| 79 | + pluginVersion: "0.1.0", | |
| 80 | + hermesVersion: "1.0.0", | |
| 81 | + capabilities: { ...capabilities, protocolVersion }, | |
| 82 | + authentication, | |
| 83 | +}); | |
| 84 | + | |
| 85 | +it.effect("authenticates before applying incompatible connection state", () => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const secrets = makeSecretStore(); | |
| 88 | + const broker = yield* makeBroker(secrets); | |
| 89 | + yield* broker.createEnrollment({ | |
| 90 | + instanceId: ProviderInstanceId.make("hermes"), | |
| 91 | + nickname: "Default Hermes", | |
| 92 | + connectorUrl: "https://t3.example.test", | |
| 93 | + }); | |
| 94 | + const enrollment = yield* broker.createEnrollment({ | |
| 95 | + instanceId, | |
| 96 | + nickname: "Remote Hermes", | |
| 97 | + connectorUrl: "https://t3.example.test", | |
| 98 | + }); | |
| 99 | + | |
| 100 | + const firstCloses: Array<number> = []; | |
| 101 | + const firstTransport: HermesGatewayTransport = { | |
| 102 | + send: () => Effect.void, | |
| 103 | + close: (code) => Effect.sync(() => firstCloses.push(code)).pipe(Effect.asVoid), | |
| 104 | + }; | |
| 105 | + const first = yield* broker.registerConnection( | |
| 106 | + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), | |
| 107 | + firstTransport, | |
| 108 | + ); | |
| 109 | + assert.isTrue(first.accepted.credential !== undefined); | |
| 110 | + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); | |
| 111 | + | |
| 112 | + const secondCloses: Array<number> = []; | |
| 113 | + const sent: Array<HermesGatewayT3ToPluginMessage> = []; | |
| 114 | + const secondTransport: HermesGatewayTransport = { | |
| 115 | + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), | |
| 116 | + close: (code) => Effect.sync(() => secondCloses.push(code)).pipe(Effect.asVoid), | |
| 117 | + }; | |
| 118 | + const second = yield* broker.registerConnection( | |
| 119 | + hello({ | |
| 120 | + type: "instance-credential", | |
| 121 | + instanceId, | |
| 122 | + credential: first.accepted.credential!, | |
| 123 | + }), | |
| 124 | + secondTransport, | |
| 125 | + ); | |
| 126 | + assert.deepEqual(firstCloses, [4001]); | |
| 127 | + | |
| 128 | + const threadId = ThreadId.make("pending-thread"); | |
| 129 | + const pendingRequestId = HermesGatewayRequestId.make("pending-session"); | |
| 130 | + const pendingRequest = yield* broker | |
| 131 | + .request(instanceId, { | |
| 132 | + type: "session.ensure", | |
| 133 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 134 | + requestId: pendingRequestId, | |
| 135 | + threadId, | |
| 136 | + }) | |
| 137 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 138 | + yield* Effect.yieldNow; | |
| 139 | + assert.equal(sent.at(-1)?.type, "session.ensure"); | |
| 140 | + | |
| 141 | + const malicious = yield* Effect.flip( | |
| 142 | + broker.registerConnection( | |
| 143 | + hello( | |
| 144 | + { | |
| 145 | + type: "instance-credential", | |
| 146 | + instanceId, | |
| 147 | + credential: HermesGatewayCredential.make("not-the-real-credential"), | |
| 148 | + }, | |
| 149 | + 2, | |
| 150 | + ), | |
| 151 | + secondTransport, | |
| 152 | + ), | |
| 153 | + ); | |
| 154 | + assert.equal(malicious.code, "invalid-authentication"); | |
| 155 | + assert.deepEqual(secondCloses, []); | |
| 156 | + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); | |
| 157 | + assert.isUndefined(pendingRequest.pollUnsafe()); | |
| 158 | + | |
| 159 | + yield* broker.receive(second, { | |
| 160 | + type: "session.ready", | |
| 161 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 162 | + requestId: pendingRequestId, | |
| 163 | + threadId, | |
| 164 | + sessionId: HermesGatewaySessionId.make("pending-session-id"), | |
| 165 | + resumed: false, | |
| 166 | + }); | |
| 167 | + assert.equal((yield* Fiber.join(pendingRequest)).type, "session.ready"); | |
| 168 | + | |
| 169 | + const incompatible = yield* Effect.flip( | |
| 170 | + broker.registerConnection( | |
| 171 | + hello( | |
| 172 | + { | |
| 173 | + type: "instance-credential", | |
| 174 | + instanceId, | |
| 175 | + credential: first.accepted.credential!, | |
| 176 | + }, | |
| 177 | + 2, | |
| 178 | + ), | |
| 179 | + secondTransport, | |
| 180 | + ), | |
| 181 | + ); | |
| 182 | + assert.equal(incompatible.code, "version-incompatible"); | |
| 183 | + assert.deepEqual(secondCloses, [4004]); | |
| 184 | + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "upgrade-required"); | |
| 185 | + | |
| 186 | + const otherEnrollment = yield* broker.createEnrollment({ | |
| 187 | + instanceId: otherInstanceId, | |
| 188 | + nickname: "Other Hermes", | |
| 189 | + connectorUrl: "https://t3.example.test", | |
| 190 | + }); | |
| 191 | + const incompatibleEnrollment = yield* Effect.flip( | |
| 192 | + broker.registerConnection( | |
| 193 | + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2), | |
| 194 | + secondTransport, | |
| 195 | + ), | |
| 196 | + ); | |
| 197 | + assert.equal(incompatibleEnrollment.code, "version-incompatible"); | |
| 198 | + const enrolledAfterUpgrade = yield* broker.registerConnection( | |
| 199 | + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }), | |
| 200 | + secondTransport, | |
| 201 | + ); | |
| 202 | + assert.equal(enrolledAfterUpgrade.instanceId, otherInstanceId); | |
| 203 | + | |
| 204 | + const revoked = yield* broker.revokeInstance(instanceId); | |
| 205 | + assert.equal(revoked.status, "revoked"); | |
| 206 | + }), | |
| 207 | +); | |
| 208 | + | |
| 209 | +it.effect("invalidates the previous credential when replacement enrollment begins", () => | |
| 210 | + Effect.gen(function* () { | |
| 211 | + const broker = yield* makeBroker(makeSecretStore()); | |
| 212 | + const enrollment = yield* broker.createEnrollment({ | |
| 213 | + instanceId, | |
| 214 | + nickname: "Remote Hermes", | |
| 215 | + connectorUrl: "https://t3.example.test", | |
| 216 | + }); | |
| 217 | + const closes: Array<number> = []; | |
| 218 | + const transport: HermesGatewayTransport = { | |
| 219 | + send: () => Effect.void, | |
| 220 | + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), | |
| 221 | + }; | |
| 222 | + const first = yield* broker.registerConnection( | |
| 223 | + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), | |
| 224 | + transport, | |
| 225 | + ); | |
| 226 | + const replacement = yield* broker.createEnrollment({ | |
| 227 | + instanceId, | |
| 228 | + nickname: "Remote Hermes", | |
| 229 | + connectorUrl: "https://t3.example.test", | |
| 230 | + }); | |
| 231 | + assert.deepEqual(closes, [4001]); | |
| 232 | + | |
| 233 | + const staleCredential = yield* Effect.flip( | |
| 234 | + broker.registerConnection( | |
| 235 | + hello({ | |
| 236 | + type: "instance-credential", | |
| 237 | + instanceId, | |
| 238 | + credential: first.accepted.credential!, | |
| 239 | + }), | |
| 240 | + transport, | |
| 241 | + ), | |
| 242 | + ); | |
| 243 | + assert.equal(staleCredential.code, "invalid-authentication"); | |
| 244 | + const replacementConnection = yield* broker.registerConnection( | |
| 245 | + hello({ type: "enrollment-token", token: replacement.oneTimeToken }), | |
| 246 | + transport, | |
| 247 | + ); | |
| 248 | + assert.equal(replacementConnection.instanceId, instanceId); | |
| 249 | + }), | |
| 250 | +); | |
| 251 | + | |
| 252 | +it.effect("accepts only the newest unconsumed enrollment token for an instance", () => | |
| 253 | + Effect.gen(function* () { | |
| 254 | + const broker = yield* makeBroker(makeSecretStore()); | |
| 255 | + const older = yield* broker.createEnrollment({ | |
| 256 | + instanceId, | |
| 257 | + nickname: "Remote Hermes", | |
| 258 | + connectorUrl: "https://t3.example.test", | |
| 259 | + }); | |
| 260 | + const newest = yield* broker.createEnrollment({ | |
| 261 | + instanceId, | |
| 262 | + nickname: "Remote Hermes", | |
| 263 | + connectorUrl: "https://t3.example.test", | |
| 264 | + }); | |
| 265 | + const transport: HermesGatewayTransport = { | |
| 266 | + send: () => Effect.void, | |
| 267 | + close: () => Effect.void, | |
| 268 | + }; | |
| 269 | + | |
| 270 | + const olderTokenError = yield* Effect.flip( | |
| 271 | + broker.registerConnection( | |
| 272 | + hello({ type: "enrollment-token", token: older.oneTimeToken }), | |
| 273 | + transport, | |
| 274 | + ), | |
| 275 | + ); | |
| 276 | + assert.equal(olderTokenError.code, "enrollment-expired"); | |
| 277 | + const newestConnection = yield* broker.registerConnection( | |
| 278 | + hello({ type: "enrollment-token", token: newest.oneTimeToken }), | |
| 279 | + transport, | |
| 280 | + ); | |
| 281 | + assert.equal(newestConnection.instanceId, instanceId); | |
| 282 | + }), | |
| 283 | +); | |
| 284 | + | |
| 285 | +it.effect("atomically reserves normalized nicknames across concurrent enrollments", () => | |
| 286 | + Effect.gen(function* () { | |
| 287 | + const storedSecrets = makeSecretStore(); | |
| 288 | + const firstPersistStarted = yield* Deferred.make<void>(); | |
| 289 | + const releaseFirstPersist = yield* Deferred.make<void>(); | |
| 290 | + let metadataPersistCount = 0; | |
| 291 | + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { | |
| 292 | + ...storedSecrets, | |
| 293 | + set: (name, value) => | |
| 294 | + name.startsWith("hermes-gateway-metadata-") | |
| 295 | + ? Effect.gen(function* () { | |
| 296 | + metadataPersistCount += 1; | |
| 297 | + if (metadataPersistCount === 1) { | |
| 298 | + yield* Deferred.succeed(firstPersistStarted, undefined); | |
| 299 | + yield* Deferred.await(releaseFirstPersist); | |
| 300 | + } | |
| 301 | + yield* storedSecrets.set(name, value); | |
| 302 | + }) | |
| 303 | + : storedSecrets.set(name, value), | |
| 304 | + }; | |
| 305 | + const broker = yield* makeBroker(secrets); | |
| 306 | + const firstEnrollment = yield* broker | |
| 307 | + .createEnrollment({ | |
| 308 | + instanceId, | |
| 309 | + nickname: "Shared Hermes", | |
| 310 | + connectorUrl: "https://t3.example.test", | |
| 311 | + }) | |
| 312 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 313 | + yield* Deferred.await(firstPersistStarted); | |
| 314 | + const secondEnrollment = yield* broker | |
| 315 | + .createEnrollment({ | |
| 316 | + instanceId: otherInstanceId, | |
| 317 | + nickname: " Shared Hermes ", | |
| 318 | + connectorUrl: "https://t3.example.test", | |
| 319 | + }) | |
| 320 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 321 | + for (let index = 0; index < 10; index += 1) { | |
| 322 | + yield* Effect.yieldNow; | |
| 323 | + } | |
| 324 | + assert.equal(metadataPersistCount, 1); | |
| 325 | + | |
| 326 | + yield* Deferred.succeed(releaseFirstPersist, undefined); | |
| 327 | + yield* Fiber.join(firstEnrollment); | |
| 328 | + const conflict = yield* Effect.flip(Fiber.join(secondEnrollment)); | |
| 329 | + assert.equal(conflict.code, "nickname-conflict"); | |
| 330 | + assert.equal(metadataPersistCount, 1); | |
| 331 | + }), | |
| 332 | +); | |
| 333 | + | |
| 334 | +it.effect("keeps a revoked instance nickname reserved", () => | |
| 335 | + Effect.gen(function* () { | |
| 336 | + const broker = yield* makeBroker(makeSecretStore()); | |
| 337 | + yield* broker.createEnrollment({ | |
| 338 | + instanceId, | |
| 339 | + nickname: "Reserved Hermes", | |
| 340 | + connectorUrl: "https://t3.example.test", | |
| 341 | + }); | |
| 342 | + assert.equal((yield* broker.revokeInstance(instanceId)).status, "revoked"); | |
| 343 | + | |
| 344 | + const conflict = yield* Effect.flip( | |
| 345 | + broker.createEnrollment({ | |
| 346 | + instanceId: otherInstanceId, | |
| 347 | + nickname: " Reserved Hermes ", | |
| 348 | + connectorUrl: "https://t3.example.test", | |
| 349 | + }), | |
| 350 | + ); | |
| 351 | + assert.equal(conflict.code, "nickname-conflict"); | |
| 352 | + }), | |
| 353 | +); | |
| 354 | + | |
| 355 | +it.effect("finalizes revocation when credential deletion fails", () => | |
| 356 | + Effect.gen(function* () { | |
| 357 | + const storedSecrets = makeSecretStore(); | |
| 358 | + let failRemovals = false; | |
| 359 | + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { | |
| 360 | + ...storedSecrets, | |
| 361 | + remove: (name) => | |
| 362 | + failRemovals | |
| 363 | + ? Effect.fail( | |
| 364 | + new ServerSecretStore.SecretStoreRemoveError({ | |
| 365 | + resource: `secret ${name}`, | |
| 366 | + cause: new Error("forced credential deletion failure"), | |
| 367 | + }), | |
| 368 | + ) | |
| 369 | + : storedSecrets.remove(name), | |
| 370 | + }; | |
| 371 | + const broker = yield* makeBroker(secrets); | |
| 372 | + const enrollment = yield* broker.createEnrollment({ | |
| 373 | + instanceId, | |
| 374 | + nickname: "Remote Hermes", | |
| 375 | + connectorUrl: "https://t3.example.test", | |
| 376 | + }); | |
| 377 | + const closes: Array<number> = []; | |
| 378 | + const sent: Array<HermesGatewayT3ToPluginMessage> = []; | |
| 379 | + const registration = yield* broker.registerConnection( | |
| 380 | + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), | |
| 381 | + { | |
| 382 | + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), | |
| 383 | + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), | |
| 384 | + }, | |
| 385 | + ); | |
| 386 | + const credential = registration.accepted.credential; | |
| 387 | + if (!credential) { | |
| 388 | + return yield* Effect.die(new Error("enrollment did not issue a credential")); | |
| 389 | + } | |
| 390 | + | |
| 391 | + const threadId = ThreadId.make("revoke-pending-thread"); | |
| 392 | + const pendingRequestId = HermesGatewayRequestId.make("revoke-pending-request"); | |
| 393 | + const pendingRequest = yield* broker | |
| 394 | + .request(instanceId, { | |
| 395 | + type: "session.ensure", | |
| 396 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 397 | + requestId: pendingRequestId, | |
| 398 | + threadId, | |
| 399 | + }) | |
| 400 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 401 | + yield* Effect.yieldNow; | |
| 402 | + assert.equal(sent.at(-1)?.type, "session.ensure"); | |
| 403 | + const statusEvent = yield* Stream.runHead(broker.streamStatuses).pipe( | |
| 404 | + Effect.forkChild({ startImmediately: true }), | |
| 405 | + ); | |
| 406 | + | |
| 407 | + failRemovals = true; | |
| 408 | + const revokeError = yield* Effect.flip(broker.revokeInstance(instanceId)); | |
| 409 | + assert.equal(revokeError.code, "persistence-failed"); | |
| 410 | + assert.deepEqual(closes, [4003]); | |
| 411 | + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked"); | |
| 412 | + assert.equal(Option.getOrUndefined(yield* Fiber.join(statusEvent))?.status, "revoked"); | |
| 413 | + const pendingError = yield* Effect.flip(Fiber.join(pendingRequest)); | |
| 414 | + assert.include(pendingError.detail, "revoked"); | |
| 415 | + | |
| 416 | + const staleReconnect = yield* Effect.flip( | |
| 417 | + broker.registerConnection(hello({ type: "instance-credential", instanceId, credential }), { | |
| 418 | + send: () => Effect.void, | |
| 419 | + close: () => Effect.void, | |
| 420 | + }), | |
| 421 | + ); | |
| 422 | + assert.equal(staleReconnect.code, "instance-revoked"); | |
| 423 | + | |
| 424 | + const restartedBroker = yield* makeBroker(secrets); | |
| 425 | + const staleReconnectAfterRestart = yield* Effect.flip( | |
| 426 | + restartedBroker.registerConnection( | |
| 427 | + hello({ type: "instance-credential", instanceId, credential }), | |
| 428 | + { | |
| 429 | + send: () => Effect.void, | |
| 430 | + close: () => Effect.void, | |
| 431 | + }, | |
| 432 | + ), | |
| 433 | + ); | |
| 434 | + assert.equal(staleReconnectAfterRestart.code, "instance-revoked"); | |
| 435 | + }), | |
| 436 | +); | |
| 437 | + | |
| 438 | +it.effect("shares one live broker between the gateway route and Hermes provider instance", () => { | |
| 439 | + const secrets = makeSecretStore(); | |
| 440 | + const providerLayer = Layer.effect( | |
| 441 | + HermesTestInstance, | |
| 442 | + HermesDriver.create({ | |
| 443 | + instanceId, | |
| 444 | + displayName: "Remote Hermes", | |
| 445 | + environment: [], | |
| 446 | + enabled: true, | |
| 447 | + config: HermesDriver.defaultConfig(), | |
| 448 | + }), | |
| 449 | + ).pipe( | |
| 450 | + Layer.provideMerge(HermesGatewayBrokerLive), | |
| 451 | + Layer.provide( | |
| 452 | + ServerSettings.layerTest({ | |
| 453 | + providerInstances: { | |
| 454 | + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, | |
| 455 | + }, | |
| 456 | + }), | |
| 457 | + ), | |
| 458 | + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, secrets)), | |
| 459 | + Layer.provide(NodeServices.layer), | |
| 460 | + ); | |
| 461 | + | |
| 462 | + return Effect.gen(function* () { | |
| 463 | + const broker = yield* HermesGatewayBroker; | |
| 464 | + const provider = yield* HermesTestInstance; | |
| 465 | + const enrollment = yield* broker.createEnrollment({ | |
| 466 | + instanceId, | |
| 467 | + nickname: "Remote Hermes", | |
| 468 | + connectorUrl: "https://t3.example.test", | |
| 469 | + }); | |
| 470 | + const sent: Array<HermesGatewayT3ToPluginMessage> = []; | |
| 471 | + const registration = yield* broker.registerConnection( | |
| 472 | + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), | |
| 473 | + { | |
| 474 | + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), | |
| 475 | + close: () => Effect.void, | |
| 476 | + }, | |
| 477 | + ); | |
| 478 | + assert.equal((yield* provider.snapshot.getSnapshot).status, "ready"); | |
| 479 | + | |
| 480 | + const threadId = ThreadId.make("shared-broker-thread"); | |
| 481 | + const startSession = yield* provider.adapter | |
| 482 | + .startSession({ | |
| 483 | + threadId, | |
| 484 | + providerInstanceId: instanceId, | |
| 485 | + runtimeMode: "full-access", | |
| 486 | + }) | |
| 487 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 488 | + yield* Effect.yieldNow; | |
| 489 | + const ensure = sent.at(-1); | |
| 490 | + if (!ensure || ensure.type !== "session.ensure") { | |
| 491 | + return yield* Effect.die(new Error("session.ensure did not reach the gateway transport")); | |
| 492 | + } | |
| 493 | + const sessionId = HermesGatewaySessionId.make("shared-broker-session"); | |
| 494 | + yield* broker.receive(registration, { | |
| 495 | + type: "session.ready", | |
| 496 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 497 | + requestId: ensure.requestId, | |
| 498 | + threadId, | |
| 499 | + sessionId, | |
| 500 | + resumed: false, | |
| 501 | + }); | |
| 502 | + yield* Fiber.join(startSession); | |
| 503 | + | |
| 504 | + const sendTurn = yield* provider.adapter | |
| 505 | + .sendTurn({ threadId, input: "hello through the shared broker" }) | |
| 506 | + .pipe(Effect.forkChild({ startImmediately: true })); | |
| 507 | + yield* Effect.yieldNow; | |
| 508 | + const turnStart = sent.at(-1); | |
| 509 | + if (!turnStart || turnStart.type !== "turn.start") { | |
| 510 | + return yield* Effect.die(new Error("turn.start did not reach the gateway transport")); | |
| 511 | + } | |
| 512 | + yield* broker.receive(registration, { | |
| 513 | + type: "turn.started", | |
| 514 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 515 | + requestId: turnStart.requestId, | |
| 516 | + threadId, | |
| 517 | + sessionId, | |
| 518 | + turnId: turnStart.turnId, | |
| 519 | + }); | |
| 520 | + assert.equal((yield* Fiber.join(sendTurn)).turnId, turnStart.turnId); | |
| 521 | + }).pipe(Effect.provide(providerLayer), Effect.scoped); | |
| 522 | +}); | |
| 523 | + | |
| 524 | +it.effect("checks nickname uniqueness from persisted metadata after restart", () => | |
| 525 | + Effect.gen(function* () { | |
| 526 | + const secrets = makeSecretStore(); | |
| 527 | + const firstBroker = yield* makeBroker(secrets); | |
| 528 | + yield* firstBroker.createEnrollment({ | |
| 529 | + instanceId, | |
| 530 | + nickname: "Persistent Nickname", | |
| 531 | + connectorUrl: "https://t3.example.test", | |
| 532 | + }); | |
| 533 | + | |
| 534 | + const restartedBroker = yield* makeBroker(secrets); | |
| 535 | + const error = yield* Effect.flip( | |
| 536 | + restartedBroker.createEnrollment({ | |
| 537 | + instanceId: otherInstanceId, | |
| 538 | + nickname: "Persistent Nickname", | |
| 539 | + connectorUrl: "https://t3.example.test", | |
| 540 | + }), | |
| 541 | + ); | |
| 542 | + assert.equal(error.code, "nickname-conflict"); | |
| 543 | + }), | |
| 544 | +); | |
| @@ -0,0 +1,892 @@ | ||
| 1 | +import * as NodeCrypto from "node:crypto"; | |
| 2 | +import { | |
| 3 | + HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 4 | + HERMES_DRIVER_KIND, | |
| 5 | + defaultInstanceIdForDriver, | |
| 6 | + HermesGatewayCredential, | |
| 7 | + HermesGatewayEnrollmentToken, | |
| 8 | + HermesGatewayCapabilities, | |
| 9 | + HermesGatewayManagementError, | |
| 10 | + type HermesGatewayConnectionHello, | |
| 11 | + type HermesGatewayCreateEnrollmentInput, | |
| 12 | + type HermesGatewayEnrollmentResult, | |
| 13 | + type HermesGatewayInstanceStatus, | |
| 14 | + type HermesGatewayPluginToT3Message, | |
| 15 | + type HermesGatewayRevokeInstanceResult, | |
| 16 | + type HermesGatewayT3ToPluginMessage, | |
| 17 | + type ProviderInstanceId, | |
| 18 | +} from "@t3tools/contracts"; | |
| 19 | +import * as Crypto from "effect/Crypto"; | |
| 20 | +import * as Clock from "effect/Clock"; | |
| 21 | +import * as DateTime from "effect/DateTime"; | |
| 22 | +import * as Deferred from "effect/Deferred"; | |
| 23 | +import * as Duration from "effect/Duration"; | |
| 24 | +import * as Effect from "effect/Effect"; | |
| 25 | +import * as Encoding from "effect/Encoding"; | |
| 26 | +import * as Layer from "effect/Layer"; | |
| 27 | +import * as Option from "effect/Option"; | |
| 28 | +import * as PubSub from "effect/PubSub"; | |
| 29 | +import * as Ref from "effect/Ref"; | |
| 30 | +import * as Schema from "effect/Schema"; | |
| 31 | +import * as Semaphore from "effect/Semaphore"; | |
| 32 | +import * as Stream from "effect/Stream"; | |
| 33 | + | |
| 34 | +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 35 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 36 | +import { ProviderAdapterRequestError } from "../Errors.ts"; | |
| 37 | +import { | |
| 38 | + HermesGatewayBroker, | |
| 39 | + type HermesGatewayConnectionRegistration, | |
| 40 | + type HermesGatewayEnvelope, | |
| 41 | + type HermesGatewayTransport, | |
| 42 | + type HermesGatewayBrokerShape, | |
| 43 | +} from "../Services/HermesGatewayBroker.ts"; | |
| 44 | + | |
| 45 | +const ENROLLMENT_TTL = Duration.minutes(10); | |
| 46 | +const REQUEST_TIMEOUT = Duration.seconds(30); | |
| 47 | +const textEncoder = new TextEncoder(); | |
| 48 | +const textDecoder = new TextDecoder(); | |
| 49 | +const isStrictCapabilities = Schema.is(HermesGatewayCapabilities); | |
| 50 | + | |
| 51 | +interface PendingEnrollment { | |
| 52 | + readonly input: HermesGatewayCreateEnrollmentInput; | |
| 53 | + readonly expiresAtMillis: number; | |
| 54 | +} | |
| 55 | + | |
| 56 | +interface InstanceMetadata { | |
| 57 | + readonly nickname: string; | |
| 58 | + readonly connectorUrl: string; | |
| 59 | + readonly revoked: boolean; | |
| 60 | + readonly lastSeen?: { | |
| 61 | + readonly pluginVersion: string; | |
| 62 | + readonly hermesVersion: string; | |
| 63 | + readonly capabilities: NonNullable<HermesGatewayInstanceStatus["capabilities"]>; | |
| 64 | + readonly connectedAt: string; | |
| 65 | + readonly activeSessionCount: number; | |
| 66 | + }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +interface ActiveConnection { | |
| 70 | + readonly generation: number; | |
| 71 | + readonly transport: HermesGatewayTransport; | |
| 72 | + readonly pluginVersion: string; | |
| 73 | + readonly hermesVersion: string; | |
| 74 | + readonly capabilities: NonNullable<HermesGatewayInstanceStatus["capabilities"]>; | |
| 75 | + readonly connectedAt: string; | |
| 76 | + readonly activeSessionCount: number; | |
| 77 | +} | |
| 78 | + | |
| 79 | +interface InstanceState { | |
| 80 | + readonly metadata: InstanceMetadata; | |
| 81 | + readonly connection?: ActiveConnection; | |
| 82 | + readonly lastSeen?: Omit<ActiveConnection, "transport" | "generation" | "activeSessionCount"> & { | |
| 83 | + readonly activeSessionCount: number; | |
| 84 | + }; | |
| 85 | + readonly upgradeRequired?: { | |
| 86 | + readonly pluginVersion: string; | |
| 87 | + readonly hermesVersion: string; | |
| 88 | + readonly protocolVersion: number; | |
| 89 | + }; | |
| 90 | +} | |
| 91 | + | |
| 92 | +type PluginMessage = Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>; | |
| 93 | + | |
| 94 | +const credentialSecretName = (instanceId: ProviderInstanceId) => | |
| 95 | + `hermes-gateway-credential-${Buffer.from(instanceId, "utf8").toString("base64url")}`; | |
| 96 | + | |
| 97 | +const metadataSecretName = (instanceId: ProviderInstanceId) => | |
| 98 | + `hermes-gateway-metadata-${Buffer.from(instanceId, "utf8").toString("base64url")}`; | |
| 99 | + | |
| 100 | +const managementError = ( | |
| 101 | + operation: HermesGatewayManagementError["operation"], | |
| 102 | + code: HermesGatewayManagementError["code"], | |
| 103 | + message: string, | |
| 104 | + instanceId?: ProviderInstanceId, | |
| 105 | +) => | |
| 106 | + new HermesGatewayManagementError({ | |
| 107 | + operation, | |
| 108 | + code, | |
| 109 | + message, | |
| 110 | + ...(instanceId ? { instanceId } : {}), | |
| 111 | + }); | |
| 112 | + | |
| 113 | +const rejection = ( | |
| 114 | + requestId: HermesGatewayConnectionHello["requestId"], | |
| 115 | + code: Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }>["code"], | |
| 116 | + message: string, | |
| 117 | +): Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }> => ({ | |
| 118 | + type: "connection.rejected", | |
| 119 | + requestId, | |
| 120 | + code, | |
| 121 | + message, | |
| 122 | + expectedProtocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 123 | +}); | |
| 124 | + | |
| 125 | +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; | |
| 126 | + | |
| 127 | +const credentialsEqual = (left: Uint8Array, right: string) => { | |
| 128 | + const rightBytes = textEncoder.encode(right); | |
| 129 | + return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes); | |
| 130 | +}; | |
| 131 | + | |
| 132 | +const statusFromState = ( | |
| 133 | + instanceId: ProviderInstanceId, | |
| 134 | + state: InstanceState, | |
| 135 | +): HermesGatewayInstanceStatus => ({ | |
| 136 | + ...(() => { | |
| 137 | + const observed = state.connection ?? state.lastSeen ?? state.metadata.lastSeen; | |
| 138 | + return { | |
| 139 | + lastConnectedAt: observed?.connectedAt ?? null, | |
| 140 | + pluginVersion: observed?.pluginVersion ?? state.upgradeRequired?.pluginVersion ?? null, | |
| 141 | + hermesVersion: observed?.hermesVersion ?? state.upgradeRequired?.hermesVersion ?? null, | |
| 142 | + activeSessionCount: state.connection?.activeSessionCount ?? 0, | |
| 143 | + protocolVersion: | |
| 144 | + observed?.capabilities.protocolVersion ?? state.upgradeRequired?.protocolVersion ?? null, | |
| 145 | + capabilities: observed?.capabilities ?? null, | |
| 146 | + }; | |
| 147 | + })(), | |
| 148 | + instanceId, | |
| 149 | + nickname: state.metadata.nickname, | |
| 150 | + status: state.metadata.revoked | |
| 151 | + ? "revoked" | |
| 152 | + : state.upgradeRequired | |
| 153 | + ? "upgrade-required" | |
| 154 | + : state.connection | |
| 155 | + ? "connected" | |
| 156 | + : "offline", | |
| 157 | + connectorUrl: state.metadata.connectorUrl, | |
| 158 | +}); | |
| 159 | + | |
| 160 | +export const makeHermesGatewayBroker = Effect.gen(function* () { | |
| 161 | + const crypto = yield* Crypto.Crypto; | |
| 162 | + const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 163 | + const settings = yield* ServerSettingsService; | |
| 164 | + const states = yield* Ref.make(new Map<ProviderInstanceId, InstanceState>()); | |
| 165 | + const enrollments = yield* Ref.make(new Map<string, PendingEnrollment>()); | |
| 166 | + const pendingRequests = yield* Ref.make( | |
| 167 | + new Map< | |
| 168 | + string, | |
| 169 | + { | |
| 170 | + readonly instanceId: ProviderInstanceId; | |
| 171 | + readonly deferred: Deferred.Deferred<PluginMessage, ProviderAdapterRequestError>; | |
| 172 | + } | |
| 173 | + >(), | |
| 174 | + ); | |
| 175 | + const events = yield* PubSub.unbounded<HermesGatewayEnvelope>(); | |
| 176 | + const statusEvents = yield* PubSub.unbounded<HermesGatewayInstanceStatus>(); | |
| 177 | + const generation = yield* Ref.make(0); | |
| 178 | + const enrollmentSemaphore = yield* Semaphore.make(1); | |
| 179 | + | |
| 180 | + const publishStatus = (instanceId: ProviderInstanceId, state: InstanceState) => | |
| 181 | + PubSub.publish(statusEvents, statusFromState(instanceId, state)).pipe(Effect.asVoid); | |
| 182 | + | |
| 183 | + const failPendingRequests = (instanceId: ProviderInstanceId, detail: string) => | |
| 184 | + Effect.gen(function* () { | |
| 185 | + const affected = yield* Ref.modify(pendingRequests, (current) => { | |
| 186 | + const next = new Map(current); | |
| 187 | + const found = Array.from(current.entries()).filter( | |
| 188 | + ([, pending]) => pending.instanceId === instanceId, | |
| 189 | + ); | |
| 190 | + for (const [requestId] of found) next.delete(requestId); | |
| 191 | + return [found.map(([, pending]) => pending.deferred), next] as const; | |
| 192 | + }); | |
| 193 | + yield* Effect.forEach( | |
| 194 | + affected, | |
| 195 | + (deferred) => | |
| 196 | + Deferred.fail( | |
| 197 | + deferred, | |
| 198 | + new ProviderAdapterRequestError({ | |
| 199 | + provider: HERMES_DRIVER_KIND, | |
| 200 | + method: "connection", | |
| 201 | + detail, | |
| 202 | + }), | |
| 203 | + ), | |
| 204 | + { discard: true }, | |
| 205 | + ); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + const persistMetadata = (instanceId: ProviderInstanceId, metadata: InstanceMetadata) => | |
| 209 | + secretStore | |
| 210 | + .set(metadataSecretName(instanceId), textEncoder.encode(JSON.stringify(metadata))) | |
| 211 | + .pipe( | |
| 212 | + Effect.mapError(() => | |
| 213 | + managementError( | |
| 214 | + "create-enrollment", | |
| 215 | + "persistence-failed", | |
| 216 | + "Failed to persist Hermes gateway metadata.", | |
| 217 | + instanceId, | |
| 218 | + ), | |
| 219 | + ), | |
| 220 | + ); | |
| 221 | + | |
| 222 | + const readMetadata = (instanceId: ProviderInstanceId) => | |
| 223 | + secretStore.get(metadataSecretName(instanceId)).pipe( | |
| 224 | + Effect.map((stored) => { | |
| 225 | + if (Option.isNone(stored)) return undefined; | |
| 226 | + try { | |
| 227 | + const parsed: unknown = JSON.parse(textDecoder.decode(stored.value)); | |
| 228 | + if ( | |
| 229 | + typeof parsed === "object" && | |
| 230 | + parsed !== null && | |
| 231 | + "nickname" in parsed && | |
| 232 | + typeof parsed.nickname === "string" && | |
| 233 | + "connectorUrl" in parsed && | |
| 234 | + typeof parsed.connectorUrl === "string" && | |
| 235 | + "revoked" in parsed && | |
| 236 | + typeof parsed.revoked === "boolean" | |
| 237 | + ) { | |
| 238 | + return { | |
| 239 | + nickname: parsed.nickname, | |
| 240 | + connectorUrl: parsed.connectorUrl, | |
| 241 | + revoked: parsed.revoked, | |
| 242 | + ...("lastSeen" in parsed && | |
| 243 | + typeof parsed.lastSeen === "object" && | |
| 244 | + parsed.lastSeen !== null && | |
| 245 | + "pluginVersion" in parsed.lastSeen && | |
| 246 | + typeof parsed.lastSeen.pluginVersion === "string" && | |
| 247 | + "hermesVersion" in parsed.lastSeen && | |
| 248 | + typeof parsed.lastSeen.hermesVersion === "string" && | |
| 249 | + "connectedAt" in parsed.lastSeen && | |
| 250 | + typeof parsed.lastSeen.connectedAt === "string" && | |
| 251 | + "activeSessionCount" in parsed.lastSeen && | |
| 252 | + typeof parsed.lastSeen.activeSessionCount === "number" && | |
| 253 | + "capabilities" in parsed.lastSeen && | |
| 254 | + isStrictCapabilities(parsed.lastSeen.capabilities) | |
| 255 | + ? { | |
| 256 | + lastSeen: { | |
| 257 | + pluginVersion: parsed.lastSeen.pluginVersion, | |
| 258 | + hermesVersion: parsed.lastSeen.hermesVersion, | |
| 259 | + connectedAt: parsed.lastSeen.connectedAt, | |
| 260 | + activeSessionCount: parsed.lastSeen.activeSessionCount, | |
| 261 | + capabilities: parsed.lastSeen.capabilities, | |
| 262 | + }, | |
| 263 | + } | |
| 264 | + : {}), | |
| 265 | + } satisfies InstanceMetadata; | |
| 266 | + } | |
| 267 | + } catch { | |
| 268 | + return undefined; | |
| 269 | + } | |
| 270 | + return undefined; | |
| 271 | + }), | |
| 272 | + Effect.orElseSucceed(() => undefined), | |
| 273 | + ); | |
| 274 | + | |
| 275 | + const getState = (instanceId: ProviderInstanceId) => | |
| 276 | + Ref.get(states).pipe( | |
| 277 | + Effect.map((current) => current.get(instanceId)), | |
| 278 | + Effect.flatMap((state) => { | |
| 279 | + if (state) return Effect.succeed(state); | |
| 280 | + return readMetadata(instanceId).pipe( | |
| 281 | + Effect.flatMap((metadata) => { | |
| 282 | + if (!metadata) { | |
| 283 | + return Effect.fail( | |
| 284 | + managementError( | |
| 285 | + "get-status", | |
| 286 | + "instance-not-found", | |
| 287 | + `Hermes gateway instance '${instanceId}' has not been enrolled.`, | |
| 288 | + instanceId, | |
| 289 | + ), | |
| 290 | + ); | |
| 291 | + } | |
| 292 | + const loaded: InstanceState = { metadata }; | |
| 293 | + return Ref.update(states, (current) => new Map(current).set(instanceId, loaded)).pipe( | |
| 294 | + Effect.as(loaded), | |
| 295 | + ); | |
| 296 | + }), | |
| 297 | + ); | |
| 298 | + }), | |
| 299 | + ); | |
| 300 | + | |
| 301 | + const createEnrollment = (input: HermesGatewayCreateEnrollmentInput) => | |
| 302 | + enrollmentSemaphore.withPermits(1)( | |
| 303 | + Effect.gen(function* () { | |
| 304 | + const normalizedNickname = input.nickname.trim(); | |
| 305 | + const normalizedInput = { | |
| 306 | + ...input, | |
| 307 | + nickname: normalizedNickname, | |
| 308 | + } satisfies HermesGatewayCreateEnrollmentInput; | |
| 309 | + const currentSettings = yield* settings.getSettings.pipe( | |
| 310 | + Effect.mapError(() => | |
| 311 | + managementError( | |
| 312 | + "create-enrollment", | |
| 313 | + "internal-error", | |
| 314 | + "Failed to read server settings.", | |
| 315 | + input.instanceId, | |
| 316 | + ), | |
| 317 | + ), | |
| 318 | + ); | |
| 319 | + const configured = currentSettings.providerInstances[input.instanceId]; | |
| 320 | + const isDefaultHermesInstance = | |
| 321 | + input.instanceId === defaultInstanceIdForDriver(HERMES_DRIVER_KIND); | |
| 322 | + if ((!configured || configured.driver !== HERMES_DRIVER_KIND) && !isDefaultHermesInstance) { | |
| 323 | + return yield* managementError( | |
| 324 | + "create-enrollment", | |
| 325 | + "instance-not-found", | |
| 326 | + `Provider instance '${input.instanceId}' is not configured with the Hermes driver.`, | |
| 327 | + input.instanceId, | |
| 328 | + ); | |
| 329 | + } | |
| 330 | + | |
| 331 | + const configuredHermesIds = [ | |
| 332 | + defaultInstanceIdForDriver(HERMES_DRIVER_KIND), | |
| 333 | + ...Object.entries(currentSettings.providerInstances) | |
| 334 | + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) | |
| 335 | + .map(([instanceId]) => instanceId as ProviderInstanceId), | |
| 336 | + ]; | |
| 337 | + const existingStates = yield* Ref.get(states); | |
| 338 | + for (const instanceId of new Set(configuredHermesIds)) { | |
| 339 | + const persistedMetadata = existingStates.has(instanceId) | |
| 340 | + ? undefined | |
| 341 | + : yield* readMetadata(instanceId); | |
| 342 | + const state = | |
| 343 | + existingStates.get(instanceId) ?? | |
| 344 | + (persistedMetadata ? { metadata: persistedMetadata } : undefined); | |
| 345 | + if (!state) continue; | |
| 346 | + if (instanceId !== input.instanceId && state.metadata.nickname === normalizedNickname) { | |
| 347 | + return yield* managementError( | |
| 348 | + "create-enrollment", | |
| 349 | + "nickname-conflict", | |
| 350 | + `A Hermes gateway named '${normalizedNickname}' already exists.`, | |
| 351 | + input.instanceId, | |
| 352 | + ); | |
| 353 | + } | |
| 354 | + } | |
| 355 | + | |
| 356 | + const token = HermesGatewayEnrollmentToken.make( | |
| 357 | + Encoding.encodeBase64Url( | |
| 358 | + yield* crypto | |
| 359 | + .randomBytes(32) | |
| 360 | + .pipe( | |
| 361 | + Effect.mapError(() => | |
| 362 | + managementError( | |
| 363 | + "create-enrollment", | |
| 364 | + "internal-error", | |
| 365 | + "Failed to generate an enrollment token.", | |
| 366 | + input.instanceId, | |
| 367 | + ), | |
| 368 | + ), | |
| 369 | + ), | |
| 370 | + ), | |
| 371 | + ); | |
| 372 | + const expiresAtMillis = | |
| 373 | + (yield* Clock.currentTimeMillis) + Duration.toMillis(ENROLLMENT_TTL); | |
| 374 | + const previousState = existingStates.get(input.instanceId); | |
| 375 | + const metadata = { | |
| 376 | + nickname: normalizedNickname, | |
| 377 | + connectorUrl: input.connectorUrl, | |
| 378 | + revoked: false, | |
| 379 | + ...(previousState?.metadata.lastSeen | |
| 380 | + ? { lastSeen: previousState.metadata.lastSeen } | |
| 381 | + : {}), | |
| 382 | + } satisfies InstanceMetadata; | |
| 383 | + yield* secretStore | |
| 384 | + .remove(credentialSecretName(input.instanceId)) | |
| 385 | + .pipe( | |
| 386 | + Effect.mapError(() => | |
| 387 | + managementError( | |
| 388 | + "create-enrollment", | |
| 389 | + "persistence-failed", | |
| 390 | + "Failed to invalidate the previous Hermes gateway credential.", | |
| 391 | + input.instanceId, | |
| 392 | + ), | |
| 393 | + ), | |
| 394 | + ); | |
| 395 | + if (previousState?.connection) { | |
| 396 | + yield* failPendingRequests( | |
| 397 | + input.instanceId, | |
| 398 | + "A new enrollment replaced the active Hermes gateway credential.", | |
| 399 | + ); | |
| 400 | + yield* previousState.connection.transport.close( | |
| 401 | + 4001, | |
| 402 | + "A new enrollment was created for this instance", | |
| 403 | + ); | |
| 404 | + } | |
| 405 | + yield* persistMetadata(input.instanceId, metadata); | |
| 406 | + const enrolledState: InstanceState = { | |
| 407 | + metadata, | |
| 408 | + ...(previousState?.lastSeen ? { lastSeen: previousState.lastSeen } : {}), | |
| 409 | + }; | |
| 410 | + yield* Ref.update(states, (current) => | |
| 411 | + new Map(current).set(input.instanceId, enrolledState), | |
| 412 | + ); | |
| 413 | + yield* publishStatus(input.instanceId, enrolledState); | |
| 414 | + yield* Ref.update(enrollments, (current) => { | |
| 415 | + const next = new Map(current); | |
| 416 | + for (const [pendingToken, pending] of current) { | |
| 417 | + if (pending.input.instanceId === input.instanceId) { | |
| 418 | + next.delete(pendingToken); | |
| 419 | + } | |
| 420 | + } | |
| 421 | + return next.set(token, { input: normalizedInput, expiresAtMillis }); | |
| 422 | + }); | |
| 423 | + | |
| 424 | + return { | |
| 425 | + instanceId: input.instanceId, | |
| 426 | + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)), | |
| 427 | + connectorUrl: input.connectorUrl, | |
| 428 | + command: `hermes t3 connect --url ${shellQuote(input.connectorUrl)} --token ${shellQuote(token)}`, | |
| 429 | + oneTimeToken: token, | |
| 430 | + } satisfies HermesGatewayEnrollmentResult; | |
| 431 | + }), | |
| 432 | + ); | |
| 433 | + | |
| 434 | + const getInstanceStatus = (instanceId: ProviderInstanceId) => | |
| 435 | + getState(instanceId).pipe(Effect.map((state) => statusFromState(instanceId, state))); | |
| 436 | + | |
| 437 | + const listInstances = Effect.gen(function* () { | |
| 438 | + const currentSettings = yield* settings.getSettings.pipe( | |
| 439 | + Effect.mapError(() => | |
| 440 | + managementError("list-instances", "internal-error", "Failed to read server settings."), | |
| 441 | + ), | |
| 442 | + ); | |
| 443 | + const ids = Array.from( | |
| 444 | + new Set([ | |
| 445 | + defaultInstanceIdForDriver(HERMES_DRIVER_KIND), | |
| 446 | + ...Object.entries(currentSettings.providerInstances) | |
| 447 | + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) | |
| 448 | + .map(([id]) => id as ProviderInstanceId), | |
| 449 | + ]), | |
| 450 | + ); | |
| 451 | + return yield* Effect.forEach( | |
| 452 | + ids, | |
| 453 | + (instanceId) => | |
| 454 | + getInstanceStatus(instanceId).pipe( | |
| 455 | + Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)), | |
| 456 | + ), | |
| 457 | + { concurrency: "unbounded" }, | |
| 458 | + ).pipe(Effect.map((values) => values.filter((value) => value !== undefined))); | |
| 459 | + }); | |
| 460 | + | |
| 461 | + const revokeInstance = (instanceId: ProviderInstanceId) => | |
| 462 | + Effect.gen(function* () { | |
| 463 | + const state = yield* getState(instanceId); | |
| 464 | + const metadata: InstanceMetadata = { ...state.metadata, revoked: true }; | |
| 465 | + yield* persistMetadata(instanceId, metadata).pipe( | |
| 466 | + Effect.mapError((error) => | |
| 467 | + managementError("revoke-instance", error.code, error.message, instanceId), | |
| 468 | + ), | |
| 469 | + ); | |
| 470 | + const next: InstanceState = { | |
| 471 | + metadata, | |
| 472 | + ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), | |
| 473 | + }; | |
| 474 | + yield* Ref.update(states, (current) => new Map(current).set(instanceId, next)); | |
| 475 | + if (state.connection) { | |
| 476 | + yield* failPendingRequests(instanceId, "The Hermes gateway credential was revoked."); | |
| 477 | + yield* state.connection.transport.close(4003, "Hermes gateway credential revoked"); | |
| 478 | + } | |
| 479 | + yield* publishStatus(instanceId, next); | |
| 480 | + yield* secretStore | |
| 481 | + .remove(credentialSecretName(instanceId)) | |
| 482 | + .pipe( | |
| 483 | + Effect.mapError(() => | |
| 484 | + managementError( | |
| 485 | + "revoke-instance", | |
| 486 | + "persistence-failed", | |
| 487 | + "Failed to remove the Hermes gateway credential.", | |
| 488 | + instanceId, | |
| 489 | + ), | |
| 490 | + ), | |
| 491 | + ); | |
| 492 | + return statusFromState(instanceId, next) satisfies HermesGatewayRevokeInstanceResult; | |
| 493 | + }); | |
| 494 | + | |
| 495 | + const registerConnectionEffect = ( | |
| 496 | + hello: HermesGatewayConnectionHello, | |
| 497 | + transport: HermesGatewayTransport, | |
| 498 | + ) => | |
| 499 | + Effect.gen(function* () { | |
| 500 | + const strictCapabilities = isStrictCapabilities(hello.capabilities) | |
| 501 | + ? hello.capabilities | |
| 502 | + : undefined; | |
| 503 | + | |
| 504 | + let instanceId: ProviderInstanceId; | |
| 505 | + let credential: HermesGatewayCredential | undefined; | |
| 506 | + let authenticatedEnrollment: PendingEnrollment | undefined; | |
| 507 | + let state: InstanceState; | |
| 508 | + if (hello.authentication.type === "enrollment-token") { | |
| 509 | + const authentication = hello.authentication; | |
| 510 | + const enrollment = (yield* Ref.get(enrollments)).get(authentication.token); | |
| 511 | + if (!enrollment || enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis)) { | |
| 512 | + return yield* Effect.fail( | |
| 513 | + rejection( | |
| 514 | + hello.requestId, | |
| 515 | + "enrollment-expired", | |
| 516 | + "The enrollment token is invalid, expired, or already used.", | |
| 517 | + ), | |
| 518 | + ); | |
| 519 | + } | |
| 520 | + instanceId = enrollment.input.instanceId; | |
| 521 | + authenticatedEnrollment = enrollment; | |
| 522 | + state = yield* getState(instanceId).pipe( | |
| 523 | + Effect.mapError(() => | |
| 524 | + rejection( | |
| 525 | + hello.requestId, | |
| 526 | + "invalid-authentication", | |
| 527 | + "Unknown Hermes gateway instance.", | |
| 528 | + ), | |
| 529 | + ), | |
| 530 | + ); | |
| 531 | + } else { | |
| 532 | + const authentication = hello.authentication; | |
| 533 | + instanceId = authentication.instanceId; | |
| 534 | + state = yield* getState(instanceId).pipe( | |
| 535 | + Effect.mapError(() => | |
| 536 | + rejection( | |
| 537 | + hello.requestId, | |
| 538 | + "invalid-authentication", | |
| 539 | + "Unknown Hermes gateway instance.", | |
| 540 | + ), | |
| 541 | + ), | |
| 542 | + ); | |
| 543 | + if (state.metadata.revoked) { | |
| 544 | + return yield* Effect.fail( | |
| 545 | + rejection( | |
| 546 | + hello.requestId, | |
| 547 | + "instance-revoked", | |
| 548 | + "This Hermes gateway instance has been revoked.", | |
| 549 | + ), | |
| 550 | + ); | |
| 551 | + } | |
| 552 | + const stored = yield* secretStore | |
| 553 | + .get(credentialSecretName(instanceId)) | |
| 554 | + .pipe( | |
| 555 | + Effect.mapError(() => | |
| 556 | + rejection( | |
| 557 | + hello.requestId, | |
| 558 | + "internal-error", | |
| 559 | + "Failed to read the gateway credential.", | |
| 560 | + ), | |
| 561 | + ), | |
| 562 | + ); | |
| 563 | + if (Option.isNone(stored) || !credentialsEqual(stored.value, authentication.credential)) { | |
| 564 | + return yield* Effect.fail( | |
| 565 | + rejection( | |
| 566 | + hello.requestId, | |
| 567 | + "invalid-authentication", | |
| 568 | + "The Hermes gateway credential is invalid.", | |
| 569 | + ), | |
| 570 | + ); | |
| 571 | + } | |
| 572 | + } | |
| 573 | + | |
| 574 | + if (state.metadata.revoked) { | |
| 575 | + return yield* Effect.fail( | |
| 576 | + rejection( | |
| 577 | + hello.requestId, | |
| 578 | + "instance-revoked", | |
| 579 | + "This Hermes gateway instance has been revoked.", | |
| 580 | + ), | |
| 581 | + ); | |
| 582 | + } | |
| 583 | + | |
| 584 | + if ( | |
| 585 | + hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION || | |
| 586 | + strictCapabilities === undefined | |
| 587 | + ) { | |
| 588 | + if (state.connection) { | |
| 589 | + yield* failPendingRequests( | |
| 590 | + instanceId, | |
| 591 | + "The Hermes gateway plugin requires a protocol upgrade.", | |
| 592 | + ); | |
| 593 | + yield* state.connection.transport.close( | |
| 594 | + 4004, | |
| 595 | + "Hermes gateway protocol version is incompatible", | |
| 596 | + ); | |
| 597 | + } | |
| 598 | + const upgradeState: InstanceState = { | |
| 599 | + metadata: state.metadata, | |
| 600 | + ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), | |
| 601 | + upgradeRequired: { | |
| 602 | + pluginVersion: hello.pluginVersion, | |
| 603 | + hermesVersion: hello.hermesVersion, | |
| 604 | + protocolVersion: hello.protocolVersion, | |
| 605 | + }, | |
| 606 | + }; | |
| 607 | + yield* Ref.update(states, (current) => new Map(current).set(instanceId, upgradeState)); | |
| 608 | + yield* publishStatus(instanceId, upgradeState); | |
| 609 | + return yield* Effect.fail( | |
| 610 | + rejection( | |
| 611 | + hello.requestId, | |
| 612 | + "version-incompatible", | |
| 613 | + `Expected protocol version ${HERMES_GATEWAY_PROTOCOL_VERSION}.`, | |
| 614 | + ), | |
| 615 | + ); | |
| 616 | + } | |
| 617 | + | |
| 618 | + if (hello.authentication.type === "enrollment-token" && authenticatedEnrollment) { | |
| 619 | + const authentication = hello.authentication; | |
| 620 | + const enrollment = yield* Ref.modify(enrollments, (current) => { | |
| 621 | + const found = current.get(authentication.token); | |
| 622 | + const next = new Map(current); | |
| 623 | + if (found === authenticatedEnrollment) next.delete(authentication.token); | |
| 624 | + return [found, next] as const; | |
| 625 | + }); | |
| 626 | + if ( | |
| 627 | + enrollment !== authenticatedEnrollment || | |
| 628 | + enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis) | |
| 629 | + ) { | |
| 630 | + return yield* Effect.fail( | |
| 631 | + rejection( | |
| 632 | + hello.requestId, | |
| 633 | + "enrollment-expired", | |
| 634 | + "The enrollment token is invalid, expired, or already used.", | |
| 635 | + ), | |
| 636 | + ); | |
| 637 | + } | |
| 638 | + instanceId = enrollment.input.instanceId; | |
| 639 | + credential = HermesGatewayCredential.make( | |
| 640 | + Encoding.encodeBase64Url( | |
| 641 | + yield* crypto | |
| 642 | + .randomBytes(32) | |
| 643 | + .pipe( | |
| 644 | + Effect.mapError(() => | |
| 645 | + rejection( | |
| 646 | + hello.requestId, | |
| 647 | + "internal-error", | |
| 648 | + "Failed to generate the Hermes gateway credential.", | |
| 649 | + ), | |
| 650 | + ), | |
| 651 | + ), | |
| 652 | + ), | |
| 653 | + ); | |
| 654 | + yield* secretStore | |
| 655 | + .set(credentialSecretName(instanceId), textEncoder.encode(credential)) | |
| 656 | + .pipe( | |
| 657 | + Effect.mapError(() => | |
| 658 | + rejection( | |
| 659 | + hello.requestId, | |
| 660 | + "internal-error", | |
| 661 | + "Failed to persist the Hermes gateway credential.", | |
| 662 | + ), | |
| 663 | + ), | |
| 664 | + ); | |
| 665 | + } | |
| 666 | + | |
| 667 | + const nextGeneration = yield* Ref.getAndUpdate(generation, (value) => value + 1); | |
| 668 | + const connectedAt = DateTime.formatIso(yield* DateTime.now); | |
| 669 | + const connection = { | |
| 670 | + generation: nextGeneration, | |
| 671 | + transport, | |
| 672 | + pluginVersion: hello.pluginVersion, | |
| 673 | + hermesVersion: hello.hermesVersion, | |
| 674 | + capabilities: strictCapabilities, | |
| 675 | + connectedAt, | |
| 676 | + activeSessionCount: 0, | |
| 677 | + } satisfies ActiveConnection; | |
| 678 | + const lastSeen = { | |
| 679 | + pluginVersion: connection.pluginVersion, | |
| 680 | + hermesVersion: connection.hermesVersion, | |
| 681 | + capabilities: connection.capabilities, | |
| 682 | + connectedAt: connection.connectedAt, | |
| 683 | + activeSessionCount: connection.activeSessionCount, | |
| 684 | + }; | |
| 685 | + const connectedMetadata: InstanceMetadata = { | |
| 686 | + ...state.metadata, | |
| 687 | + lastSeen, | |
| 688 | + }; | |
| 689 | + yield* persistMetadata(instanceId, connectedMetadata).pipe( | |
| 690 | + Effect.mapError(() => | |
| 691 | + rejection( | |
| 692 | + hello.requestId, | |
| 693 | + "internal-error", | |
| 694 | + "Failed to persist Hermes gateway connection metadata.", | |
| 695 | + ), | |
| 696 | + ), | |
| 697 | + ); | |
| 698 | + if (state.connection) { | |
| 699 | + yield* failPendingRequests( | |
| 700 | + instanceId, | |
| 701 | + "The Hermes gateway connection was replaced by a newer connection.", | |
| 702 | + ); | |
| 703 | + yield* state.connection.transport.close(4001, "Replaced by a newer connection"); | |
| 704 | + } | |
| 705 | + yield* Ref.update(states, (current) => | |
| 706 | + new Map(current).set(instanceId, { | |
| 707 | + metadata: connectedMetadata, | |
| 708 | + connection, | |
| 709 | + lastSeen, | |
| 710 | + }), | |
| 711 | + ); | |
| 712 | + yield* publishStatus(instanceId, { | |
| 713 | + metadata: connectedMetadata, | |
| 714 | + connection, | |
| 715 | + lastSeen, | |
| 716 | + }); | |
| 717 | + | |
| 718 | + const accepted = { | |
| 719 | + type: "connection.accepted", | |
| 720 | + requestId: hello.requestId, | |
| 721 | + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | |
| 722 | + instanceId, | |
| 723 | + nickname: state.metadata.nickname, | |
| 724 | + ...(credential ? { credential } : {}), | |
| 725 | + } as const; | |
| 726 | + return { | |
| 727 | + instanceId, | |
| 728 | + generation: nextGeneration, | |
| 729 | + accepted, | |
| 730 | + } satisfies HermesGatewayConnectionRegistration; | |
| 731 | + }); | |
| 732 | + | |
| 733 | + const registerConnection = ( | |
| 734 | + hello: HermesGatewayConnectionHello, | |
| 735 | + transport: HermesGatewayTransport, | |
| 736 | + ) => | |
| 737 | + hello.authentication.type === "enrollment-token" | |
| 738 | + ? enrollmentSemaphore.withPermits(1)(registerConnectionEffect(hello, transport)) | |
| 739 | + : registerConnectionEffect(hello, transport); | |
| 740 | + | |
| 741 | + const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) => | |
| 742 | + Effect.gen(function* () { | |
| 743 | + const state = (yield* Ref.get(states)).get(registration.instanceId); | |
| 744 | + if (state?.connection?.generation !== registration.generation) return; | |
| 745 | + if (message.type === "connection.status") { | |
| 746 | + yield* Ref.update(states, (current) => { | |
| 747 | + const found = current.get(registration.instanceId); | |
| 748 | + if (found?.connection?.generation !== registration.generation) return current; | |
| 749 | + return new Map(current).set(registration.instanceId, { | |
| 750 | + ...found, | |
| 751 | + connection: { | |
| 752 | + ...found.connection, | |
| 753 | + activeSessionCount: message.activeSessionCount, | |
| 754 | + }, | |
| 755 | + }); | |
| 756 | + }); | |
| 757 | + const updated = (yield* Ref.get(states)).get(registration.instanceId); | |
| 758 | + if (updated) yield* publishStatus(registration.instanceId, updated); | |
| 759 | + } | |
| 760 | + if ("requestId" in message && message.requestId) { | |
| 761 | + const correlatedRequestId = message.requestId; | |
| 762 | + const pending = yield* Ref.modify(pendingRequests, (current) => { | |
| 763 | + const found = current.get(correlatedRequestId); | |
| 764 | + if (!found) return [undefined, current] as const; | |
| 765 | + const next = new Map(current); | |
| 766 | + next.delete(correlatedRequestId); | |
| 767 | + return [found, next] as const; | |
| 768 | + }); | |
| 769 | + if (pending) { | |
| 770 | + yield* Deferred.succeed(pending.deferred, message); | |
| 771 | + } | |
| 772 | + } | |
| 773 | + yield* PubSub.publish(events, { | |
| 774 | + instanceId: registration.instanceId, | |
| 775 | + message, | |
| 776 | + }); | |
| 777 | + }); | |
| 778 | + | |
| 779 | + const disconnect = (registration: HermesGatewayConnectionRegistration) => | |
| 780 | + Effect.gen(function* () { | |
| 781 | + let disconnected: InstanceState | undefined; | |
| 782 | + yield* Ref.update(states, (current) => { | |
| 783 | + const found = current.get(registration.instanceId); | |
| 784 | + if (found?.connection?.generation !== registration.generation) return current; | |
| 785 | + const next = new Map(current); | |
| 786 | + const lastSeen = { | |
| 787 | + pluginVersion: found.connection.pluginVersion, | |
| 788 | + hermesVersion: found.connection.hermesVersion, | |
| 789 | + capabilities: found.connection.capabilities, | |
| 790 | + connectedAt: found.connection.connectedAt, | |
| 791 | + activeSessionCount: found.connection.activeSessionCount, | |
| 792 | + }; | |
| 793 | + disconnected = { | |
| 794 | + metadata: { ...found.metadata, lastSeen }, | |
| 795 | + lastSeen, | |
| 796 | + }; | |
| 797 | + next.set(registration.instanceId, disconnected); | |
| 798 | + return next; | |
| 799 | + }); | |
| 800 | + if (disconnected) { | |
| 801 | + yield* persistMetadata(registration.instanceId, disconnected.metadata).pipe(Effect.ignore); | |
| 802 | + } | |
| 803 | + if (disconnected) yield* publishStatus(registration.instanceId, disconnected); | |
| 804 | + if (disconnected) { | |
| 805 | + yield* failPendingRequests( | |
| 806 | + registration.instanceId, | |
| 807 | + "The Hermes gateway connection disconnected.", | |
| 808 | + ); | |
| 809 | + } | |
| 810 | + }); | |
| 811 | + | |
| 812 | + const send = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => | |
| 813 | + Effect.gen(function* () { | |
| 814 | + const state = (yield* Ref.get(states)).get(instanceId); | |
| 815 | + if (!state?.connection) { | |
| 816 | + return yield* new ProviderAdapterRequestError({ | |
| 817 | + provider: "hermes", | |
| 818 | + method: message.type, | |
| 819 | + detail: `Hermes gateway instance '${instanceId}' is offline.`, | |
| 820 | + }); | |
| 821 | + } | |
| 822 | + yield* state.connection.transport.send(message); | |
| 823 | + }); | |
| 824 | + | |
| 825 | + const request = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => | |
| 826 | + Effect.gen(function* () { | |
| 827 | + if (!("requestId" in message) || !message.requestId) { | |
| 828 | + return yield* new ProviderAdapterRequestError({ | |
| 829 | + provider: "hermes", | |
| 830 | + method: message.type, | |
| 831 | + detail: "A correlated Hermes gateway request requires a request id.", | |
| 832 | + }); | |
| 833 | + } | |
| 834 | + const deferred = yield* Deferred.make<PluginMessage, ProviderAdapterRequestError>(); | |
| 835 | + yield* Ref.update(pendingRequests, (current) => | |
| 836 | + new Map(current).set(message.requestId as string, { | |
| 837 | + instanceId, | |
| 838 | + deferred, | |
| 839 | + }), | |
| 840 | + ); | |
| 841 | + yield* send(instanceId, message).pipe( | |
| 842 | + Effect.tapError(() => | |
| 843 | + Ref.update(pendingRequests, (current) => { | |
| 844 | + const next = new Map(current); | |
| 845 | + next.delete(message.requestId as string); | |
| 846 | + return next; | |
| 847 | + }), | |
| 848 | + ), | |
| 849 | + ); | |
| 850 | + return yield* Deferred.await(deferred).pipe( | |
| 851 | + Effect.timeout(REQUEST_TIMEOUT), | |
| 852 | + Effect.mapError((error) => | |
| 853 | + error._tag === "TimeoutError" | |
| 854 | + ? new ProviderAdapterRequestError({ | |
| 855 | + provider: "hermes", | |
| 856 | + method: message.type, | |
| 857 | + detail: "Hermes gateway request timed out.", | |
| 858 | + }) | |
| 859 | + : error, | |
| 860 | + ), | |
| 861 | + Effect.ensuring( | |
| 862 | + Ref.update(pendingRequests, (current) => { | |
| 863 | + const next = new Map(current); | |
| 864 | + next.delete(message.requestId as string); | |
| 865 | + return next; | |
| 866 | + }), | |
| 867 | + ), | |
| 868 | + ); | |
| 869 | + }); | |
| 870 | + | |
| 871 | + const isConnected = (instanceId: ProviderInstanceId) => | |
| 872 | + Ref.get(states).pipe( | |
| 873 | + Effect.map((current) => current.get(instanceId)?.connection !== undefined), | |
| 874 | + ); | |
| 875 | + | |
| 876 | + return { | |
| 877 | + createEnrollment, | |
| 878 | + getInstanceStatus, | |
| 879 | + listInstances, | |
| 880 | + revokeInstance, | |
| 881 | + registerConnection, | |
| 882 | + receive, | |
| 883 | + disconnect, | |
| 884 | + request, | |
| 885 | + send, | |
| 886 | + isConnected, | |
| 887 | + stream: Stream.fromPubSub(events), | |
| 888 | + streamStatuses: Stream.fromPubSub(statusEvents), | |
| 889 | + } satisfies HermesGatewayBrokerShape; | |
| 890 | +}); | |
| 891 | + | |
| 892 | +export const HermesGatewayBrokerLive = Layer.effect(HermesGatewayBroker, makeHermesGatewayBroker); | |
| @@ -0,0 +1,101 @@ | ||
| 1 | +import type { | |
| 2 | + HermesGatewayConnectionHello, | |
| 3 | + HermesGatewayCreateEnrollmentInput, | |
| 4 | + HermesGatewayEnrollmentResult, | |
| 5 | + HermesGatewayInstanceStatus, | |
| 6 | + HermesGatewayPluginToT3Message, | |
| 7 | + HermesGatewayRevokeInstanceResult, | |
| 8 | + HermesGatewayT3ToPluginMessage, | |
| 9 | + ProviderInstanceId, | |
| 10 | +} from "@t3tools/contracts"; | |
| 11 | +import * as Context from "effect/Context"; | |
| 12 | +import * as Effect from "effect/Effect"; | |
| 13 | +import * as Stream from "effect/Stream"; | |
| 14 | + | |
| 15 | +import type { HermesGatewayManagementError } from "@t3tools/contracts"; | |
| 16 | +import type { ProviderAdapterRequestError } from "../Errors.ts"; | |
| 17 | + | |
| 18 | +export interface HermesGatewayTransport { | |
| 19 | + readonly send: ( | |
| 20 | + message: HermesGatewayT3ToPluginMessage, | |
| 21 | + ) => Effect.Effect<void, ProviderAdapterRequestError>; | |
| 22 | + readonly close: (code: number, reason: string) => Effect.Effect<void>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface HermesGatewayConnectionRegistration { | |
| 26 | + readonly instanceId: ProviderInstanceId; | |
| 27 | + readonly generation: number; | |
| 28 | + readonly accepted: Extract< | |
| 29 | + HermesGatewayT3ToPluginMessage, | |
| 30 | + { readonly type: "connection.accepted" } | |
| 31 | + >; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export interface HermesGatewayEnvelope { | |
| 35 | + readonly instanceId: ProviderInstanceId; | |
| 36 | + readonly message: Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface HermesGatewayBrokerShape { | |
| 40 | + readonly createEnrollment: ( | |
| 41 | + input: HermesGatewayCreateEnrollmentInput, | |
| 42 | + ) => Effect.Effect<HermesGatewayEnrollmentResult, HermesGatewayManagementError>; | |
| 43 | + readonly getInstanceStatus: ( | |
| 44 | + instanceId: ProviderInstanceId, | |
| 45 | + ) => Effect.Effect<HermesGatewayInstanceStatus, HermesGatewayManagementError>; | |
| 46 | + readonly listInstances: Effect.Effect< | |
| 47 | + ReadonlyArray<HermesGatewayInstanceStatus>, | |
| 48 | + HermesGatewayManagementError | |
| 49 | + >; | |
| 50 | + readonly revokeInstance: ( | |
| 51 | + instanceId: ProviderInstanceId, | |
| 52 | + ) => Effect.Effect<HermesGatewayRevokeInstanceResult, HermesGatewayManagementError>; | |
| 53 | + readonly registerConnection: ( | |
| 54 | + hello: HermesGatewayConnectionHello, | |
| 55 | + transport: HermesGatewayTransport, | |
| 56 | + ) => Effect.Effect< | |
| 57 | + HermesGatewayConnectionRegistration, | |
| 58 | + Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }> | |
| 59 | + >; | |
| 60 | + readonly receive: ( | |
| 61 | + registration: HermesGatewayConnectionRegistration, | |
| 62 | + message: Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>, | |
| 63 | + ) => Effect.Effect<void>; | |
| 64 | + readonly disconnect: (registration: HermesGatewayConnectionRegistration) => Effect.Effect<void>; | |
| 65 | + readonly request: ( | |
| 66 | + instanceId: ProviderInstanceId, | |
| 67 | + message: HermesGatewayT3ToPluginMessage, | |
| 68 | + ) => Effect.Effect< | |
| 69 | + Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>, | |
| 70 | + ProviderAdapterRequestError | |
| 71 | + >; | |
| 72 | + readonly send: ( | |
| 73 | + instanceId: ProviderInstanceId, | |
| 74 | + message: HermesGatewayT3ToPluginMessage, | |
| 75 | + ) => Effect.Effect<void, ProviderAdapterRequestError>; | |
| 76 | + readonly isConnected: (instanceId: ProviderInstanceId) => Effect.Effect<boolean>; | |
| 77 | + readonly stream: Stream.Stream<HermesGatewayEnvelope>; | |
| 78 | + readonly streamStatuses: Stream.Stream<HermesGatewayInstanceStatus>; | |
| 79 | +} | |
| 80 | + | |
| 81 | +const unavailable = () => Effect.die(new Error("HermesGatewayBroker live layer is not installed")); | |
| 82 | + | |
| 83 | +export const HermesGatewayBroker = Context.Reference<HermesGatewayBrokerShape>( | |
| 84 | + "t3/provider/Services/HermesGatewayBroker", | |
| 85 | + { | |
| 86 | + defaultValue: () => ({ | |
| 87 | + createEnrollment: unavailable, | |
| 88 | + getInstanceStatus: unavailable, | |
| 89 | + listInstances: unavailable(), | |
| 90 | + revokeInstance: unavailable, | |
| 91 | + registerConnection: unavailable, | |
| 92 | + receive: unavailable, | |
| 93 | + disconnect: unavailable, | |
| 94 | + request: unavailable, | |
| 95 | + send: unavailable, | |
| 96 | + isConnected: () => Effect.succeed(false), | |
| 97 | + stream: Stream.empty, | |
| 98 | + streamStatuses: Stream.empty, | |
| 99 | + }), | |
| 100 | + }, | |
| 101 | +); | |
| @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; · 23 unmodified lines | ||
| 24 | 24 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 25 | 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 26 | 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 27 | +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; | |
| 27 | 28 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 28 | 29 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; |
| 29 | 30 | |
| @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = · 7 unmodified lines | ||
| 37 | 38 | | CodexDriverEnv |
| 38 | 39 | | CursorDriverEnv |
| 39 | 40 | | GrokDriverEnv |
| 41 | + | HermesDriverEnv | |
| 40 | 42 | | OpenCodeDriverEnv; |
| 41 | 43 | |
| 42 | 44 | /** |
| @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv · 7 unmodified lines | ||
| 50 | 52 | CursorDriver, |
| 51 | 53 | GrokDriver, |
| 52 | 54 | OpenCodeDriver, |
| 55 | + HermesDriver, | |
| 53 | 56 | ]; |
| @@ -0,0 +1,116 @@ | ||
| 1 | +import { | |
| 2 | + HermesGatewayConnectionHello, | |
| 3 | + HermesGatewayPluginToT3Message, | |
| 4 | + HermesGatewayT3ToPluginMessage, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as Option from "effect/Option"; | |
| 9 | +import * as Ref from "effect/Ref"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 12 | +import * as Socket from "effect/unstable/socket/Socket"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + HermesGatewayBroker, | |
| 16 | + type HermesGatewayConnectionRegistration, | |
| 17 | +} from "./Services/HermesGatewayBroker.ts"; | |
| 18 | +import { ProviderAdapterRequestError } from "./Errors.ts"; | |
| 19 | + | |
| 20 | +export const HERMES_GATEWAY_WEBSOCKET_PATH = "/api/hermes-gateway/ws"; | |
| 21 | + | |
| 22 | +const decodePluginFrame = Schema.decodeUnknownEffect( | |
| 23 | + Schema.fromJsonString(HermesGatewayPluginToT3Message), | |
| 24 | +); | |
| 25 | +const encodeServerFrame = Schema.encodeSync(Schema.fromJsonString(HermesGatewayT3ToPluginMessage)); | |
| 26 | +const isConnectionHello = Schema.is(HermesGatewayConnectionHello); | |
| 27 | + | |
| 28 | +export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( | |
| 29 | + Effect.gen(function* () { | |
| 30 | + const broker = yield* HermesGatewayBroker; | |
| 31 | + return HttpRouter.add( | |
| 32 | + "GET", | |
| 33 | + HERMES_GATEWAY_WEBSOCKET_PATH, | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 36 | + const socket = yield* Effect.orDie(request.upgrade); | |
| 37 | + const write = yield* socket.writer; | |
| 38 | + const registration = yield* Ref.make<Option.Option<HermesGatewayConnectionRegistration>>( | |
| 39 | + Option.none(), | |
| 40 | + ); | |
| 41 | + const transport = { | |
| 42 | + send: (message: HermesGatewayT3ToPluginMessage) => | |
| 43 | + write(encodeServerFrame(message)).pipe( | |
| 44 | + Effect.mapError( | |
| 45 | + (cause) => | |
| 46 | + new ProviderAdapterRequestError({ | |
| 47 | + provider: "hermes", | |
| 48 | + method: message.type, | |
| 49 | + detail: "Failed to write a Hermes gateway WebSocket frame.", | |
| 50 | + cause, | |
| 51 | + }), | |
| 52 | + ), | |
| 53 | + ), | |
| 54 | + close: (code: number, reason: string) => | |
| 55 | + write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore), | |
| 56 | + }; | |
| 57 | + | |
| 58 | + yield* socket | |
| 59 | + .runString((frame) => | |
| 60 | + Effect.gen(function* () { | |
| 61 | + const message = yield* decodePluginFrame(frame); | |
| 62 | + const current = yield* Ref.get(registration); | |
| 63 | + | |
| 64 | + if (Option.isNone(current)) { | |
| 65 | + if (!isConnectionHello(message)) { | |
| 66 | + yield* transport.close(4002, "First message must be connection.hello"); | |
| 67 | + return; | |
| 68 | + } | |
| 69 | + const registered = yield* broker | |
| 70 | + .registerConnection(message, transport) | |
| 71 | + .pipe( | |
| 72 | + Effect.tapError((rejected) => | |
| 73 | + transport | |
| 74 | + .send(rejected) | |
| 75 | + .pipe(Effect.andThen(transport.close(4003, rejected.message))), | |
| 76 | + ), | |
| 77 | + ); | |
| 78 | + yield* Ref.set(registration, Option.some(registered)); | |
| 79 | + yield* transport.send(registered.accepted); | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + | |
| 83 | + if (isConnectionHello(message)) { | |
| 84 | + yield* transport.close(4002, "connection.hello may only be sent once"); | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + yield* broker.receive(current.value, message); | |
| 88 | + }).pipe( | |
| 89 | + Effect.catch((cause) => | |
| 90 | + Effect.logWarning("Rejected Hermes gateway WebSocket frame", { cause }).pipe( | |
| 91 | + Effect.andThen(transport.close(4002, "Invalid Hermes gateway message")), | |
| 92 | + ), | |
| 93 | + ), | |
| 94 | + ), | |
| 95 | + ) | |
| 96 | + .pipe( | |
| 97 | + Effect.catch((cause) => | |
| 98 | + Effect.logDebug("Hermes gateway WebSocket disconnected", { cause }), | |
| 99 | + ), | |
| 100 | + Effect.ensuring( | |
| 101 | + Ref.get(registration).pipe( | |
| 102 | + Effect.flatMap( | |
| 103 | + Option.match({ | |
| 104 | + onNone: () => Effect.void, | |
| 105 | + onSome: broker.disconnect, | |
| 106 | + }), | |
| 107 | + ), | |
| 108 | + ), | |
| 109 | + ), | |
| 110 | + ); | |
| 111 | + | |
| 112 | + return HttpServerResponse.empty(); | |
| 113 | + }), | |
| 114 | + ); | |
| 115 | + }), | |
| 116 | +); | |
| @@ -21,6 +21,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; · 20 unmodified lines | ||
| 21 | 21 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; |
| 22 | 22 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; |
| 23 | 23 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 24 | +import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts"; | |
| 25 | +import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts"; | |
| 24 | 26 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 25 | 27 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 26 | 28 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| @@ -287,6 +289,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( · 260 unmodified lines | ||
| 287 | 289 | Layer.provideMerge(OrchestrationLayerLive), |
| 288 | 290 | ); |
| 289 | 291 | |
| 292 | +const HermesProviderRuntimeLive = ProviderInstanceRegistryHydrationLive.pipe( | |
| 293 | + Layer.provideMerge(HermesGatewayBrokerLive), | |
| 294 | +); | |
| 295 | + | |
| 290 | 296 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 291 | 297 | // Core Services |
| 292 | 298 | Layer.provideMerge(CheckpointingLayerLive), |
| @@ -303,7 +309,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 10 unmodified lines | ||
| 303 | 309 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; |
| 304 | 310 | // `providerInstances` hydration merges `settings.providers.<kind>` |
| 305 | 311 | // with explicit `providerInstances` entries on boot. |
| 306 | − Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | |
| 312 | + Layer.provideMerge(HermesProviderRuntimeLive), | |
| 307 | 313 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 308 | 314 | // drivers (native stream, written from inside each `<X>Adapter`) and |
| 309 | 315 | // `ProviderService` (canonical stream, written after event normalization). |
| @@ -362,6 +368,7 @@ export const makeRoutesLayer = Layer.mergeAll( · 52 unmodified lines | ||
| 362 | 368 | assetRouteLayer, |
| 363 | 369 | staticAndDevRouteLayer, |
| 364 | 370 | websocketRpcRouteLayer, |
| 371 | + hermesGatewayWebSocketRouteLayer, | |
| 365 | 372 | ), |
| 366 | 373 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), |
| 367 | 374 | ).pipe( |
| @@ -78,6 +78,7 @@ import { · 77 unmodified lines | ||
| 78 | 78 | } from "./observability/RpcInstrumentation.ts"; |
| 79 | 79 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 80 | 80 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 81 | +import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts"; | |
| 81 | 82 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 82 | 83 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 83 | 84 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| @@ -307,6 +308,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 223 unmodified lines | ||
| 307 | 308 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], |
| 308 | 309 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], |
| 309 | 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 | 315 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], |
| 311 | 316 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], |
| 312 | 317 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], |
| @@ -420,6 +425,7 @@ const makeWsRpcLayer = ( · 107 unmodified lines | ||
| 420 | 425 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 421 | 426 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 422 | 427 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 428 | + const hermesGatewayBroker = yield* HermesGatewayBroker; | |
| 423 | 429 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; |
| 424 | 430 | const config = yield* ServerConfig.ServerConfig; |
| 425 | 431 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| @@ -1521,6 +1527,30 @@ const makeWsRpcLayer = ( · 1095 unmodified lines | ||
| 1521 | 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 | 1554 | [WS_METHODS.serverDiscoverSourceControl]: (_input) => |
| 1525 | 1555 | observeRpcEffect( |
| 1526 | 1556 | WS_METHODS.serverDiscoverSourceControl, |
| @@ -0,0 +1,18 @@ | ||
| 1 | +import type { Icon } from "./Icons"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Compact local mark for Hermes provider surfaces. The paired wings suggest | |
| 5 | + * Hermes' caduceus without depending on a remote image asset. | |
| 6 | + */ | |
| 7 | +export const HermesIcon: Icon = (props) => ( | |
| 8 | + <svg {...props} viewBox="0 0 24 24" fill="none"> | |
| 9 | + <path | |
| 10 | + d="M12 3v18M8.5 6.5c-2.8-2.2-5-1.1-5.5-.7.8 2.8 2.6 4.2 5.5 4.2h7c2.9 0 4.7-1.4 5.5-4.2-.5-.4-2.7-1.5-5.5.7M8 14h8M9.5 18h5" | |
| 11 | + stroke="currentColor" | |
| 12 | + strokeWidth="1.7" | |
| 13 | + strokeLinecap="round" | |
| 14 | + strokeLinejoin="round" | |
| 15 | + /> | |
| 16 | + <circle cx="12" cy="3" r="1.4" fill="currentColor" /> | |
| 17 | + </svg> | |
| 18 | +); | |
| @@ -75,7 +75,10 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; · 74 unmodified lines | ||
| 75 | 75 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 76 | 76 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 77 | 77 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 78 | −import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; | |
| 78 | +import { | |
| 79 | + ComposerPrimaryActions, | |
| 80 | + shouldUseCollapsedHermesRunningActions, | |
| 81 | +} from "./ComposerPrimaryActions"; | |
| 79 | 82 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; |
| 80 | 83 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 81 | 84 | import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; |
| @@ -171,9 +174,10 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ · 89 unmodified lines | ||
| 171 | 174 | import { |
| 172 | 175 | applyProviderInstanceSettings, |
| 173 | 176 | deriveProviderInstanceEntries, |
| 174 | − NO_PROVIDER_MODEL_SELECTION, | |
| 177 | + getProviderInstanceComposerUnavailableMessage, | |
| 178 | + isProviderInstanceComposerAvailable, | |
| 179 | + resolveComposerProviderInstanceId, | |
| 175 | 180 | resolveProviderDriverKindForInstanceSelection, |
| 176 | − resolveSelectableProviderInstanceEntry, | |
| 177 | 181 | sortProviderInstanceEntries, |
| 178 | 182 | type ProviderInstanceEntry, |
| 179 | 183 | } from "../../providerInstances"; |
| @@ -412,6 +416,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 232 unmodified lines | ||
| 412 | 416 | isComplete: boolean; |
| 413 | 417 | } | null; |
| 414 | 418 | isRunning: boolean; |
| 419 | + allowSendWhileRunning: boolean; | |
| 415 | 420 | showPlanFollowUpPrompt: boolean; |
| 416 | 421 | promptHasText: boolean; |
| 417 | 422 | isSendBusy: boolean; |
| @@ -438,6 +443,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 20 unmodified lines | ||
| 438 | 443 | compact={props.compact} |
| 439 | 444 | pendingAction={props.pendingAction} |
| 440 | 445 | isRunning={props.isRunning} |
| 446 | + allowSendWhileRunning={props.allowSendWhileRunning} | |
| 441 | 447 | showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} |
| 442 | 448 | promptHasText={props.promptHasText} |
| 443 | 449 | isSendBusy={props.isSendBusy} |
| @@ -776,52 +782,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 332 unmodified lines | ||
| 776 | 782 | |
| 777 | 783 | // Resolve which configured instance the composer is currently targeting. |
| 778 | 784 | // Priority: |
| 779 | − // 1. The composer draft's `activeProvider` — the user's unsaved pick | |
| 785 | + // 1. A started Hermes thread's persisted instance binding. Hermes | |
| 786 | + // sessions cannot be reassigned, including when that instance is | |
| 787 | + // currently missing or disabled. | |
| 788 | + // 2. The composer draft's `activeProvider` — the user's unsaved pick | |
| 780 | 789 | // from the model picker (must win, otherwise the UI appears to |
| 781 | 790 | // ignore picker selections). |
| 782 | − // 2. Thread's persisted instance id (server-side saved selection). | |
| 783 | − // 3. Project default's instance id. | |
| 784 | − // 4. First enabled entry matching the current driver kind. | |
| 785 | − // 5. First enabled entry overall / default instance for the kind. | |
| 791 | + // 3. Thread's persisted instance id (server-side saved selection). | |
| 792 | + // 4. Project default's instance id. | |
| 793 | + // 5. First enabled entry matching the current driver kind. | |
| 794 | + // 6. First enabled entry overall / default instance for the kind. | |
| 786 | 795 | // |
| 787 | 796 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { |
| 788 | − const candidates: Array<string | null | undefined> = [ | |
| 789 | − composerDraft.activeProvider, | |
| 790 | − activeThread?.session?.providerInstanceId, | |
| 791 | − activeThreadModelSelection?.instanceId, | |
| 792 | − activeProjectDefaultModelSelection?.instanceId, | |
| 793 | − ]; | |
| 794 | − for (const candidate of candidates) { | |
| 795 | − if (!candidate) continue; | |
| 796 | − const match = providerInstanceEntries.find( | |
| 797 | − (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, | |
| 798 | − ); | |
| 799 | − if (match) { | |
| 800 | − // When locked to a specific driver kind, ignore persisted instance | |
| 801 | − // ids from a different kind or continuation group. | |
| 802 | − if (lockedProvider && match.driverKind !== lockedProvider) continue; | |
| 803 | − if ( | |
| 804 | − lockedContinuationGroupKey && | |
| 805 | − match.continuationGroupKey !== lockedContinuationGroupKey | |
| 806 | − ) { | |
| 807 | − continue; | |
| 808 | − } | |
| 809 | − return match.instanceId; | |
| 810 | − } | |
| 811 | − } | |
| 812 | − const compatibleEntries = providerInstanceEntries.filter( | |
| 813 | − (entry) => | |
| 814 | − (!lockedProvider || entry.driverKind === lockedProvider) && | |
| 815 | − (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), | |
| 816 | − ); | |
| 817 | − const requestedDriverEntries = compatibleEntries.filter( | |
| 818 | − (entry) => entry.driverKind === requestedDriverKind, | |
| 819 | − ); | |
| 820 | − return ( | |
| 821 | − resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? | |
| 822 | − resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? | |
| 823 | − NO_PROVIDER_MODEL_SELECTION.instanceId | |
| 824 | − ); | |
| 797 | + return resolveComposerProviderInstanceId({ | |
| 798 | + entries: providerInstanceEntries, | |
| 799 | + draftInstanceId: composerDraft.activeProvider ?? null, | |
| 800 | + threadInstanceId: activeThread?.session?.providerInstanceId, | |
| 801 | + threadModelInstanceId: activeThreadModelSelection?.instanceId, | |
| 802 | + projectInstanceId: activeProjectDefaultModelSelection?.instanceId, | |
| 803 | + requestedDriverKind, | |
| 804 | + lockedProvider, | |
| 805 | + lockedContinuationGroupKey, | |
| 806 | + }); | |
| 825 | 807 | }, [ |
| 826 | 808 | activeProjectDefaultModelSelection?.instanceId, |
| 827 | 809 | activeThread?.session?.providerInstanceId, |
| @@ -840,12 +822,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines | ||
| 840 | 822 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), |
| 841 | 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 | 841 | // The driver kind follows the instance that will actually run the turn, |
| 845 | 842 | // which can differ from the persisted selection when that selection is |
| 846 | 843 | // disabled. |
| 847 | 844 | const selectedProvider: ProviderDriverKind = |
| 848 | 845 | selectedProviderEntry?.driverKind ?? requestedDriverKind; |
| 846 | + const allowHermesSteering = phase === "running" && selectedProvider === "hermes"; | |
| 849 | 847 | |
| 850 | 848 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 851 | 849 | threadRef: composerDraftTarget, |
| @@ -1216,7 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 364 unmodified lines | ||
| 1216 | 1214 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], |
| 1217 | 1215 | ); |
| 1218 | 1216 | const collapsedComposerPrimaryActionDisabled = |
| 1219 | − phase === "running" || | |
| 1217 | + (phase === "running" && !allowHermesSteering) || | |
| 1220 | 1218 | isSendBusy || |
| 1221 | 1219 | isConnecting || |
| 1222 | 1220 | noProviderAvailable || |
| @@ -1765,7 +1763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 542 unmodified lines | ||
| 1765 | 1763 | isConnecting || |
| 1766 | 1764 | noProviderAvailable || |
| 1767 | 1765 | environmentUnavailable !== null || |
| 1768 | − phase === "running" | |
| 1766 | + (phase === "running" && !allowHermesSteering) | |
| 1769 | 1767 | ) { |
| 1770 | 1768 | return false; |
| 1771 | 1769 | } |
| @@ -1783,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | ||
| 1783 | 1781 | isSendBusy, |
| 1784 | 1782 | noProviderAvailable, |
| 1785 | 1783 | phase, |
| 1784 | + allowHermesSteering, | |
| 1786 | 1785 | showPlanFollowUpPrompt, |
| 1787 | 1786 | ]); |
| 1788 | 1787 | |
| @@ -2368,29 +2367,56 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 579 unmodified lines | ||
| 2368 | 2367 | ? activePendingProgress.customAnswer || |
| 2369 | 2368 | "Type your own answer, or leave this blank to use the selected option" |
| 2370 | 2369 | : prompt.trim() || |
| 2371 | − (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} | |
| 2372 | − </button> | |
| 2373 | − <button | |
| 2374 | − type="button" | |
| 2375 | − className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" | |
| 2376 | − disabled={collapsedComposerPrimaryActionDisabled} | |
| 2377 | − aria-label={collapsedComposerPrimaryActionLabel} | |
| 2378 | − onPointerDown={(event) => event.preventDefault()} | |
| 2379 | − onClick={(event) => { | |
| 2380 | − event.stopPropagation(); | |
| 2381 | − submitComposer(); | |
| 2382 | − }} | |
| 2383 | − > | |
| 2384 | − <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| 2385 | − <path | |
| 2386 | − d="M8 3L8 13M8 3L4 7M8 3L12 7" | |
| 2387 | − stroke="currentColor" | |
| 2388 | − strokeWidth="2" | |
| 2389 | − strokeLinecap="round" | |
| 2390 | − strokeLinejoin="round" | |
| 2391 | − /> | |
| 2392 | − </svg> | |
| 2370 | + (noProviderAvailable ? providerUnavailableMessage : "Ask anything...")} | |
| 2393 | 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 | 2420 | </div> |
| 2395 | 2421 | ) : null} |
| 2396 | 2422 | |
| @@ -2575,7 +2601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 178 unmodified lines | ||
| 2575 | 2601 | : projectSelectionRequired |
| 2576 | 2602 | ? "Choose a project above to start a thread" |
| 2577 | 2603 | : noProviderAvailable |
| 2578 | − ? "Enable a provider in Settings to send a message" | |
| 2604 | + ? providerUnavailableMessage | |
| 2579 | 2605 | : phase === "disconnected" |
| 2580 | 2606 | ? "Ask for follow-up changes or attach images" |
| 2581 | 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 | 2669 | className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" |
| 2644 | 2670 | > |
| 2645 | 2671 | <CircleAlertIcon className="size-4" /> |
| 2646 | − No provider available | |
| 2672 | + {providerUnavailableMessage} | |
| 2647 | 2673 | </Button> |
| 2648 | 2674 | ) : ( |
| 2649 | 2675 | <ProviderModelPicker |
| @@ -2721,6 +2747,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 71 unmodified lines | ||
| 2721 | 2747 | activeThreadProviderDisplayName={activeThreadProviderDisplayName} |
| 2722 | 2748 | pendingAction={pendingPrimaryAction} |
| 2723 | 2749 | isRunning={phase === "running"} |
| 2750 | + allowSendWhileRunning={allowHermesSteering} | |
| 2724 | 2751 | showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} |
| 2725 | 2752 | promptHasText={prompt.trim().length > 0} |
| 2726 | 2753 | isSendBusy={isSendBusy} |
| @@ -1,6 +1,45 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; | |
| 3 | +import { | |
| 4 | + formatPendingPrimaryActionLabel, | |
| 5 | + resolveRunningPrimaryActionMode, | |
| 6 | + shouldUseCollapsedHermesRunningActions, | |
| 7 | +} from "./ComposerPrimaryActions"; | |
| 8 | + | |
| 9 | +describe("resolveRunningPrimaryActionMode", () => { | |
| 10 | + it("keeps stop-only behavior for ordinary running providers", () => { | |
| 11 | + expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: false })).toBe( | |
| 12 | + "stop", | |
| 13 | + ); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("shows steer and stop actions for a running Hermes provider", () => { | |
| 17 | + expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: true })).toBe( | |
| 18 | + "steer", | |
| 19 | + ); | |
| 20 | + expect( | |
| 21 | + shouldUseCollapsedHermesRunningActions({ | |
| 22 | + isRunning: true, | |
| 23 | + allowSendWhileRunning: true, | |
| 24 | + }), | |
| 25 | + ).toBe(true); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("does not replace collapsed actions for idle or ordinary providers", () => { | |
| 29 | + expect( | |
| 30 | + shouldUseCollapsedHermesRunningActions({ | |
| 31 | + isRunning: false, | |
| 32 | + allowSendWhileRunning: true, | |
| 33 | + }), | |
| 34 | + ).toBe(false); | |
| 35 | + expect( | |
| 36 | + shouldUseCollapsedHermesRunningActions({ | |
| 37 | + isRunning: true, | |
| 38 | + allowSendWhileRunning: false, | |
| 39 | + }), | |
| 40 | + ).toBe(false); | |
| 41 | + }); | |
| 42 | +}); | |
| 4 | 43 | |
| 5 | 44 | describe("formatPendingPrimaryActionLabel", () => { |
| 6 | 45 | it("returns 'Submitting...' while responding", () => { |
| @@ -18,6 +18,7 @@ interface ComposerPrimaryActionsProps { · 17 unmodified lines | ||
| 18 | 18 | compact: boolean; |
| 19 | 19 | pendingAction: PendingActionState | null; |
| 20 | 20 | isRunning: boolean; |
| 21 | + allowSendWhileRunning?: boolean; | |
| 21 | 22 | showPlanFollowUpPrompt: boolean; |
| 22 | 23 | promptHasText: boolean; |
| 23 | 24 | isSendBusy: boolean; |
| @@ -49,6 +50,21 @@ export const formatPendingPrimaryActionLabel = (input: { · 25 unmodified lines | ||
| 49 | 50 | return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; |
| 50 | 51 | }; |
| 51 | 52 | |
| 53 | +export function resolveRunningPrimaryActionMode(input: { | |
| 54 | + readonly isRunning: boolean; | |
| 55 | + readonly allowSendWhileRunning: boolean; | |
| 56 | +}): "idle" | "stop" | "steer" { | |
| 57 | + if (!input.isRunning) return "idle"; | |
| 58 | + return input.allowSendWhileRunning ? "steer" : "stop"; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function shouldUseCollapsedHermesRunningActions(input: { | |
| 62 | + readonly isRunning: boolean; | |
| 63 | + readonly allowSendWhileRunning: boolean; | |
| 64 | +}): boolean { | |
| 65 | + return resolveRunningPrimaryActionMode(input) === "steer"; | |
| 66 | +} | |
| 67 | + | |
| 52 | 68 | const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => { |
| 53 | 69 | event.preventDefault(); |
| 54 | 70 | }; |
| @@ -57,6 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 2 unmodified lines | ||
| 57 | 73 | compact, |
| 58 | 74 | pendingAction, |
| 59 | 75 | isRunning, |
| 76 | + allowSendWhileRunning = false, | |
| 60 | 77 | showPlanFollowUpPrompt, |
| 61 | 78 | promptHasText, |
| 62 | 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 | 147 | return ( |
| 130 | 148 | <button |
| 131 | 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 | 197 | if (showPlanFollowUpPrompt) { |
| 145 | 198 | if (promptHasText) { |
| 146 | 199 | return ( |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; |
| 4 | +import { HermesIcon } from "../HermesIcon"; | |
| 4 | 5 | |
| 5 | 6 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 6 | 7 | [ProviderDriverKind.make("codex")]: OpenAI, |
| @@ -8,6 +9,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> · 1 unmodified line | ||
| 8 | 9 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, |
| 9 | 10 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 10 | 11 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 12 | + [ProviderDriverKind.make("hermes")]: HermesIcon, | |
| 11 | 13 | }; |
| 12 | 14 | |
| 13 | 15 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
| @@ -1,11 +1,50 @@ | ||
| 1 | +import type { ProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | + | |
| 1 | 3 | export type WizardNavigation = |
| 2 | 4 | | { readonly kind: "navigate"; readonly step: number } |
| 3 | 5 | | { readonly kind: "blocked"; readonly step: number; readonly error: string }; |
| 4 | 6 | |
| 5 | 7 | const IDENTITY_STEP = 1; |
| 8 | +const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | |
| 6 | 9 | |
| 7 | 10 | export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; |
| 8 | 11 | |
| 12 | +export function isOwnedHermesEnrollmentRetry(input: { | |
| 13 | + readonly driver: ProviderDriverKind; | |
| 14 | + readonly instanceId: string; | |
| 15 | + readonly createdHermesInstanceId: string | null; | |
| 16 | +}): boolean { | |
| 17 | + return ( | |
| 18 | + input.driver === "hermes" && | |
| 19 | + input.createdHermesInstanceId !== null && | |
| 20 | + input.createdHermesInstanceId === input.instanceId | |
| 21 | + ); | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function validateProviderInstanceIdForWizard(input: { | |
| 25 | + readonly driver: ProviderDriverKind; | |
| 26 | + readonly instanceId: string; | |
| 27 | + readonly existingIds: ReadonlySet<string>; | |
| 28 | + readonly createdHermesInstanceId: string | null; | |
| 29 | +}): string | null { | |
| 30 | + if (input.instanceId.length === 0) return "Instance ID is required."; | |
| 31 | + if (input.instanceId.length > 64) return "Instance ID must be 64 characters or fewer."; | |
| 32 | + if (!INSTANCE_ID_PATTERN.test(input.instanceId)) { | |
| 33 | + return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; | |
| 34 | + } | |
| 35 | + if ( | |
| 36 | + input.existingIds.has(input.instanceId) && | |
| 37 | + !isOwnedHermesEnrollmentRetry({ | |
| 38 | + driver: input.driver, | |
| 39 | + instanceId: input.instanceId, | |
| 40 | + createdHermesInstanceId: input.createdHermesInstanceId, | |
| 41 | + }) | |
| 42 | + ) { | |
| 43 | + return `An instance named '${input.instanceId}' already exists.`; | |
| 44 | + } | |
| 45 | + return null; | |
| 46 | +} | |
| 47 | + | |
| 9 | 48 | /** |
| 10 | 49 | * Resolve navigation within the add-provider wizard. |
| 11 | 50 | * |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; | |
| 3 | +import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + isOwnedHermesEnrollmentRetry, | |
| 7 | + resolveWizardNavigation, | |
| 8 | + validateProviderInstanceIdForWizard, | |
| 9 | +} from "./AddProviderInstanceDialog.logic"; | |
| 4 | 10 | |
| 5 | 11 | describe("resolveWizardNavigation", () => { |
| 6 | 12 | const invalidId = { instanceIdError: "Instance ID is required." }; |
| @@ -42,3 +48,52 @@ describe("resolveWizardNavigation", () => { · 35 unmodified lines | ||
| 42 | 48 | expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); |
| 43 | 49 | }); |
| 44 | 50 | }); |
| 51 | + | |
| 52 | +describe("Hermes enrollment retry ownership", () => { | |
| 53 | + const existingIds = new Set(["hermes-research", "hermes-existing"]); | |
| 54 | + | |
| 55 | + it("allows retrying enrollment for the exact Hermes instance created by this wizard", () => { | |
| 56 | + expect( | |
| 57 | + isOwnedHermesEnrollmentRetry({ | |
| 58 | + driver: ProviderDriverKind.make("hermes"), | |
| 59 | + instanceId: "hermes-research", | |
| 60 | + createdHermesInstanceId: "hermes-research", | |
| 61 | + }), | |
| 62 | + ).toBe(true); | |
| 63 | + expect( | |
| 64 | + validateProviderInstanceIdForWizard({ | |
| 65 | + driver: ProviderDriverKind.make("hermes"), | |
| 66 | + instanceId: "hermes-research", | |
| 67 | + existingIds, | |
| 68 | + createdHermesInstanceId: "hermes-research", | |
| 69 | + }), | |
| 70 | + ).toBeNull(); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("still rejects arbitrary existing instances and non-Hermes reuse", () => { | |
| 74 | + expect( | |
| 75 | + validateProviderInstanceIdForWizard({ | |
| 76 | + driver: ProviderDriverKind.make("hermes"), | |
| 77 | + instanceId: "hermes-existing", | |
| 78 | + existingIds, | |
| 79 | + createdHermesInstanceId: "hermes-research", | |
| 80 | + }), | |
| 81 | + ).toContain("already exists"); | |
| 82 | + expect( | |
| 83 | + validateProviderInstanceIdForWizard({ | |
| 84 | + driver: ProviderDriverKind.make("codex"), | |
| 85 | + instanceId: "hermes-research", | |
| 86 | + existingIds, | |
| 87 | + createdHermesInstanceId: "hermes-research", | |
| 88 | + }), | |
| 89 | + ).toContain("already exists"); | |
| 90 | + expect( | |
| 91 | + validateProviderInstanceIdForWizard({ | |
| 92 | + driver: ProviderDriverKind.make("hermes"), | |
| 93 | + instanceId: "hermes-research", | |
| 94 | + existingIds, | |
| 95 | + createdHermesInstanceId: null, | |
| 96 | + }), | |
| 97 | + ).toContain("already exists"); | |
| 98 | + }); | |
| 99 | +}); | |
| @@ -1,14 +1,17 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 4 | −import { CheckIcon } from "lucide-react"; | |
| 5 | −import { useMemo, useState } from "react"; | |
| 4 | +import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react"; | |
| 5 | +import { useEffect, useMemo, useState } from "react"; | |
| 6 | 6 | import { |
| 7 | + type HermesGatewayEnrollmentResult, | |
| 7 | 8 | ProviderInstanceId, |
| 8 | 9 | ProviderDriverKind, |
| 9 | 10 | type ProviderInstanceConfig, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 12 | +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 11 | 13 | |
| 14 | +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 12 | 15 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 13 | 16 | import { cn } from "../../lib/utils"; |
| 14 | 17 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| @@ -31,10 +34,20 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe · 16 unmodified lines | ||
| 31 | 34 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 32 | 35 | import { |
| 33 | 36 | ADD_PROVIDER_WIZARD_STEPS, |
| 37 | + isOwnedHermesEnrollmentRetry, | |
| 34 | 38 | resolveWizardNavigation, |
| 35 | 39 | type WizardNavigation, |
| 40 | + validateProviderInstanceIdForWizard, | |
| 36 | 41 | } from "./AddProviderInstanceDialog.logic"; |
| 37 | 42 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; |
| 43 | +import { usePrimaryEnvironment } from "../../state/environments"; | |
| 44 | +import { serverEnvironment } from "../../state/server"; | |
| 45 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 46 | +import { | |
| 47 | + defaultHermesConnectorUrl, | |
| 48 | + formatHermesLastConnected, | |
| 49 | + messageFromUnknownError, | |
| 50 | +} from "./HermesGatewayInstanceSection.logic"; | |
| 38 | 51 | |
| 39 | 52 | const PROVIDER_ACCENT_SWATCHES = [ |
| 40 | 53 | "#2563eb", |
| @@ -63,10 +76,12 @@ function slugifyLabel(value: string): string { · 22 unmodified lines | ||
| 63 | 76 | |
| 64 | 77 | function deriveInstanceId(driver: ProviderDriverKind, label: string): string { |
| 65 | 78 | const slug = slugifyLabel(label); |
| 79 | + if (driver === "hermes") { | |
| 80 | + return slug ? `hermes-${slug.replaceAll("_", "-")}` : ""; | |
| 81 | + } | |
| 66 | 82 | return slug ? `${driver}_${slug}` : ""; |
| 67 | 83 | } |
| 68 | 84 | |
| 69 | −const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | |
| 70 | 85 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 71 | 86 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; |
| 72 | 87 | const 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 | − */ | |
| 107 | −function validateInstanceId(id: string, existing: ReadonlySet<string>): string | null { | |
| 108 | − if (id.length === 0) return "Instance ID is required."; | |
| 109 | − if (id.length > 64) return "Instance ID must be 64 characters or fewer."; | |
| 110 | − if (!INSTANCE_ID_PATTERN.test(id)) { | |
| 111 | − return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; | |
| 112 | − } | |
| 113 | − if (existing.has(id)) return `An instance named '${id}' already exists.`; | |
| 114 | − return null; | |
| 115 | −} | |
| 116 | − | |
| 117 | 117 | interface AddProviderInstanceDialogProps { |
| 118 | 118 | open: boolean; |
| 119 | 119 | onOpenChange: (open: boolean) => void; |
| @@ -122,6 +122,13 @@ interface AddProviderInstanceDialogProps { · 2 unmodified lines | ||
| 122 | 122 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { |
| 123 | 123 | const settings = usePrimarySettings(); |
| 124 | 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 | 133 | const [wizardStep, setWizardStep] = useState(0); |
| 127 | 134 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); |
| @@ -134,6 +141,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines | ||
| 134 | 141 | // Errors are suppressed until the user has tried to submit once. After that |
| 135 | 142 | // they update live so fixing the problem clears the message in place. |
| 136 | 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 | 176 | const existingIds = useMemo( |
| 139 | 177 | () => new Set(Object.keys(settings.providerInstances ?? {})), |
| @@ -146,7 +184,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines | ||
| 146 | 184 | () => deriveProviderSettingsFields(driverOption), |
| 147 | 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 | 196 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; |
| 151 | 197 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 152 | 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 | 229 | setHasAttemptedSubmit(true); |
| 184 | 230 | if (instanceIdError !== null) return; |
| 185 | 231 | |
| @@ -195,15 +241,60 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 9 unmodified lines | ||
| 195 | 241 | ...(hasConfig ? { config } : {}), |
| 196 | 242 | }; |
| 197 | 243 | // `ProviderInstanceId.make` revalidates the slug; we've already checked |
| 198 | − // it via `validateInstanceId`, but going through the brand constructor | |
| 244 | + // it via `validateProviderInstanceIdForWizard`, but going through the brand constructor | |
| 199 | 245 | // keeps the type boundary honest and guards against any future drift in |
| 200 | 246 | // the slug rules. |
| 201 | 247 | const brandedId = ProviderInstanceId.make(instanceId); |
| 248 | + const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({ | |
| 249 | + driver, | |
| 250 | + instanceId, | |
| 251 | + createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, | |
| 252 | + }); | |
| 202 | 253 | const nextMap = { |
| 203 | 254 | ...settings.providerInstances, |
| 204 | 255 | [brandedId]: nextInstance, |
| 205 | 256 | }; |
| 206 | 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 | 298 | updateSettings({ providerInstances: nextMap }); |
| 208 | 299 | toastManager.add({ |
| 209 | 300 | type: "success", |
| @@ -212,11 +303,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 2 unmodified lines | ||
| 212 | 303 | }); |
| 213 | 304 | onOpenChange(false); |
| 214 | 305 | } catch (error) { |
| 306 | + const message = messageFromUnknownError(error); | |
| 307 | + setSaveError(message); | |
| 215 | 308 | toastManager.add({ |
| 216 | 309 | type: "error", |
| 217 | 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 | 325 | Configure an additional provider instance — for example, a second Codex install |
| 231 | 326 | pointed at a different workspace. |
| 232 | 327 | </DialogDescription> |
| 233 | − <AddProviderInstanceWizardSteps | |
| 234 | − currentStep={wizardStep} | |
| 235 | − summaries={wizardStepSummaries} | |
| 236 | − instanceIdError={instanceIdError} | |
| 237 | − onNavigation={applyWizardNavigation} | |
| 238 | − /> | |
| 328 | + {hermesEnrollment === null && createdHermesIdentity === null ? ( | |
| 329 | + <AddProviderInstanceWizardSteps | |
| 330 | + currentStep={wizardStep} | |
| 331 | + summaries={wizardStepSummaries} | |
| 332 | + instanceIdError={instanceIdError} | |
| 333 | + onNavigation={applyWizardNavigation} | |
| 334 | + /> | |
| 335 | + ) : null} | |
| 239 | 336 | </DialogHeader> |
| 240 | 337 | |
| 241 | 338 | <div |
| 242 | 339 | data-slot="dialog-panel" |
| 243 | 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> | |
| 246 | − <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> | |
| 247 | − <div id="add-instance-driver-label" className="text-sm font-medium text-foreground"> | |
| 248 | − Driver | |
| 342 | + {hermesEnrollment ? ( | |
| 343 | + <div className="grid gap-3"> | |
| 344 | + <div> | |
| 345 | + <p className="text-sm font-medium text-foreground">Connect Hermes</p> | |
| 346 | + <p className="text-xs text-muted-foreground"> | |
| 347 | + This one-time command expires{" "} | |
| 348 | + {formatHermesLastConnected(hermesEnrollment.expiresAt)}. | |
| 349 | + </p> | |
| 249 | 350 | </div> |
| 250 | − <RadioGroup | |
| 251 | − value={driver} | |
| 252 | − onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | |
| 253 | − aria-labelledby="add-instance-driver-label" | |
| 254 | − className="grid grid-cols-1 gap-2 sm:grid-cols-2" | |
| 255 | − > | |
| 256 | − {DRIVER_OPTIONS.map((option) => { | |
| 257 | − const IconComponent = option.icon; | |
| 258 | − return ( | |
| 259 | − <RadioPrimitive.Root | |
| 260 | − key={option.value} | |
| 261 | − value={option.value} | |
| 262 | − className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15" | |
| 263 | − > | |
| 264 | − <IconComponent className="size-4 shrink-0" aria-hidden /> | |
| 265 | − <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | |
| 266 | − {option.label} | |
| 267 | − </span> | |
| 268 | − <RadioPrimitive.Indicator | |
| 269 | − className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground" | |
| 270 | − aria-hidden | |
| 351 | + <div className="flex min-w-0 items-center gap-2"> | |
| 352 | + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-2 text-[11px]"> | |
| 353 | + {hermesEnrollment.command} | |
| 354 | + </code> | |
| 355 | + <Button | |
| 356 | + type="button" | |
| 357 | + size="icon-sm" | |
| 358 | + variant="outline" | |
| 359 | + onClick={() => copyToClipboard(hermesEnrollment.command, undefined)} | |
| 360 | + aria-label="Copy Hermes enrollment command" | |
| 361 | + > | |
| 362 | + {isCopied ? <CheckIcon /> : <CopyIcon />} | |
| 363 | + </Button> | |
| 364 | + </div> | |
| 365 | + <p className="text-[11px] text-muted-foreground"> | |
| 366 | + The persistent gateway credential is delivered directly to the plugin and is never | |
| 367 | + shown in T3 Code. | |
| 368 | + </p> | |
| 369 | + </div> | |
| 370 | + ) : ( | |
| 371 | + <AnimatedHeight> | |
| 372 | + <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> | |
| 373 | + <div | |
| 374 | + id="add-instance-driver-label" | |
| 375 | + className="text-sm font-medium text-foreground" | |
| 376 | + > | |
| 377 | + Driver | |
| 378 | + </div> | |
| 379 | + <RadioGroup | |
| 380 | + value={driver} | |
| 381 | + onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | |
| 382 | + aria-labelledby="add-instance-driver-label" | |
| 383 | + className="grid grid-cols-1 gap-2 sm:grid-cols-2" | |
| 384 | + > | |
| 385 | + {DRIVER_OPTIONS.map((option) => { | |
| 386 | + const IconComponent = option.icon; | |
| 387 | + return ( | |
| 388 | + <RadioPrimitive.Root | |
| 389 | + key={option.value} | |
| 390 | + value={option.value} | |
| 391 | + className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15" | |
| 271 | 392 | > |
| 272 | − <CheckIcon className="size-3.5 shrink-0" /> | |
| 273 | − </RadioPrimitive.Indicator> | |
| 274 | − {option.badgeLabel ? ( | |
| 393 | + <IconComponent className="size-4 shrink-0" aria-hidden /> | |
| 394 | + <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | |
| 395 | + {option.label} | |
| 396 | + </span> | |
| 397 | + <RadioPrimitive.Indicator | |
| 398 | + className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground" | |
| 399 | + aria-hidden | |
| 400 | + > | |
| 401 | + <CheckIcon className="size-3.5 shrink-0" /> | |
| 402 | + </RadioPrimitive.Indicator> | |
| 403 | + {option.badgeLabel ? ( | |
| 404 | + <Badge variant="warning" size="sm"> | |
| 405 | + {option.badgeLabel} | |
| 406 | + </Badge> | |
| 407 | + ) : null} | |
| 408 | + </RadioPrimitive.Root> | |
| 409 | + ); | |
| 410 | + })} | |
| 411 | + {COMING_SOON_DRIVER_OPTIONS.map((option) => { | |
| 412 | + const IconComponent = option.icon; | |
| 413 | + return ( | |
| 414 | + <RadioPrimitive.Root | |
| 415 | + key={option.value} | |
| 416 | + value={option.value} | |
| 417 | + disabled | |
| 418 | + className={cn( | |
| 419 | + "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", | |
| 420 | + )} | |
| 421 | + > | |
| 422 | + <IconComponent | |
| 423 | + className="size-4 shrink-0 text-muted-foreground" | |
| 424 | + aria-hidden | |
| 425 | + /> | |
| 426 | + <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | |
| 427 | + {option.label} | |
| 428 | + </span> | |
| 275 | 429 | <Badge variant="warning" size="sm"> |
| 276 | − {option.badgeLabel} | |
| 430 | + Coming Soon | |
| 277 | 431 | </Badge> |
| 278 | − ) : null} | |
| 279 | − </RadioPrimitive.Root> | |
| 280 | − ); | |
| 281 | − })} | |
| 282 | − {COMING_SOON_DRIVER_OPTIONS.map((option) => { | |
| 283 | − const IconComponent = option.icon; | |
| 284 | − return ( | |
| 285 | − <RadioPrimitive.Root | |
| 286 | − key={option.value} | |
| 287 | − value={option.value} | |
| 288 | − disabled | |
| 289 | − className={cn( | |
| 290 | − "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", | |
| 291 | − )} | |
| 292 | − > | |
| 293 | − <IconComponent | |
| 294 | − className="size-4 shrink-0 text-muted-foreground" | |
| 295 | − aria-hidden | |
| 296 | − /> | |
| 297 | − <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | |
| 298 | − {option.label} | |
| 299 | − </span> | |
| 300 | − <Badge variant="warning" size="sm"> | |
| 301 | − Coming Soon | |
| 302 | − </Badge> | |
| 303 | − </RadioPrimitive.Root> | |
| 304 | − ); | |
| 305 | − })} | |
| 306 | − </RadioGroup> | |
| 307 | − </div> | |
| 308 | − | |
| 309 | − <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 310 | − <span className="text-xs font-medium text-foreground">Label</span> | |
| 311 | − <Input | |
| 312 | − className="bg-background" | |
| 313 | − placeholder="e.g. Work" | |
| 314 | − value={label} | |
| 315 | − onChange={(event) => setLabel(event.target.value)} | |
| 316 | − /> | |
| 317 | − <span className="text-[11px] text-muted-foreground"> | |
| 318 | − Shown in the provider list. Optional. | |
| 319 | − </span> | |
| 320 | − </label> | |
| 432 | + </RadioPrimitive.Root> | |
| 433 | + ); | |
| 434 | + })} | |
| 435 | + </RadioGroup> | |
| 436 | + </div> | |
| 321 | 437 | |
| 322 | − <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 323 | − <span className="text-xs font-medium text-foreground">Instance ID</span> | |
| 324 | − <Input | |
| 325 | − className="bg-background" | |
| 326 | − placeholder={`${driver}_work`} | |
| 327 | − value={instanceId} | |
| 328 | − onChange={(event) => { | |
| 329 | − setInstanceIdOverride(event.target.value); | |
| 330 | − }} | |
| 331 | − aria-invalid={showInstanceIdError} | |
| 332 | − /> | |
| 333 | − {showInstanceIdError ? ( | |
| 334 | − <span className="text-[11px] text-destructive">{instanceIdError}</span> | |
| 335 | − ) : ( | |
| 438 | + <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 439 | + <span className="text-xs font-medium text-foreground">Label</span> | |
| 440 | + <Input | |
| 441 | + className="bg-background" | |
| 442 | + placeholder="e.g. Work" | |
| 443 | + value={label} | |
| 444 | + onChange={(event) => setLabel(event.target.value)} | |
| 445 | + /> | |
| 336 | 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 | 448 | </span> |
| 339 | − )} | |
| 340 | − </label> | |
| 449 | + </label> | |
| 341 | 450 | |
| 342 | − <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 343 | − <span className="text-xs font-medium text-foreground">Accent color</span> | |
| 344 | − <div className="flex min-w-0 flex-wrap items-center gap-2"> | |
| 345 | − <input | |
| 346 | − type="color" | |
| 347 | − value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]} | |
| 348 | − onChange={(event) => setAccentColor(event.target.value)} | |
| 349 | − aria-label="Provider instance accent color" | |
| 350 | − className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | |
| 451 | + <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 452 | + <span className="text-xs font-medium text-foreground">Instance ID</span> | |
| 453 | + <Input | |
| 454 | + className="bg-background" | |
| 455 | + placeholder={`${driver}_work`} | |
| 456 | + value={instanceId} | |
| 457 | + onChange={(event) => { | |
| 458 | + setInstanceIdOverride(event.target.value); | |
| 459 | + }} | |
| 460 | + aria-invalid={showInstanceIdError} | |
| 351 | 461 | /> |
| 352 | − <div className="flex flex-wrap gap-1.5"> | |
| 353 | − {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | |
| 354 | − const selected = accentColor.toLowerCase() === swatch; | |
| 355 | − return ( | |
| 356 | − <button | |
| 357 | − key={swatch} | |
| 358 | − type="button" | |
| 359 | − className={cn( | |
| 360 | − "size-6 cursor-pointer rounded-full border transition", | |
| 361 | − selected | |
| 362 | − ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | |
| 363 | − : "border-black/10 hover:scale-105 dark:border-white/20", | |
| 364 | − )} | |
| 365 | − style={{ backgroundColor: swatch }} | |
| 366 | − onClick={() => setAccentColor(swatch)} | |
| 367 | − aria-label={`Use ${swatch} accent`} | |
| 368 | − /> | |
| 369 | − ); | |
| 370 | − })} | |
| 462 | + {showInstanceIdError ? ( | |
| 463 | + <span className="text-[11px] text-destructive">{instanceIdError}</span> | |
| 464 | + ) : ( | |
| 465 | + <span className="text-[11px] text-muted-foreground"> | |
| 466 | + Routing key used by threads and sessions. Letters, digits, '-', or '_'. | |
| 467 | + </span> | |
| 468 | + )} | |
| 469 | + </label> | |
| 470 | + | |
| 471 | + <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | |
| 472 | + <span className="text-xs font-medium text-foreground">Accent color</span> | |
| 473 | + <div className="flex min-w-0 flex-wrap items-center gap-2"> | |
| 474 | + <input | |
| 475 | + type="color" | |
| 476 | + value={ | |
| 477 | + normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0] | |
| 478 | + } | |
| 479 | + onChange={(event) => setAccentColor(event.target.value)} | |
| 480 | + aria-label="Provider instance accent color" | |
| 481 | + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | |
| 482 | + /> | |
| 483 | + <div className="flex flex-wrap gap-1.5"> | |
| 484 | + {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | |
| 485 | + const selected = accentColor.toLowerCase() === swatch; | |
| 486 | + return ( | |
| 487 | + <button | |
| 488 | + key={swatch} | |
| 489 | + type="button" | |
| 490 | + className={cn( | |
| 491 | + "size-6 cursor-pointer rounded-full border transition", | |
| 492 | + selected | |
| 493 | + ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | |
| 494 | + : "border-black/10 hover:scale-105 dark:border-white/20", | |
| 495 | + )} | |
| 496 | + style={{ backgroundColor: swatch }} | |
| 497 | + onClick={() => setAccentColor(swatch)} | |
| 498 | + aria-label={`Use ${swatch} accent`} | |
| 499 | + /> | |
| 500 | + ); | |
| 501 | + })} | |
| 502 | + </div> | |
| 503 | + {accentColor ? ( | |
| 504 | + <Button | |
| 505 | + type="button" | |
| 506 | + size="sm" | |
| 507 | + variant="ghost" | |
| 508 | + className="h-7 px-2 text-xs text-muted-foreground" | |
| 509 | + onClick={() => setAccentColor("")} | |
| 510 | + > | |
| 511 | + Clear | |
| 512 | + </Button> | |
| 513 | + ) : null} | |
| 371 | 514 | </div> |
| 372 | − {accentColor ? ( | |
| 373 | − <Button | |
| 374 | − type="button" | |
| 375 | − size="sm" | |
| 376 | − variant="ghost" | |
| 377 | − className="h-7 px-2 text-xs text-muted-foreground" | |
| 378 | − onClick={() => setAccentColor("")} | |
| 379 | − > | |
| 380 | − Clear | |
| 381 | − </Button> | |
| 382 | − ) : null} | |
| 515 | + <span className="text-[11px] text-muted-foreground"> | |
| 516 | + Optional marker shown in the picker. | |
| 517 | + </span> | |
| 383 | 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 ? ( | |
| 390 | − <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | |
| 391 | − <ProviderSettingsForm | |
| 392 | − definition={driverOption} | |
| 393 | − value={configDraft} | |
| 394 | − idPrefix={`add-provider-${driver}`} | |
| 395 | − variant="dialog" | |
| 396 | − onChange={setConfigDraft} | |
| 397 | − /> | |
| 398 | − </div> | |
| 399 | − ) : wizardStep === 2 ? ( | |
| 400 | − <div className="grid gap-2"> | |
| 401 | − <p className="text-sm text-muted-foreground"> | |
| 402 | − This driver has no required configuration. You can add the instance now. | |
| 520 | + {driver === "hermes" && wizardStep === 2 ? ( | |
| 521 | + <label className="grid gap-2"> | |
| 522 | + <span className="text-xs font-medium text-foreground">Connector URL</span> | |
| 523 | + <Input | |
| 524 | + className="bg-background" | |
| 525 | + value={hermesConnectorUrl} | |
| 526 | + onChange={(event) => setHermesConnectorUrl(event.target.value)} | |
| 527 | + placeholder="wss://t3.example.com/api/hermes-gateway/ws" | |
| 528 | + spellCheck={false} | |
| 529 | + /> | |
| 530 | + <span className="text-[11px] text-muted-foreground"> | |
| 531 | + Defaults to this browser's origin and works over Tailscale or any | |
| 532 | + reachable network. | |
| 533 | + </span> | |
| 534 | + </label> | |
| 535 | + ) : driverSettingsFields.length > 0 ? ( | |
| 536 | + <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | |
| 537 | + <ProviderSettingsForm | |
| 538 | + definition={driverOption} | |
| 539 | + value={configDraft} | |
| 540 | + idPrefix={`add-provider-${driver}`} | |
| 541 | + variant="dialog" | |
| 542 | + onChange={setConfigDraft} | |
| 543 | + /> | |
| 544 | + </div> | |
| 545 | + ) : wizardStep === 2 ? ( | |
| 546 | + <div className="grid gap-2"> | |
| 547 | + <p className="text-sm text-muted-foreground"> | |
| 548 | + This driver has no required configuration. You can add the instance now. | |
| 549 | + </p> | |
| 550 | + </div> | |
| 551 | + ) : null} | |
| 552 | + {saveError ? <p className="text-xs text-destructive">{saveError}</p> : null} | |
| 553 | + {createdHermesIdentity ? ( | |
| 554 | + <p className="text-xs text-muted-foreground"> | |
| 555 | + The Hermes instance was added. Retry enrollment for this exact instance, or | |
| 556 | + close this dialog and finish pairing from its Settings card. | |
| 403 | 557 | </p> |
| 404 | − </div> | |
| 405 | − ) : null} | |
| 406 | − </AnimatedHeight> | |
| 558 | + ) : null} | |
| 559 | + </AnimatedHeight> | |
| 560 | + )} | |
| 407 | 561 | </div> |
| 408 | 562 | |
| 409 | 563 | <DialogFooter variant="bare"> |
| 410 | − <Button | |
| 411 | − variant="outline" | |
| 412 | − size="sm" | |
| 413 | − onClick={() => { | |
| 414 | − if (wizardStep === 0) { | |
| 415 | − onOpenChange(false); | |
| 416 | − return; | |
| 417 | − } | |
| 418 | − setWizardStep((step) => Math.max(0, step - 1)); | |
| 419 | − }} | |
| 420 | − > | |
| 421 | − {wizardStep === 0 ? "Cancel" : "Back"} | |
| 422 | − </Button> | |
| 423 | − {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( | |
| 564 | + {hermesEnrollment ? ( | |
| 565 | + <Button size="sm" onClick={() => onOpenChange(false)}> | |
| 566 | + Done | |
| 567 | + </Button> | |
| 568 | + ) : ( | |
| 569 | + <Button | |
| 570 | + variant="outline" | |
| 571 | + size="sm" | |
| 572 | + onClick={() => { | |
| 573 | + if (createdHermesIdentity) { | |
| 574 | + onOpenChange(false); | |
| 575 | + return; | |
| 576 | + } | |
| 577 | + if (wizardStep === 0) { | |
| 578 | + onOpenChange(false); | |
| 579 | + return; | |
| 580 | + } | |
| 581 | + setWizardStep((step) => Math.max(0, step - 1)); | |
| 582 | + }} | |
| 583 | + disabled={isSaving} | |
| 584 | + > | |
| 585 | + {createdHermesIdentity ? "Close" : wizardStep === 0 ? "Cancel" : "Back"} | |
| 586 | + </Button> | |
| 587 | + )} | |
| 588 | + {hermesEnrollment ? null : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( | |
| 424 | 589 | <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}> |
| 425 | 590 | Next |
| 426 | 591 | </Button> |
| 427 | 592 | ) : ( |
| 428 | − <Button size="sm" onClick={handleSave}> | |
| 429 | − Add instance | |
| 593 | + <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> | |
| 594 | + {isSaving ? <LoaderIcon className="animate-spin" /> : null} | |
| 595 | + {isSaving | |
| 596 | + ? createdHermesIdentity | |
| 597 | + ? "Retrying" | |
| 598 | + : "Adding" | |
| 599 | + : createdHermesIdentity | |
| 600 | + ? "Retry enrollment" | |
| 601 | + : "Add instance"} | |
| 430 | 602 | </Button> |
| 431 | 603 | )} |
| 432 | 604 | </DialogFooter> |
| @@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + defaultHermesConnectorUrl, | |
| 5 | + formatHermesLastConnected, | |
| 6 | + hermesGatewayStatusLabel, | |
| 7 | + messageFromUnknownError, | |
| 8 | + shouldApplyHermesConnectorStatusUrl, | |
| 9 | +} from "./HermesGatewayInstanceSection.logic"; | |
| 10 | + | |
| 11 | +describe("Hermes gateway settings logic", () => { | |
| 12 | + it("uses the current origin with the gateway WebSocket route", () => { | |
| 13 | + expect(defaultHermesConnectorUrl("https://siva.davis7.space:3774/settings")).toBe( | |
| 14 | + "https://siva.davis7.space:3774/api/hermes-gateway/ws", | |
| 15 | + ); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it("provides useful labels for every connection state", () => { | |
| 19 | + expect( | |
| 20 | + ["offline", "connecting", "connected", "upgrade-required", "revoked"].map((status) => | |
| 21 | + hermesGatewayStatusLabel(status as never), | |
| 22 | + ), | |
| 23 | + ).toEqual(["Offline", "Connecting", "Connected", "Upgrade required", "Revoked"]); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("keeps invalid server timestamps readable and extracts structured messages", () => { | |
| 27 | + expect(formatHermesLastConnected(null)).toBe("Never"); | |
| 28 | + expect(formatHermesLastConnected("not-a-date")).toBe("not-a-date"); | |
| 29 | + expect(messageFromUnknownError({ message: "Nickname is already used." })).toBe( | |
| 30 | + "Nickname is already used.", | |
| 31 | + ); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("does not replace an in-progress connector URL edit during status polling", () => { | |
| 35 | + expect(shouldApplyHermesConnectorStatusUrl(false)).toBe(true); | |
| 36 | + expect(shouldApplyHermesConnectorStatusUrl(true)).toBe(false); | |
| 37 | + }); | |
| 38 | +}); | |
| @@ -0,0 +1,46 @@ | ||
| 1 | +import type { HermesGatewayConnectionState } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +export const HERMES_GATEWAY_SOCKET_PATH = "/api/hermes-gateway/ws"; | |
| 4 | + | |
| 5 | +export function defaultHermesConnectorUrl(origin: string): string { | |
| 6 | + return new URL(HERMES_GATEWAY_SOCKET_PATH, origin).toString(); | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function shouldApplyHermesConnectorStatusUrl(hasLocalEdits: boolean): boolean { | |
| 10 | + return !hasLocalEdits; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function hermesGatewayStatusLabel(status: HermesGatewayConnectionState): string { | |
| 14 | + switch (status) { | |
| 15 | + case "connected": | |
| 16 | + return "Connected"; | |
| 17 | + case "connecting": | |
| 18 | + return "Connecting"; | |
| 19 | + case "upgrade-required": | |
| 20 | + return "Upgrade required"; | |
| 21 | + case "revoked": | |
| 22 | + return "Revoked"; | |
| 23 | + case "offline": | |
| 24 | + return "Offline"; | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +export function formatHermesLastConnected(value: string | null): string { | |
| 29 | + if (value === null) return "Never"; | |
| 30 | + const date = new Date(value); | |
| 31 | + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function messageFromUnknownError(error: unknown): string { | |
| 35 | + if (error instanceof Error && error.message.trim()) return error.message; | |
| 36 | + if ( | |
| 37 | + typeof error === "object" && | |
| 38 | + error !== null && | |
| 39 | + "message" in error && | |
| 40 | + typeof error.message === "string" && | |
| 41 | + error.message.trim() | |
| 42 | + ) { | |
| 43 | + return error.message; | |
| 44 | + } | |
| 45 | + return "The Hermes gateway request failed."; | |
| 46 | +} | |
| @@ -0,0 +1,276 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 4 | +import { | |
| 5 | + type HermesGatewayConnectionState, | |
| 6 | + type HermesGatewayEnrollmentResult, | |
| 7 | + type HermesGatewayInstanceStatus, | |
| 8 | + type ProviderInstanceId, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { CheckIcon, CopyIcon, LoaderIcon, RefreshCwIcon, UnplugIcon } from "lucide-react"; | |
| 11 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 12 | + | |
| 13 | +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 14 | +import { usePrimaryEnvironment } from "../../state/environments"; | |
| 15 | +import { serverEnvironment } from "../../state/server"; | |
| 16 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 17 | +import { Badge } from "../ui/badge"; | |
| 18 | +import { Button } from "../ui/button"; | |
| 19 | +import { Input } from "../ui/input"; | |
| 20 | +import { toastManager } from "../ui/toast"; | |
| 21 | +import { | |
| 22 | + defaultHermesConnectorUrl, | |
| 23 | + formatHermesLastConnected, | |
| 24 | + hermesGatewayStatusLabel, | |
| 25 | + messageFromUnknownError, | |
| 26 | + shouldApplyHermesConnectorStatusUrl, | |
| 27 | +} from "./HermesGatewayInstanceSection.logic"; | |
| 28 | + | |
| 29 | +const badgeVariantByStatus: Record< | |
| 30 | + HermesGatewayConnectionState, | |
| 31 | + "success" | "warning" | "error" | "secondary" | |
| 32 | +> = { | |
| 33 | + connected: "success", | |
| 34 | + connecting: "warning", | |
| 35 | + offline: "secondary", | |
| 36 | + "upgrade-required": "warning", | |
| 37 | + revoked: "error", | |
| 38 | +}; | |
| 39 | + | |
| 40 | +function browserDefaultConnectorUrl() { | |
| 41 | + if (typeof window === "undefined") return "http://localhost/api/hermes-gateway/ws"; | |
| 42 | + return defaultHermesConnectorUrl(window.location.origin); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function HermesGatewayInstanceSection(props: { | |
| 46 | + readonly instanceId: ProviderInstanceId; | |
| 47 | + readonly nickname: string; | |
| 48 | +}) { | |
| 49 | + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; | |
| 50 | + const getStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { | |
| 51 | + reportFailure: false, | |
| 52 | + }); | |
| 53 | + const createEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, { | |
| 54 | + reportFailure: false, | |
| 55 | + }); | |
| 56 | + const revokeInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { | |
| 57 | + reportFailure: false, | |
| 58 | + }); | |
| 59 | + const [connectorUrl, setConnectorUrl] = useState(browserDefaultConnectorUrl); | |
| 60 | + const [status, setStatus] = useState<HermesGatewayInstanceStatus | null>(null); | |
| 61 | + const [enrollment, setEnrollment] = useState<HermesGatewayEnrollmentResult | null>(null); | |
| 62 | + const [error, setError] = useState<string | null>(null); | |
| 63 | + const [pendingAction, setPendingAction] = useState<"status" | "enroll" | "revoke" | null>(null); | |
| 64 | + const connectorUrlHasLocalEditsRef = useRef(false); | |
| 65 | + const { copyToClipboard, isCopied } = useCopyToClipboard({ | |
| 66 | + target: "Hermes enrollment command", | |
| 67 | + onCopy: () => { | |
| 68 | + toastManager.add({ | |
| 69 | + type: "success", | |
| 70 | + title: "Hermes command copied", | |
| 71 | + description: "Run it in the terminal where Hermes is installed.", | |
| 72 | + }); | |
| 73 | + }, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + const refreshStatus = useCallback( | |
| 77 | + async (quiet = false) => { | |
| 78 | + if (environmentId === null) return; | |
| 79 | + if (!quiet) setPendingAction("status"); | |
| 80 | + const result = await getStatus({ | |
| 81 | + environmentId, | |
| 82 | + input: { instanceId: props.instanceId }, | |
| 83 | + }); | |
| 84 | + if (result._tag === "Success") { | |
| 85 | + setStatus(result.value); | |
| 86 | + if (shouldApplyHermesConnectorStatusUrl(connectorUrlHasLocalEditsRef.current)) { | |
| 87 | + setConnectorUrl(result.value.connectorUrl); | |
| 88 | + } | |
| 89 | + setError(null); | |
| 90 | + } else if (!quiet) { | |
| 91 | + setError(messageFromUnknownError(squashAtomCommandFailure(result))); | |
| 92 | + } | |
| 93 | + if (!quiet) setPendingAction(null); | |
| 94 | + }, | |
| 95 | + [environmentId, getStatus, props.instanceId], | |
| 96 | + ); | |
| 97 | + | |
| 98 | + useEffect(() => { | |
| 99 | + void refreshStatus(); | |
| 100 | + const interval = window.setInterval(() => void refreshStatus(true), 5_000); | |
| 101 | + return () => window.clearInterval(interval); | |
| 102 | + }, [refreshStatus]); | |
| 103 | + | |
| 104 | + const handleCreateEnrollment = async () => { | |
| 105 | + if (environmentId === null) { | |
| 106 | + setError("Connect this browser to a T3 server before pairing Hermes."); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + setPendingAction("enroll"); | |
| 110 | + setError(null); | |
| 111 | + const result = await createEnrollment({ | |
| 112 | + environmentId, | |
| 113 | + input: { | |
| 114 | + instanceId: props.instanceId, | |
| 115 | + nickname: props.nickname, | |
| 116 | + connectorUrl, | |
| 117 | + }, | |
| 118 | + }); | |
| 119 | + if (result._tag === "Success") { | |
| 120 | + connectorUrlHasLocalEditsRef.current = false; | |
| 121 | + setConnectorUrl(result.value.connectorUrl); | |
| 122 | + setEnrollment(result.value); | |
| 123 | + setStatus((current) => | |
| 124 | + current | |
| 125 | + ? { ...current, connectorUrl: result.value.connectorUrl, status: "offline" } | |
| 126 | + : current, | |
| 127 | + ); | |
| 128 | + } else { | |
| 129 | + setError(messageFromUnknownError(squashAtomCommandFailure(result))); | |
| 130 | + } | |
| 131 | + setPendingAction(null); | |
| 132 | + }; | |
| 133 | + | |
| 134 | + const handleRevoke = async () => { | |
| 135 | + if (environmentId === null) return; | |
| 136 | + setPendingAction("revoke"); | |
| 137 | + setError(null); | |
| 138 | + const result = await revokeInstance({ | |
| 139 | + environmentId, | |
| 140 | + input: { instanceId: props.instanceId }, | |
| 141 | + }); | |
| 142 | + if (result._tag === "Success") { | |
| 143 | + setStatus(result.value); | |
| 144 | + setEnrollment(null); | |
| 145 | + } else { | |
| 146 | + setError(messageFromUnknownError(squashAtomCommandFailure(result))); | |
| 147 | + } | |
| 148 | + setPendingAction(null); | |
| 149 | + }; | |
| 150 | + | |
| 151 | + const connectionState = status?.status ?? "offline"; | |
| 152 | + const isBusy = pendingAction !== null; | |
| 153 | + | |
| 154 | + return ( | |
| 155 | + <section className="grid gap-4 rounded-lg border border-border/70 bg-muted/15 p-3"> | |
| 156 | + <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 157 | + <div> | |
| 158 | + <p className="text-xs font-medium text-foreground">Hermes gateway</p> | |
| 159 | + <p className="text-xs text-muted-foreground"> | |
| 160 | + Pair an already-running Hermes process over any reachable network. | |
| 161 | + </p> | |
| 162 | + </div> | |
| 163 | + <div className="flex items-center gap-1.5"> | |
| 164 | + <Badge variant={badgeVariantByStatus[connectionState]} size="sm"> | |
| 165 | + {hermesGatewayStatusLabel(connectionState)} | |
| 166 | + </Badge> | |
| 167 | + <Button | |
| 168 | + type="button" | |
| 169 | + size="icon-xs" | |
| 170 | + variant="ghost" | |
| 171 | + disabled={isBusy || environmentId === null} | |
| 172 | + onClick={() => void refreshStatus()} | |
| 173 | + aria-label="Refresh Hermes gateway status" | |
| 174 | + > | |
| 175 | + <RefreshCwIcon className={pendingAction === "status" ? "animate-spin" : undefined} /> | |
| 176 | + </Button> | |
| 177 | + </div> | |
| 178 | + </div> | |
| 179 | + | |
| 180 | + <label className="grid gap-1.5"> | |
| 181 | + <span className="text-xs font-medium text-foreground">Connector URL</span> | |
| 182 | + <Input | |
| 183 | + value={connectorUrl} | |
| 184 | + onChange={(event) => { | |
| 185 | + connectorUrlHasLocalEditsRef.current = true; | |
| 186 | + setConnectorUrl(event.target.value); | |
| 187 | + }} | |
| 188 | + placeholder="wss://t3.example.com/api/hermes-gateway/ws" | |
| 189 | + spellCheck={false} | |
| 190 | + disabled={connectionState === "revoked"} | |
| 191 | + /> | |
| 192 | + <span className="text-[11px] text-muted-foreground"> | |
| 193 | + Defaults to this browser'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 | +} | |
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import type { ServerProviderModel } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | −import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; | |
| 4 | +import { | |
| 5 | + deriveProviderModelsForDisplay, | |
| 6 | + isProviderInstanceDisplayNameEditable, | |
| 7 | +} from "./ProviderInstanceCard"; | |
| 5 | 8 | |
| 6 | 9 | describe("deriveProviderModelsForDisplay", () => { |
| 7 | 10 | it("uses current config custom models instead of stale live custom rows", () => { |
| @@ -34,3 +37,11 @@ describe("deriveProviderModelsForDisplay", () => { · 26 unmodified lines | ||
| 34 | 37 | ).toEqual(["server-model", "kept-custom"]); |
| 35 | 38 | }); |
| 36 | 39 | }); |
| 40 | + | |
| 41 | +describe("isProviderInstanceDisplayNameEditable", () => { | |
| 42 | + it("keeps Hermes nicknames immutable without changing other providers", () => { | |
| 43 | + expect(isProviderInstanceDisplayNameEditable("hermes")).toBe(false); | |
| 44 | + expect(isProviderInstanceDisplayNameEditable("codex")).toBe(true); | |
| 45 | + expect(isProviderInstanceDisplayNameEditable("claudeAgent")).toBe(true); | |
| 46 | + }); | |
| 47 | +}); | |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | import * as Arr from "effect/Array"; |
| 14 | 14 | import * as Result from "effect/Result"; |
| 15 | 15 | import { useState, type ReactNode } from "react"; |
| 16 | +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 16 | 17 | import { |
| 17 | 18 | isProviderDriverKind, |
| 18 | 19 | type ProviderInstanceConfig, |
| @@ -26,6 +27,9 @@ import { · 7 unmodified lines | ||
| 26 | 27 | import { cn } from "../../lib/utils"; |
| 27 | 28 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 28 | 29 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 30 | +import { usePrimaryEnvironment } from "../../state/environments"; | |
| 31 | +import { serverEnvironment } from "../../state/server"; | |
| 32 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 29 | 33 | import { Badge } from "../ui/badge"; |
| 30 | 34 | import { Button } from "../ui/button"; |
| 31 | 35 | import { Checkbox } from "../ui/checkbox"; |
| @@ -43,6 +47,8 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 11 unmodified lines | ||
| 43 | 47 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 44 | 48 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 45 | 49 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 50 | +import { HermesGatewayInstanceSection } from "./HermesGatewayInstanceSection"; | |
| 51 | +import { messageFromUnknownError } from "./HermesGatewayInstanceSection.logic"; | |
| 46 | 52 | import { |
| 47 | 53 | getProviderVersionAdvisoryPresentation, |
| 48 | 54 | PROVIDER_STATUS_STYLES, |
| @@ -131,6 +137,10 @@ export function deriveProviderModelsForDisplay(input: { · 82 unmodified lines | ||
| 131 | 137 | return [...serverModels, ...customModels]; |
| 132 | 138 | } |
| 133 | 139 | |
| 140 | +export function isProviderInstanceDisplayNameEditable(driver: string): boolean { | |
| 141 | + return driver !== "hermes"; | |
| 142 | +} | |
| 143 | + | |
| 134 | 144 | function ProviderAuthEmail(props: { |
| 135 | 145 | readonly email: string | undefined; |
| 136 | 146 | readonly prefix?: string; |
| @@ -394,6 +404,11 @@ export function ProviderInstanceCard({ · 257 unmodified lines | ||
| 394 | 404 | onRunUpdate, |
| 395 | 405 | isUpdating = false, |
| 396 | 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 | 412 | const enabled = instance.enabled ?? true; |
| 398 | 413 | // The server-reported status wins when present; otherwise fall back to |
| 399 | 414 | // "disabled"/"warning" based on the local `enabled` flag so the dot |
| @@ -413,8 +428,21 @@ export function ProviderInstanceCard({ · 13 unmodified lines | ||
| 413 | 428 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); |
| 414 | 429 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 415 | 430 | const FallbackIconComponent = driverOption?.icon; |
| 416 | − const displayName = | |
| 417 | − instance.displayName?.trim() || driverOption?.label || String(instance.driver); | |
| 431 | + const configuredDisplayName = instance.displayName?.trim(); | |
| 432 | + const isHermes = instance.driver === "hermes"; | |
| 433 | + const hermesNickname = | |
| 434 | + configuredDisplayName?.replace(/^Hermes\s*[·:–—-]?\s*/u, "").trim() || | |
| 435 | + (instanceId === "hermes" | |
| 436 | + ? "Hermes" | |
| 437 | + : String(instanceId) | |
| 438 | + .replace(/^hermes[-_]?/u, "") | |
| 439 | + .replace(/[-_]+/g, " ") | |
| 440 | + .trim()); | |
| 441 | + const displayName = isHermes | |
| 442 | + ? instanceId === "hermes" || hermesNickname === "Hermes" | |
| 443 | + ? "Hermes" | |
| 444 | + : `Hermes · ${hermesNickname}` | |
| 445 | + : configuredDisplayName || driverOption?.label || String(instance.driver); | |
| 418 | 446 | const accentColor = normalizeProviderAccentColor(instance.accentColor); |
| 419 | 447 | const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ |
| 420 | 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 | 575 | const titleIconNode = driverKind ? ( |
| 505 | 576 | <ProviderInstanceIcon |
| 506 | 577 | driverKind={driverKind} |
| @@ -563,10 +634,15 @@ export function ProviderInstanceCard({ · 56 unmodified lines | ||
| 563 | 634 | size="icon-xs" |
| 564 | 635 | variant="ghost" |
| 565 | 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 | 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 | 646 | </Button> |
| 571 | 647 | } |
| 572 | 648 | /> |
| @@ -730,22 +806,38 @@ export function ProviderInstanceCard({ · 157 unmodified lines | ||
| 730 | 806 | <Collapsible open={isExpanded} onOpenChange={onExpandedChange}> |
| 731 | 807 | <CollapsibleContent> |
| 732 | 808 | <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4"> |
| 733 | − <div> | |
| 734 | − <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | |
| 735 | − <span className="text-xs font-medium text-foreground">Display name</span> | |
| 736 | − <DraftInput | |
| 737 | − id={`provider-instance-${instanceId}-display-name`} | |
| 738 | − className="mt-1.5" | |
| 739 | − value={instance.displayName ?? ""} | |
| 740 | − onCommit={updateDisplayName} | |
| 741 | − placeholder={driverOption?.label ?? "Instance label"} | |
| 742 | − spellCheck={false} | |
| 743 | − /> | |
| 809 | + {isProviderInstanceDisplayNameEditable(String(instance.driver)) ? ( | |
| 810 | + <div> | |
| 811 | + <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | |
| 812 | + <span className="text-xs font-medium text-foreground">Display name</span> | |
| 813 | + <DraftInput | |
| 814 | + id={`provider-instance-${instanceId}-display-name`} | |
| 815 | + className="mt-1.5" | |
| 816 | + value={instance.displayName ?? ""} | |
| 817 | + onCommit={updateDisplayName} | |
| 818 | + placeholder={driverOption?.label ?? "Instance label"} | |
| 819 | + spellCheck={false} | |
| 820 | + /> | |
| 821 | + <span className="mt-1 block text-xs text-muted-foreground"> | |
| 822 | + Optional label shown in the provider list. | |
| 823 | + </span> | |
| 824 | + </label> | |
| 825 | + </div> | |
| 826 | + ) : ( | |
| 827 | + <div> | |
| 828 | + <span className="text-xs font-medium text-foreground">Hermes nickname</span> | |
| 829 | + <div | |
| 830 | + className="mt-1.5 rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-sm text-foreground" | |
| 831 | + data-hermes-nickname-readonly="true" | |
| 832 | + > | |
| 833 | + {displayName} | |
| 834 | + </div> | |
| 744 | 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 | 838 | </span> |
| 747 | − </label> | |
| 748 | − </div> | |
| 839 | + </div> | |
| 840 | + )} | |
| 749 | 841 | |
| 750 | 842 | <div> |
| 751 | 843 | <ProviderAccentColorPicker |
| @@ -757,14 +849,18 @@ export function ProviderInstanceCard({ · 5 unmodified lines | ||
| 757 | 849 | /> |
| 758 | 850 | </div> |
| 759 | 851 | |
| 760 | − <div> | |
| 761 | − <ProviderEnvironmentSection | |
| 762 | − environment={instance.environment ?? []} | |
| 763 | − onChange={updateEnvironment} | |
| 764 | − /> | |
| 765 | − </div> | |
| 852 | + {isHermes ? ( | |
| 853 | + <HermesGatewayInstanceSection instanceId={instanceId} nickname={hermesNickname} /> | |
| 854 | + ) : ( | |
| 855 | + <div> | |
| 856 | + <ProviderEnvironmentSection | |
| 857 | + environment={instance.environment ?? []} | |
| 858 | + onChange={updateEnvironment} | |
| 859 | + /> | |
| 860 | + </div> | |
| 861 | + )} | |
| 766 | 862 | |
| 767 | − {driverOption ? ( | |
| 863 | + {driverOption && !isHermes ? ( | |
| 768 | 864 | <ProviderSettingsForm |
| 769 | 865 | definition={driverOption} |
| 770 | 866 | value={instance.config} |
| @@ -774,7 +870,7 @@ export function ProviderInstanceCard({ · 3 unmodified lines | ||
| 774 | 870 | /> |
| 775 | 871 | ) : null} |
| 776 | 872 | |
| 777 | − {driverOption !== undefined ? ( | |
| 873 | + {driverOption !== undefined && !isHermes ? ( | |
| 778 | 874 | <ProviderModelsSection |
| 779 | 875 | instanceId={instanceId} |
| 780 | 876 | driverKind={driverKind} |
| @@ -788,7 +884,7 @@ export function ProviderInstanceCard({ · 7 unmodified lines | ||
| 788 | 884 | onFavoriteModelsChange={onFavoriteModelsChange} |
| 789 | 885 | onModelOrderChange={onModelOrderChange} |
| 790 | 886 | /> |
| 791 | − ) : ( | |
| 887 | + ) : driverOption === undefined ? ( | |
| 792 | 888 | <div> |
| 793 | 889 | <p className="text-xs text-muted-foreground"> |
| 794 | 890 | This instance uses a driver ( |
| @@ -797,7 +893,7 @@ export function ProviderInstanceCard({ · 2 unmodified lines | ||
| 797 | 893 | edited from this surface. |
| 798 | 894 | </p> |
| 799 | 895 | </div> |
| 800 | − )} | |
| 896 | + ) : null} | |
| 801 | 897 | </div> |
| 802 | 898 | </CollapsibleContent> |
| 803 | 899 | </Collapsible> |
| @@ -3,11 +3,13 @@ import { · 2 unmodified lines | ||
| 3 | 3 | CodexSettings, |
| 4 | 4 | CursorSettings, |
| 5 | 5 | GrokSettings, |
| 6 | + HermesSettings, | |
| 6 | 7 | OpenCodeSettings, |
| 7 | 8 | ProviderDriverKind, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import type * as Schema from "effect/Schema"; |
| 10 | 11 | import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 12 | +import { HermesIcon } from "../HermesIcon"; | |
| 11 | 13 | |
| 12 | 14 | type ProviderSettingsSchema = { |
| 13 | 15 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| @@ -67,6 +69,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = · 53 unmodified lines | ||
| 67 | 69 | icon: OpenCodeIcon, |
| 68 | 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 | |
| 72 | 80 | export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< |
| @@ -64,7 +64,7 @@ function readInstanceCustomModels( · 63 unmodified lines | ||
| 64 | 64 | } |
| 65 | 65 | const legacyProviders = settings.providers as Record< |
| 66 | 66 | string, |
| 67 | − { readonly customModels: ReadonlyArray<string> } | undefined | |
| 67 | + { readonly customModels?: ReadonlyArray<string> } | undefined | |
| 68 | 68 | >; |
| 69 | 69 | return legacyProviders[driverKind]?.customModels ?? []; |
| 70 | 70 | } |
| @@ -5,7 +5,10 @@ import { · 4 unmodified lines | ||
| 5 | 5 | deriveProviderInstanceEntries, |
| 6 | 6 | getDefaultProviderInstanceModel, |
| 7 | 7 | isProviderInstancePickerReady, |
| 8 | + isProviderInstanceComposerAvailable, | |
| 9 | + getProviderInstanceComposerUnavailableMessage, | |
| 8 | 10 | isProviderInstancePickerVisible, |
| 11 | + resolveComposerProviderInstanceId, | |
| 9 | 12 | resolveDefaultProviderModelSelection, |
| 10 | 13 | resolveSelectableProviderInstance, |
| 11 | 14 | resolveProviderDriverKindForInstanceSelection, |
| @@ -68,6 +71,66 @@ describe("isProviderInstancePickerReady", () => { · 56 unmodified lines | ||
| 68 | 71 | }); |
| 69 | 72 | }); |
| 70 | 73 | |
| 74 | +describe("Hermes composer availability", () => { | |
| 75 | + it("keeps an offline Hermes instance selected but marks it unsendable", () => { | |
| 76 | + const [entry] = deriveProviderInstanceEntries([ | |
| 77 | + provider({ | |
| 78 | + provider: ProviderDriverKind.make("hermes"), | |
| 79 | + instanceId: "hermes-research", | |
| 80 | + displayName: "Research", | |
| 81 | + status: "error", | |
| 82 | + }), | |
| 83 | + ]); | |
| 84 | + | |
| 85 | + expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(false); | |
| 86 | + expect(getProviderInstanceComposerUnavailableMessage(entry)).toContain( | |
| 87 | + "Hermes · Research is offline", | |
| 88 | + ); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("does not tighten legacy composer availability for other providers", () => { | |
| 92 | + const [entry] = deriveProviderInstanceEntries([ | |
| 93 | + provider({ | |
| 94 | + provider: ProviderDriverKind.make("codex"), | |
| 95 | + instanceId: "codex", | |
| 96 | + status: "error", | |
| 97 | + }), | |
| 98 | + ]); | |
| 99 | + | |
| 100 | + expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(true); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + it("preserves a missing bound Hermes instance instead of rebinding to another gateway", () => { | |
| 104 | + const missing = ProviderInstanceId.make("hermes-research"); | |
| 105 | + const connected = ProviderInstanceId.make("hermes-lab"); | |
| 106 | + const entries = deriveProviderInstanceEntries([ | |
| 107 | + provider({ | |
| 108 | + provider: ProviderDriverKind.make("hermes"), | |
| 109 | + instanceId: connected, | |
| 110 | + }), | |
| 111 | + ]); | |
| 112 | + | |
| 113 | + expect( | |
| 114 | + resolveComposerProviderInstanceId({ | |
| 115 | + entries, | |
| 116 | + draftInstanceId: null, | |
| 117 | + threadInstanceId: missing, | |
| 118 | + threadModelInstanceId: missing, | |
| 119 | + projectInstanceId: connected, | |
| 120 | + requestedDriverKind: ProviderDriverKind.make("hermes"), | |
| 121 | + lockedProvider: ProviderDriverKind.make("hermes"), | |
| 122 | + lockedContinuationGroupKey: null, | |
| 123 | + }), | |
| 124 | + ).toBe(missing); | |
| 125 | + expect( | |
| 126 | + getProviderInstanceComposerUnavailableMessage(undefined, { | |
| 127 | + driverKind: ProviderDriverKind.make("hermes"), | |
| 128 | + instanceId: missing, | |
| 129 | + }), | |
| 130 | + ).toContain("hermes-research"); | |
| 131 | + }); | |
| 132 | +}); | |
| 133 | + | |
| 71 | 134 | describe("isProviderInstancePickerVisible", () => { |
| 72 | 135 | it("keeps enabled instances in the rail and removes disabled instances", () => { |
| 73 | 136 | const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ |
| @@ -130,6 +193,32 @@ describe("deriveProviderInstanceEntries", () => { · 56 unmodified lines | ||
| 130 | 193 | expect(entry?.driverKind).toBe("codex"); |
| 131 | 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 | |
| 135 | 224 | describe("resolveSelectableProviderInstance", () => { |
| @@ -74,6 +74,31 @@ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boo · 73 unmodified lines | ||
| 74 | 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 | + */ | |
| 82 | +export function isProviderInstanceComposerAvailable(entry: ProviderInstanceEntry): boolean { | |
| 83 | + return entry.driverKind !== "hermes" || isProviderInstancePickerReady(entry); | |
| 84 | +} | |
| 85 | + | |
| 86 | +export function getProviderInstanceComposerUnavailableMessage( | |
| 87 | + entry: ProviderInstanceEntry | undefined, | |
| 88 | + missingSelection?: { | |
| 89 | + readonly driverKind: ProviderDriverKind; | |
| 90 | + readonly instanceId: ProviderInstanceId; | |
| 91 | + }, | |
| 92 | +): string { | |
| 93 | + if (entry?.driverKind === "hermes") { | |
| 94 | + return `${entry.displayName} is offline. Reconnect it in Settings to continue this thread.`; | |
| 95 | + } | |
| 96 | + if (missingSelection?.driverKind === "hermes") { | |
| 97 | + return `Hermes instance '${missingSelection.instanceId}' is unavailable. Restore it in Settings to continue this thread.`; | |
| 98 | + } | |
| 99 | + return "Enable a provider in Settings to send a message."; | |
| 100 | +} | |
| 101 | + | |
| 77 | 102 | /** Picker rails contain configured, enabled instances only. */ |
| 78 | 103 | export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { |
| 79 | 104 | return entry.enabled; |
| @@ -139,6 +164,15 @@ function resolveInstanceDisplayName( · 59 unmodified lines | ||
| 139 | 164 | ): string { |
| 140 | 165 | const trimmedSnapshotName = snapshot.displayName?.trim(); |
| 141 | 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 | 176 | if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { |
| 143 | 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 | + */ | |
| 356 | +export function resolveComposerProviderInstanceId(input: { | |
| 357 | + readonly entries: ReadonlyArray<ProviderInstanceEntry>; | |
| 358 | + readonly draftInstanceId: ProviderInstanceId | null; | |
| 359 | + readonly threadInstanceId: ProviderInstanceId | undefined; | |
| 360 | + readonly threadModelInstanceId: ProviderInstanceId | undefined; | |
| 361 | + readonly projectInstanceId: ProviderInstanceId | undefined; | |
| 362 | + readonly requestedDriverKind: ProviderDriverKind; | |
| 363 | + readonly lockedProvider: ProviderDriverKind | null; | |
| 364 | + readonly lockedContinuationGroupKey: string | null; | |
| 365 | +}): ProviderInstanceId { | |
| 366 | + const boundThreadInstanceId = input.threadInstanceId ?? input.threadModelInstanceId; | |
| 367 | + if (input.lockedProvider === "hermes" && boundThreadInstanceId) { | |
| 368 | + return boundThreadInstanceId; | |
| 369 | + } | |
| 370 | + | |
| 371 | + const candidates = [ | |
| 372 | + input.draftInstanceId, | |
| 373 | + input.threadInstanceId, | |
| 374 | + input.threadModelInstanceId, | |
| 375 | + input.projectInstanceId, | |
| 376 | + ]; | |
| 377 | + for (const candidate of candidates) { | |
| 378 | + if (!candidate) continue; | |
| 379 | + const match = input.entries.find( | |
| 380 | + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, | |
| 381 | + ); | |
| 382 | + if (!match) continue; | |
| 383 | + if (input.lockedProvider && match.driverKind !== input.lockedProvider) continue; | |
| 384 | + if ( | |
| 385 | + input.lockedContinuationGroupKey && | |
| 386 | + match.continuationGroupKey !== input.lockedContinuationGroupKey | |
| 387 | + ) { | |
| 388 | + continue; | |
| 389 | + } | |
| 390 | + return match.instanceId; | |
| 391 | + } | |
| 392 | + | |
| 393 | + const compatibleEntries = input.entries.filter( | |
| 394 | + (entry) => | |
| 395 | + (!input.lockedProvider || entry.driverKind === input.lockedProvider) && | |
| 396 | + (!input.lockedContinuationGroupKey || | |
| 397 | + entry.continuationGroupKey === input.lockedContinuationGroupKey), | |
| 398 | + ); | |
| 399 | + const requestedDriverEntries = compatibleEntries.filter( | |
| 400 | + (entry) => entry.driverKind === input.requestedDriverKind, | |
| 401 | + ); | |
| 402 | + return ( | |
| 403 | + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? | |
| 404 | + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? | |
| 405 | + NO_PROVIDER_MODEL_SELECTION.instanceId | |
| 406 | + ); | |
| 407 | +} | |
| 408 | + | |
| 315 | 409 | /** |
| 316 | 410 | * Resolve the routing key for a selection that may reference an instance |
| 317 | 411 | * id that no longer exists (e.g. a persisted thread selection after the |
| @@ -0,0 +1,48 @@ | ||
| 1 | +# Hermes event compatibility inventory | |
| 2 | + | |
| 3 | +The plugin deliberately uses only the public Hermes plugin and platform-adapter | |
| 4 | +surfaces audited at Hermes Agent commit | |
| 5 | +`3910ab28c0892fcf846fc61318d2fd15689eddf1`. | |
| 6 | + | |
| 7 | +## Mapped in V1 | |
| 8 | + | |
| 9 | +| Hermes surface | T3 gateway event | | |
| 10 | +| ----------------------------------------- | ---------------------------------------------- | | |
| 11 | +| Cumulative `send` / `edit_message` output | `content.delta` | | |
| 12 | +| Final stream edit | `item.completed`, `turn.completed` | | |
| 13 | +| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | | |
| 14 | +| Live adapter status text | Generic `unknown` activity item | | |
| 15 | +| `send_exec_approval` | `request.opened` / `request.resolved` | | |
| 16 | +| `send_clarify` | `user-input.requested` / `user-input.resolved` | | |
| 17 | +| `/steer` gateway command | `turn.steer` | | |
| 18 | +| Adapter interrupt event | `turn.interrupt` | | |
| 19 | + | |
| 20 | +## Known limitations | |
| 21 | + | |
| 22 | +- The platform adapter receives cumulative rendered text, not the underlying | |
| 23 | + token stream category. V1 maps it to `assistant_text`; Hermes reasoning, | |
| 24 | + plan, and command-output stream categories are not publicly exposed here. | |
| 25 | +- The delta contract has no replacement operation. Rare edits that revise | |
| 26 | + already-emitted text become a meaningful generic activity instead of | |
| 27 | + corrupting the T3 transcript. | |
| 28 | +- Hermes' documented tool hook surface exposes a `task_id`, tool name, | |
| 29 | + arguments, string result, and duration. The audited runtime additionally | |
| 30 | + supplies `session_id` and `tool_call_id`; V1 uses them when present and falls | |
| 31 | + back to the documented IDs for older versions. It projects only canonical, | |
| 32 | + whitelisted fields (command/cwd, file path, search query, image path, or MCP | |
| 33 | + server/operation); arbitrary arguments and raw results never cross the wire. | |
| 34 | +- Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI | |
| 35 | + prompt, then resolve the oldest matching Hermes approval for that session. | |
| 36 | +- The public `clarify` hook is a single question. The wire protocol supports an | |
| 37 | + array so richer structured input can be added without a protocol break. | |
| 38 | +- Hermes session completion has no dedicated platform-adapter callback. The | |
| 39 | + plugin uses the stream consumer's required `finalize=True` edit as the | |
| 40 | + authoritative completion boundary. | |
| 41 | +- Active `/steer` dispatch returns a textual Hermes control acknowledgement | |
| 42 | + through the normal platform `send(..., notify=True)` path. The plugin | |
| 43 | + captures that response in the originating steering request's async context, | |
| 44 | + suppresses it from the transcript, and acknowledges T3 only when the audited | |
| 45 | + Hermes success response begins with `⏩ Steer queued`. Unknown future response | |
| 46 | + shapes fail closed with `protocol.error` rather than completing the turn. | |
| 47 | +- Attachments are not accepted. They are the first planned post-stability | |
| 48 | + feature; the capability is reserved and fixed to `false` in protocol V1. | |
| @@ -0,0 +1,71 @@ | ||
| 1 | +# Hermes T3 Code Gateway | |
| 2 | + | |
| 3 | +Experimental Hermes platform plugin for connecting one already-running Hermes | |
| 4 | +process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes | |
| 5 | +does not need to listen on a public port. | |
| 6 | + | |
| 7 | +Each T3 thread maps deterministically to one Hermes gateway session. A new T3 | |
| 8 | +thread creates a new session identity, later messages use the same identity, | |
| 9 | +active-turn messages use Hermes' native `/steer` path, and stopping a turn does | |
| 10 | +not delete its transcript. | |
| 11 | + | |
| 12 | +## Install from this repository | |
| 13 | + | |
| 14 | +Copy or symlink this directory into the active Hermes profile: | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +mkdir -p ~/.hermes/plugins | |
| 18 | +ln -s /path/to/t3code/integrations/hermes-t3-gateway \ | |
| 19 | + ~/.hermes/plugins/hermes-t3-gateway | |
| 20 | +hermes plugins enable hermes-t3-gateway | |
| 21 | +``` | |
| 22 | + | |
| 23 | +In T3 Code, open the Hermes instance settings, choose **Add Hermes**, enter a | |
| 24 | +unique nickname, and copy the generated enrollment command. It has this shape: | |
| 25 | + | |
| 26 | +```bash | |
| 27 | +hermes t3 connect \ | |
| 28 | + --url https://siva.davis7.space:<port> \ | |
| 29 | + --token <one-time-token> | |
| 30 | +``` | |
| 31 | + | |
| 32 | +`--url` accepts an HTTP(S) browser origin or an explicit WS(S) URL. The command | |
| 33 | +normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated | |
| 34 | +`connection.hello` frame, and saves these values with Hermes' | |
| 35 | +profile-aware `save_env_value` helper: | |
| 36 | + | |
| 37 | +- `HERMES_T3_GATEWAY_URL` | |
| 38 | +- `HERMES_T3_GATEWAY_INSTANCE_ID` | |
| 39 | +- `HERMES_T3_GATEWAY_CREDENTIAL` | |
| 40 | +- `HERMES_T3_GATEWAY_NICKNAME` | |
| 41 | + | |
| 42 | +The long-lived credential is never printed. Restart `hermes gateway` after | |
| 43 | +enrollment. `hermes t3 status` reports the local enrollment without revealing | |
| 44 | +the credential. | |
| 45 | + | |
| 46 | +## V1 scope | |
| 47 | + | |
| 48 | +- Text input and live assistant streaming | |
| 49 | +- Multiple concurrent Hermes sessions in one process | |
| 50 | +- Active-turn steering and interrupt | |
| 51 | +- Dangerous-command approvals | |
| 52 | +- Structured `clarify` questions | |
| 53 | +- Tool lifecycle activity through Hermes plugin hooks | |
| 54 | +- Reconnect with bounded backoff | |
| 55 | +- Version-incompatible and revoked credentials fail closed | |
| 56 | + | |
| 57 | +Attachments intentionally advertise `false`. Adding bounded image/file input is | |
| 58 | +the first post-stability feature and should not reuse arbitrary raw payloads. | |
| 59 | + | |
| 60 | +See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface | |
| 61 | +limitations. | |
| 62 | + | |
| 63 | +## Tests | |
| 64 | + | |
| 65 | +The pure protocol and transport tests do not require a live Hermes or T3 server: | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +python -m unittest discover \ | |
| 69 | + integrations/hermes-t3-gateway/tests \ | |
| 70 | + -p 'test_*.py' | |
| 71 | +``` | |
| @@ -0,0 +1,77 @@ | ||
| 1 | +"""T3 Code gateway plugin registration for Hermes Agent.""" | |
| 2 | +# ruff: noqa: N999 - Hermes loads hyphenated plugin directories dynamically. | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +from .adapter import ( | |
| 7 | + T3PlatformAdapter, | |
| 8 | + check_requirements, | |
| 9 | + env_enablement, | |
| 10 | + validate_config, | |
| 11 | +) | |
| 12 | +from .cli import register_cli, t3_command | |
| 13 | + | |
| 14 | + | |
| 15 | +def _pre_tool_call( | |
| 16 | + tool_name: str, | |
| 17 | + args: dict, | |
| 18 | + task_id: str, | |
| 19 | + **kwargs, | |
| 20 | +) -> None: | |
| 21 | + session_id = str(kwargs.get("session_id") or task_id) | |
| 22 | + tool_call_id = str(kwargs.get("tool_call_id") or "") | |
| 23 | + T3PlatformAdapter.route_tool_started(tool_name, args, session_id, tool_call_id) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _post_tool_call( | |
| 27 | + tool_name: str, | |
| 28 | + args: dict, | |
| 29 | + result: str, | |
| 30 | + task_id: str, | |
| 31 | + duration_ms: int | None = None, | |
| 32 | + **kwargs, | |
| 33 | +) -> None: | |
| 34 | + del args | |
| 35 | + session_id = str(kwargs.get("session_id") or task_id) | |
| 36 | + tool_call_id = str(kwargs.get("tool_call_id") or "") | |
| 37 | + status = str(kwargs.get("status") or "") | |
| 38 | + T3PlatformAdapter.route_tool_completed( | |
| 39 | + tool_name, result, session_id, duration_ms, tool_call_id, status | |
| 40 | + ) | |
| 41 | + | |
| 42 | + | |
| 43 | +def register(ctx) -> None: | |
| 44 | + ctx.register_platform( | |
| 45 | + name="t3", | |
| 46 | + label="T3 Code", | |
| 47 | + adapter_factory=lambda config: T3PlatformAdapter(config), | |
| 48 | + check_fn=check_requirements, | |
| 49 | + validate_config=validate_config, | |
| 50 | + required_env=[ | |
| 51 | + "HERMES_T3_GATEWAY_URL", | |
| 52 | + "HERMES_T3_GATEWAY_INSTANCE_ID", | |
| 53 | + "HERMES_T3_GATEWAY_CREDENTIAL", | |
| 54 | + ], | |
| 55 | + env_enablement_fn=env_enablement, | |
| 56 | + max_message_length=120_000, | |
| 57 | + emoji="🔺", | |
| 58 | + pii_safe=True, | |
| 59 | + platform_hint=( | |
| 60 | + "You are chatting through T3 Code. Preserve normal Hermes behavior; " | |
| 61 | + "T3 renders streamed text, tool activity, approvals, and questions." | |
| 62 | + ), | |
| 63 | + ) | |
| 64 | + ctx.register_cli_command( | |
| 65 | + name="t3", | |
| 66 | + help="Pair and inspect the T3 Code gateway", | |
| 67 | + setup_fn=register_cli, | |
| 68 | + handler_fn=t3_command, | |
| 69 | + description=( | |
| 70 | + "Connect this Hermes process to a named T3 Code provider instance." | |
| 71 | + ), | |
| 72 | + ) | |
| 73 | + ctx.register_hook("pre_tool_call", _pre_tool_call) | |
| 74 | + ctx.register_hook("post_tool_call", _post_tool_call) | |
| 75 | + | |
| 76 | + | |
| 77 | +__all__ = ["register"] | |
| @@ -0,0 +1,940 @@ | ||
| 1 | +"""Hermes platform adapter that treats each T3 thread as one Hermes session.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import contextvars | |
| 7 | +import logging | |
| 8 | +import os | |
| 9 | +import threading | |
| 10 | +import uuid | |
| 11 | +import weakref | |
| 12 | +from dataclasses import dataclass, field | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +from gateway.config import Platform, PlatformConfig | |
| 16 | +from gateway.platforms.base import ( | |
| 17 | + BasePlatformAdapter, | |
| 18 | + MessageEvent, | |
| 19 | + MessageType, | |
| 20 | + SendResult, | |
| 21 | +) | |
| 22 | +from gateway.session import build_session_key | |
| 23 | + | |
| 24 | +from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV | |
| 25 | +from .connection import T3GatewayConnection, dependency_available | |
| 26 | +from .protocol import ( | |
| 27 | + canonical_tool_data, | |
| 28 | + canonical_tool_item_type, | |
| 29 | + frame, | |
| 30 | + iso_now, | |
| 31 | + item_id, | |
| 32 | + protocol_error, | |
| 33 | + validate_server_frame, | |
| 34 | +) | |
| 35 | + | |
| 36 | +logger = logging.getLogger(__name__) | |
| 37 | + | |
| 38 | + | |
| 39 | +def _hermes_version() -> str: | |
| 40 | + try: | |
| 41 | + from hermes_cli import __version__ | |
| 42 | + | |
| 43 | + return str(__version__) | |
| 44 | + except Exception: # noqa: BLE001 - version discovery must not block loading | |
| 45 | + return "unknown" | |
| 46 | + | |
| 47 | + | |
| 48 | +@dataclass | |
| 49 | +class _TurnState: | |
| 50 | + thread_id: str | |
| 51 | + session_id: str | |
| 52 | + turn_id: str | |
| 53 | + request_id: str | |
| 54 | + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) | |
| 55 | + visible_text: str = "" | |
| 56 | + assistant_started: bool = False | |
| 57 | + tool_items: dict[str, str] = field(default_factory=dict) | |
| 58 | + generic_activity_id: str | None = None | |
| 59 | + generic_activity_detail: str | None = None | |
| 60 | + | |
| 61 | + | |
| 62 | +@dataclass | |
| 63 | +class _SteerControlResponse: | |
| 64 | + thread_id: str | |
| 65 | + request_id: str | |
| 66 | + messages: list[str] = field(default_factory=list) | |
| 67 | + | |
| 68 | + | |
| 69 | +_steer_control_response = contextvars.ContextVar[_SteerControlResponse | None]( | |
| 70 | + "hermes_t3_steer_control_response", | |
| 71 | + default=None, | |
| 72 | +) | |
| 73 | + | |
| 74 | + | |
| 75 | +class T3PlatformAdapter(BasePlatformAdapter): | |
| 76 | + """One process-level T3 connection serving many isolated thread sessions.""" | |
| 77 | + | |
| 78 | + supports_code_blocks = True | |
| 79 | + supports_status_text = True | |
| 80 | + REQUIRES_EDIT_FINALIZE = True | |
| 81 | + MAX_MESSAGE_LENGTH = 120_000 | |
| 82 | + _instances: weakref.WeakSet[T3PlatformAdapter] = weakref.WeakSet() | |
| 83 | + | |
| 84 | + def __init__(self, config: PlatformConfig): | |
| 85 | + super().__init__(config, Platform("t3")) | |
| 86 | + extra = config.extra or {} | |
| 87 | + self._url = str(extra.get("url") or os.environ.get(URL_ENV, "")).strip() | |
| 88 | + self._instance_id = str( | |
| 89 | + extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "") | |
| 90 | + ).strip() | |
| 91 | + self._credential = str( | |
| 92 | + extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "") | |
| 93 | + ).strip() | |
| 94 | + self._nickname = str( | |
| 95 | + extra.get("nickname") or os.environ.get(NICKNAME_ENV, "") or "Hermes" | |
| 96 | + ).strip() | |
| 97 | + self._connection: T3GatewayConnection | None = None | |
| 98 | + self._event_loop: asyncio.AbstractEventLoop | None = None | |
| 99 | + self._sessions: dict[str, str] = {} | |
| 100 | + self._active_session_threads: set[str] = set() | |
| 101 | + self._thread_by_session: dict[str, str] = {} | |
| 102 | + self._active_turns: dict[str, _TurnState] = {} | |
| 103 | + self._approval_requests: dict[str, tuple[str, str]] = {} | |
| 104 | + self._user_input_requests: dict[str, tuple[str, str]] = {} | |
| 105 | + type(self)._instances.add(self) | |
| 106 | + | |
| 107 | + @property | |
| 108 | + def name(self) -> str: | |
| 109 | + return f"T3 Code ({self._nickname})" | |
| 110 | + | |
| 111 | + @property | |
| 112 | + def authorization_is_upstream(self) -> bool: | |
| 113 | + # The only source of inbound messages is T3's instance-authenticated | |
| 114 | + # socket. There is no separate Hermes-side user allowlist. | |
| 115 | + return True | |
| 116 | + | |
| 117 | + async def connect(self, *, is_reconnect: bool = False) -> bool: | |
| 118 | + del is_reconnect | |
| 119 | + if not (self._url and self._instance_id and self._credential): | |
| 120 | + self._set_fatal_error( | |
| 121 | + "t3_not_enrolled", | |
| 122 | + "Run `hermes t3 connect --url <url> --token <token>` first.", | |
| 123 | + retryable=False, | |
| 124 | + ) | |
| 125 | + return False | |
| 126 | + self._event_loop = asyncio.get_running_loop() | |
| 127 | + self._connection = T3GatewayConnection( | |
| 128 | + url=self._url, | |
| 129 | + instance_id=self._instance_id, | |
| 130 | + credential=self._credential, | |
| 131 | + hermes_version=_hermes_version(), | |
| 132 | + on_message=self._handle_server_frame, | |
| 133 | + on_state=self._handle_connection_state, | |
| 134 | + ) | |
| 135 | + try: | |
| 136 | + connected = await self._connection.connect() | |
| 137 | + except Exception as exc: # noqa: BLE001 - transport supplies typed rejection details | |
| 138 | + self._set_fatal_error("t3_connection_rejected", str(exc), retryable=False) | |
| 139 | + return False | |
| 140 | + if connected: | |
| 141 | + self._mark_connected() | |
| 142 | + await self._send_status() | |
| 143 | + return connected | |
| 144 | + | |
| 145 | + async def disconnect(self) -> None: | |
| 146 | + if self._connection is not None: | |
| 147 | + await self._connection.disconnect() | |
| 148 | + self._connection = None | |
| 149 | + self._mark_disconnected() | |
| 150 | + | |
| 151 | + async def send( | |
| 152 | + self, | |
| 153 | + chat_id: str, | |
| 154 | + content: str, | |
| 155 | + reply_to: str | None = None, | |
| 156 | + metadata: dict[str, Any] | None = None, | |
| 157 | + ) -> SendResult: | |
| 158 | + del reply_to | |
| 159 | + captured = self._capture_steer_control_response(chat_id, content) | |
| 160 | + if captured is not None: | |
| 161 | + return captured | |
| 162 | + turn = self._active_turns.get(str(chat_id)) | |
| 163 | + if turn is None: | |
| 164 | + return SendResult(success=False, error="no active T3 turn") | |
| 165 | + try: | |
| 166 | + await self._emit_assistant_content(turn, content) | |
| 167 | + if bool((metadata or {}).get("notify")): | |
| 168 | + await self._complete_turn(turn) | |
| 169 | + return SendResult(success=True, message_id=turn.message_id) | |
| 170 | + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult | |
| 171 | + return SendResult(success=False, error=str(exc)) | |
| 172 | + | |
| 173 | + async def edit_message( | |
| 174 | + self, | |
| 175 | + chat_id: str, | |
| 176 | + message_id: str, | |
| 177 | + content: str, | |
| 178 | + *, | |
| 179 | + finalize: bool = False, | |
| 180 | + metadata: dict[str, Any] | None = None, | |
| 181 | + ) -> SendResult: | |
| 182 | + del metadata | |
| 183 | + captured = self._capture_steer_control_response(chat_id, content) | |
| 184 | + if captured is not None: | |
| 185 | + return captured | |
| 186 | + turn = self._active_turns.get(str(chat_id)) | |
| 187 | + if turn is None: | |
| 188 | + return SendResult(success=False, error="no active T3 turn") | |
| 189 | + try: | |
| 190 | + await self._emit_assistant_content(turn, content) | |
| 191 | + if finalize: | |
| 192 | + await self._complete_turn(turn) | |
| 193 | + return SendResult(success=True, message_id=message_id) | |
| 194 | + except Exception as exc: # noqa: BLE001 - adapter edit must return SendResult | |
| 195 | + return SendResult(success=False, error=str(exc)) | |
| 196 | + | |
| 197 | + async def get_chat_info(self, chat_id: str) -> dict[str, Any]: | |
| 198 | + return {"name": f"T3 thread {chat_id}", "type": "dm"} | |
| 199 | + | |
| 200 | + async def send_typing( | |
| 201 | + self, chat_id: str, metadata: dict[str, Any] | None = None | |
| 202 | + ) -> None: | |
| 203 | + del metadata | |
| 204 | + turn = self._active_turns.get(str(chat_id)) | |
| 205 | + if turn is None: | |
| 206 | + return | |
| 207 | + status = getattr(self, "_status_text", {}).get(str(chat_id)) | |
| 208 | + if status: | |
| 209 | + await self._emit_generic_activity(turn, status) | |
| 210 | + | |
| 211 | + def set_status_text(self, chat_id: str, text: str | None) -> None: | |
| 212 | + super().set_status_text(chat_id, text) | |
| 213 | + if not text: | |
| 214 | + return | |
| 215 | + turn = self._active_turns.get(str(chat_id)) | |
| 216 | + if turn is not None: | |
| 217 | + self._schedule(self._emit_generic_activity(turn, text)) | |
| 218 | + | |
| 219 | + async def send_exec_approval( | |
| 220 | + self, | |
| 221 | + chat_id: str, | |
| 222 | + command: str, | |
| 223 | + session_key: str, | |
| 224 | + description: str, | |
| 225 | + metadata: dict[str, Any] | None = None, | |
| 226 | + **kwargs: Any, | |
| 227 | + ) -> SendResult: | |
| 228 | + del metadata, kwargs | |
| 229 | + turn = self._active_turns.get(str(chat_id)) | |
| 230 | + if turn is None: | |
| 231 | + return SendResult(success=False, error="no active T3 turn") | |
| 232 | + approval_id = str(uuid.uuid4()) | |
| 233 | + self._approval_requests[approval_id] = (session_key, turn.turn_id) | |
| 234 | + await self._send_frame( | |
| 235 | + frame( | |
| 236 | + "request.opened", | |
| 237 | + threadId=turn.thread_id, | |
| 238 | + sessionId=turn.session_id, | |
| 239 | + turnId=turn.turn_id, | |
| 240 | + requestId=approval_id, | |
| 241 | + requestType="command_execution_approval", | |
| 242 | + detail=description or "Hermes requests permission to run a command", | |
| 243 | + args={"command": command}, | |
| 244 | + ) | |
| 245 | + ) | |
| 246 | + return SendResult(success=True, message_id=approval_id) | |
| 247 | + | |
| 248 | + async def send_clarify( | |
| 249 | + self, | |
| 250 | + chat_id: str, | |
| 251 | + question: str, | |
| 252 | + choices: list[Any] | None, | |
| 253 | + clarify_id: str, | |
| 254 | + session_key: str, | |
| 255 | + metadata: dict[str, Any] | None = None, | |
| 256 | + ) -> SendResult: | |
| 257 | + del metadata | |
| 258 | + turn = self._active_turns.get(str(chat_id)) | |
| 259 | + if turn is None: | |
| 260 | + return SendResult(success=False, error="no active T3 turn") | |
| 261 | + options = [] | |
| 262 | + for choice in choices or []: | |
| 263 | + label = str(choice.get("label") if isinstance(choice, dict) else choice) | |
| 264 | + description = ( | |
| 265 | + str(choice.get("description") or label) | |
| 266 | + if isinstance(choice, dict) | |
| 267 | + else label | |
| 268 | + ) | |
| 269 | + options.append({"label": label, "description": description}) | |
| 270 | + self._user_input_requests[clarify_id] = (session_key, turn.turn_id) | |
| 271 | + await self._send_frame( | |
| 272 | + frame( | |
| 273 | + "user-input.requested", | |
| 274 | + threadId=turn.thread_id, | |
| 275 | + sessionId=turn.session_id, | |
| 276 | + turnId=turn.turn_id, | |
| 277 | + requestId=clarify_id, | |
| 278 | + questions=[ | |
| 279 | + { | |
| 280 | + "id": clarify_id, | |
| 281 | + "header": "Hermes", | |
| 282 | + "question": question, | |
| 283 | + "options": options, | |
| 284 | + "multiSelect": False, | |
| 285 | + } | |
| 286 | + ], | |
| 287 | + ) | |
| 288 | + ) | |
| 289 | + return SendResult(success=True, message_id=clarify_id) | |
| 290 | + | |
| 291 | + async def _handle_server_frame(self, raw: dict[str, Any]) -> None: | |
| 292 | + request = raw.get("requestId") | |
| 293 | + try: | |
| 294 | + message = validate_server_frame(raw) | |
| 295 | + frame_type = message["type"] | |
| 296 | + if frame_type == "session.ensure": | |
| 297 | + await self._ensure_session(message) | |
| 298 | + elif frame_type == "turn.start": | |
| 299 | + await self._start_turn(message) | |
| 300 | + elif frame_type == "turn.steer": | |
| 301 | + await self._steer_turn(message) | |
| 302 | + elif frame_type == "turn.interrupt": | |
| 303 | + await self._interrupt_turn(message) | |
| 304 | + elif frame_type == "approval.respond": | |
| 305 | + await self._resolve_approval(message) | |
| 306 | + elif frame_type == "user-input.respond": | |
| 307 | + await self._resolve_user_input(message) | |
| 308 | + elif frame_type == "session.stop": | |
| 309 | + await self._stop_session(message) | |
| 310 | + elif frame_type == "ping": | |
| 311 | + await self._send_frame( | |
| 312 | + frame( | |
| 313 | + "pong", | |
| 314 | + requestId=message["requestId"], | |
| 315 | + sentAt=message.get("sentAt") or iso_now(), | |
| 316 | + ) | |
| 317 | + ) | |
| 318 | + except ValueError as exc: | |
| 319 | + await self._send_frame( | |
| 320 | + protocol_error( | |
| 321 | + "unsupported-message", | |
| 322 | + str(exc), | |
| 323 | + recoverable=True, | |
| 324 | + related_request_id=str(request) if request else None, | |
| 325 | + ) | |
| 326 | + ) | |
| 327 | + except Exception as exc: | |
| 328 | + logger.exception("T3 gateway command failed") | |
| 329 | + await self._send_frame( | |
| 330 | + protocol_error( | |
| 331 | + "internal-error", | |
| 332 | + str(exc) or type(exc).__name__, | |
| 333 | + recoverable=True, | |
| 334 | + related_request_id=str(request) if request else None, | |
| 335 | + ) | |
| 336 | + ) | |
| 337 | + | |
| 338 | + async def _ensure_session(self, message: dict[str, Any]) -> None: | |
| 339 | + thread_id = str(message["threadId"]) | |
| 340 | + source = self._source(thread_id, str(message["requestId"])) | |
| 341 | + session_id = build_session_key(source) | |
| 342 | + resume_id = str(message.get("resumeSessionId") or "") | |
| 343 | + self._sessions[thread_id] = session_id | |
| 344 | + self._active_session_threads.add(thread_id) | |
| 345 | + self._thread_by_session[session_id] = thread_id | |
| 346 | + await self._send_frame( | |
| 347 | + frame( | |
| 348 | + "session.ready", | |
| 349 | + requestId=message["requestId"], | |
| 350 | + threadId=thread_id, | |
| 351 | + sessionId=session_id, | |
| 352 | + resumed=bool(resume_id and resume_id == session_id), | |
| 353 | + ) | |
| 354 | + ) | |
| 355 | + await self._send_status() | |
| 356 | + | |
| 357 | + async def _start_turn(self, message: dict[str, Any]) -> None: | |
| 358 | + thread_id = str(message["threadId"]) | |
| 359 | + session_id = self._sessions.get(thread_id) | |
| 360 | + if not session_id or session_id != str(message["sessionId"]): | |
| 361 | + await self._send_frame( | |
| 362 | + protocol_error( | |
| 363 | + "session-not-found", | |
| 364 | + "Call session.ensure before starting a turn.", | |
| 365 | + recoverable=True, | |
| 366 | + related_request_id=str(message["requestId"]), | |
| 367 | + ) | |
| 368 | + ) | |
| 369 | + return | |
| 370 | + if thread_id in self._active_turns: | |
| 371 | + await self._send_frame( | |
| 372 | + protocol_error( | |
| 373 | + "invalid-message", | |
| 374 | + "This Hermes session already has an active turn; use turn.steer.", | |
| 375 | + recoverable=True, | |
| 376 | + related_request_id=str(message["requestId"]), | |
| 377 | + ) | |
| 378 | + ) | |
| 379 | + return | |
| 380 | + turn = _TurnState( | |
| 381 | + thread_id=thread_id, | |
| 382 | + session_id=session_id, | |
| 383 | + turn_id=str(message["turnId"]), | |
| 384 | + request_id=str(message["requestId"]), | |
| 385 | + ) | |
| 386 | + self._active_turns[thread_id] = turn | |
| 387 | + await self._send_frame( | |
| 388 | + frame( | |
| 389 | + "turn.started", | |
| 390 | + requestId=turn.request_id, | |
| 391 | + threadId=thread_id, | |
| 392 | + sessionId=session_id, | |
| 393 | + turnId=turn.turn_id, | |
| 394 | + ) | |
| 395 | + ) | |
| 396 | + await self._send_status() | |
| 397 | + await self.handle_message( | |
| 398 | + MessageEvent( | |
| 399 | + text=str(message["text"]), | |
| 400 | + message_type=( | |
| 401 | + MessageType.COMMAND | |
| 402 | + if str(message["text"]).lstrip().startswith("/") | |
| 403 | + else MessageType.TEXT | |
| 404 | + ), | |
| 405 | + source=self._source(thread_id, turn.request_id), | |
| 406 | + message_id=turn.request_id, | |
| 407 | + metadata={"t3_turn_id": turn.turn_id}, | |
| 408 | + ) | |
| 409 | + ) | |
| 410 | + | |
| 411 | + async def _steer_turn(self, message: dict[str, Any]) -> None: | |
| 412 | + turn = self._active_turns.get(str(message["threadId"])) | |
| 413 | + if turn is None or turn.turn_id != str(message["turnId"]): | |
| 414 | + await self._send_frame( | |
| 415 | + protocol_error( | |
| 416 | + "turn-not-active", | |
| 417 | + "The requested Hermes turn is no longer active.", | |
| 418 | + recoverable=True, | |
| 419 | + related_request_id=str(message["requestId"]), | |
| 420 | + ) | |
| 421 | + ) | |
| 422 | + return | |
| 423 | + # `/steer` is Hermes' official active-run injection surface. The base | |
| 424 | + # adapter dispatches active slash commands inline, then sends the | |
| 425 | + # command's textual acknowledgement back through this adapter with | |
| 426 | + # `notify=True`. Capture that one command response by request context: | |
| 427 | + # it is control traffic, not assistant output and not a turn boundary. | |
| 428 | + control = _SteerControlResponse( | |
| 429 | + thread_id=turn.thread_id, | |
| 430 | + request_id=str(message["requestId"]), | |
| 431 | + ) | |
| 432 | + context_token = _steer_control_response.set(control) | |
| 433 | + command_error: Exception | None = None | |
| 434 | + try: | |
| 435 | + await self.handle_message( | |
| 436 | + MessageEvent( | |
| 437 | + text=f"/steer {message['text']}", | |
| 438 | + message_type=MessageType.COMMAND, | |
| 439 | + source=self._source(turn.thread_id, control.request_id), | |
| 440 | + message_id=control.request_id, | |
| 441 | + metadata={"t3_turn_id": turn.turn_id, "t3_steer": True}, | |
| 442 | + ) | |
| 443 | + ) | |
| 444 | + except Exception as exc: # noqa: BLE001 - translate command failures to the wire | |
| 445 | + command_error = exc | |
| 446 | + finally: | |
| 447 | + _steer_control_response.reset(context_token) | |
| 448 | + | |
| 449 | + if command_error is not None: | |
| 450 | + await self._send_frame( | |
| 451 | + protocol_error( | |
| 452 | + "internal-error", | |
| 453 | + str(command_error) or "Hermes steering failed.", | |
| 454 | + recoverable=True, | |
| 455 | + related_request_id=control.request_id, | |
| 456 | + ) | |
| 457 | + ) | |
| 458 | + return | |
| 459 | + | |
| 460 | + response = control.messages[-1] if control.messages else "" | |
| 461 | + if not response.startswith("⏩ Steer queued"): | |
| 462 | + if response.startswith(("Agent still starting", "No active agent")): | |
| 463 | + error_code = "turn-not-active" | |
| 464 | + elif response.startswith("⚠️ Steer failed"): | |
| 465 | + error_code = "internal-error" | |
| 466 | + else: | |
| 467 | + error_code = "invalid-message" | |
| 468 | + await self._send_frame( | |
| 469 | + protocol_error( | |
| 470 | + error_code, | |
| 471 | + response or "Hermes did not acknowledge the steering request.", | |
| 472 | + recoverable=True, | |
| 473 | + related_request_id=control.request_id, | |
| 474 | + ) | |
| 475 | + ) | |
| 476 | + return | |
| 477 | + | |
| 478 | + # Correlated command acknowledgement. It intentionally reuses the | |
| 479 | + # existing turnId: this is not a second runtime turn. T3 consumes the | |
| 480 | + # steering requestId as its broker acknowledgement and suppresses the | |
| 481 | + # duplicate turn-start lifecycle projection. | |
| 482 | + await self._send_frame( | |
| 483 | + frame( | |
| 484 | + "turn.started", | |
| 485 | + requestId=control.request_id, | |
| 486 | + threadId=turn.thread_id, | |
| 487 | + sessionId=turn.session_id, | |
| 488 | + turnId=turn.turn_id, | |
| 489 | + ) | |
| 490 | + ) | |
| 491 | + | |
| 492 | + def _capture_steer_control_response( | |
| 493 | + self, | |
| 494 | + chat_id: str, | |
| 495 | + content: str, | |
| 496 | + ) -> SendResult | None: | |
| 497 | + control = _steer_control_response.get() | |
| 498 | + if control is None or control.thread_id != str(chat_id): | |
| 499 | + return None | |
| 500 | + control.messages.append(str(content)) | |
| 501 | + return SendResult( | |
| 502 | + success=True, | |
| 503 | + message_id=f"t3-steer-control-{control.request_id}", | |
| 504 | + ) | |
| 505 | + | |
| 506 | + async def _interrupt_turn(self, message: dict[str, Any]) -> None: | |
| 507 | + thread_id = str(message["threadId"]) | |
| 508 | + turn = self._active_turns.get(thread_id) | |
| 509 | + if turn is None or turn.turn_id != str(message["turnId"]): | |
| 510 | + await self._send_frame( | |
| 511 | + protocol_error( | |
| 512 | + "turn-not-active", | |
| 513 | + "The requested Hermes turn is no longer active.", | |
| 514 | + recoverable=True, | |
| 515 | + related_request_id=str(message["requestId"]), | |
| 516 | + ) | |
| 517 | + ) | |
| 518 | + return | |
| 519 | + await self.interrupt_session_activity(turn.session_id, thread_id) | |
| 520 | + await self._send_frame( | |
| 521 | + frame( | |
| 522 | + "turn.aborted", | |
| 523 | + threadId=thread_id, | |
| 524 | + sessionId=turn.session_id, | |
| 525 | + turnId=turn.turn_id, | |
| 526 | + reason="Interrupted by T3 Code", | |
| 527 | + ) | |
| 528 | + ) | |
| 529 | + self._active_turns.pop(thread_id, None) | |
| 530 | + await self._send_status() | |
| 531 | + | |
| 532 | + async def _resolve_approval(self, message: dict[str, Any]) -> None: | |
| 533 | + request_id = str(message["requestId"]) | |
| 534 | + pending = self._approval_requests.pop(request_id, None) | |
| 535 | + if pending is None: | |
| 536 | + await self._send_frame( | |
| 537 | + protocol_error( | |
| 538 | + "request-not-found", | |
| 539 | + "The Hermes approval request is no longer pending.", | |
| 540 | + recoverable=True, | |
| 541 | + related_request_id=request_id, | |
| 542 | + ) | |
| 543 | + ) | |
| 544 | + return | |
| 545 | + session_key, _turn_id = pending | |
| 546 | + decision = str(message["decision"]) | |
| 547 | + choice = { | |
| 548 | + "accept": "once", | |
| 549 | + "acceptForSession": "session", | |
| 550 | + "decline": "deny", | |
| 551 | + "cancel": "deny", | |
| 552 | + }.get(decision, "deny") | |
| 553 | + from tools.approval import resolve_gateway_approval | |
| 554 | + | |
| 555 | + resolved = resolve_gateway_approval(session_key, choice) | |
| 556 | + await self._send_frame( | |
| 557 | + frame( | |
| 558 | + "request.resolved", | |
| 559 | + threadId=message["threadId"], | |
| 560 | + sessionId=message["sessionId"], | |
| 561 | + turnId=message["turnId"], | |
| 562 | + requestId=request_id, | |
| 563 | + requestType="command_execution_approval", | |
| 564 | + decision=decision, | |
| 565 | + resolution={"resolvedCount": resolved}, | |
| 566 | + ) | |
| 567 | + ) | |
| 568 | + | |
| 569 | + async def _resolve_user_input(self, message: dict[str, Any]) -> None: | |
| 570 | + request_id = str(message["requestId"]) | |
| 571 | + pending = self._user_input_requests.pop(request_id, None) | |
| 572 | + if pending is None: | |
| 573 | + await self._send_frame( | |
| 574 | + protocol_error( | |
| 575 | + "request-not-found", | |
| 576 | + "The Hermes user-input request is no longer pending.", | |
| 577 | + recoverable=True, | |
| 578 | + related_request_id=request_id, | |
| 579 | + ) | |
| 580 | + ) | |
| 581 | + return | |
| 582 | + answers = message.get("answers") or {} | |
| 583 | + answer = answers.get(request_id) if isinstance(answers, dict) else None | |
| 584 | + if answer is None and isinstance(answers, dict) and answers: | |
| 585 | + answer = next(iter(answers.values())) | |
| 586 | + if isinstance(answer, list): | |
| 587 | + response = ", ".join(str(value) for value in answer) | |
| 588 | + else: | |
| 589 | + response = str(answer or "") | |
| 590 | + from tools.clarify_gateway import resolve_gateway_clarify | |
| 591 | + | |
| 592 | + resolved = resolve_gateway_clarify(request_id, response) | |
| 593 | + await self._send_frame( | |
| 594 | + frame( | |
| 595 | + "user-input.resolved", | |
| 596 | + threadId=message["threadId"], | |
| 597 | + sessionId=message["sessionId"], | |
| 598 | + turnId=message["turnId"], | |
| 599 | + requestId=request_id, | |
| 600 | + answers=answers, | |
| 601 | + ) | |
| 602 | + ) | |
| 603 | + if not resolved: | |
| 604 | + logger.warning( | |
| 605 | + "Hermes clarify request %s was no longer pending", request_id | |
| 606 | + ) | |
| 607 | + | |
| 608 | + async def _stop_session(self, message: dict[str, Any]) -> None: | |
| 609 | + thread_id = str(message["threadId"]) | |
| 610 | + session_id = self._sessions.get(thread_id) | |
| 611 | + if session_id is None: | |
| 612 | + await self._send_frame( | |
| 613 | + protocol_error( | |
| 614 | + "session-not-found", | |
| 615 | + "The requested Hermes session is not active in this connection.", | |
| 616 | + recoverable=True, | |
| 617 | + related_request_id=str(message["requestId"]), | |
| 618 | + ) | |
| 619 | + ) | |
| 620 | + return | |
| 621 | + turn = self._active_turns.pop(thread_id, None) | |
| 622 | + if turn is not None: | |
| 623 | + await self.interrupt_session_activity(session_id, thread_id) | |
| 624 | + await self._send_frame( | |
| 625 | + frame( | |
| 626 | + "turn.aborted", | |
| 627 | + threadId=thread_id, | |
| 628 | + sessionId=session_id, | |
| 629 | + turnId=turn.turn_id, | |
| 630 | + reason="Hermes session stopped by T3 Code", | |
| 631 | + ) | |
| 632 | + ) | |
| 633 | + await self._send_frame( | |
| 634 | + frame( | |
| 635 | + "session.exited", | |
| 636 | + threadId=thread_id, | |
| 637 | + sessionId=session_id, | |
| 638 | + reason="Stopped by T3 Code", | |
| 639 | + recoverable=True, | |
| 640 | + ) | |
| 641 | + ) | |
| 642 | + # Deliberately retain the deterministic mapping and Hermes transcript. | |
| 643 | + # A later session.ensure resumes this same thread/session identity. | |
| 644 | + self._active_session_threads.discard(thread_id) | |
| 645 | + await self._send_status() | |
| 646 | + | |
| 647 | + async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: | |
| 648 | + visible = str(content or "").replace(" ▉", "").replace("▉", "") | |
| 649 | + if not turn.assistant_started: | |
| 650 | + turn.assistant_started = True | |
| 651 | + await self._send_frame( | |
| 652 | + frame( | |
| 653 | + "item.started", | |
| 654 | + threadId=turn.thread_id, | |
| 655 | + sessionId=turn.session_id, | |
| 656 | + turnId=turn.turn_id, | |
| 657 | + itemId=turn.message_id, | |
| 658 | + itemType="assistant_message", | |
| 659 | + status="inProgress", | |
| 660 | + title="Hermes response", | |
| 661 | + ) | |
| 662 | + ) | |
| 663 | + if visible.startswith(turn.visible_text): | |
| 664 | + delta = visible[len(turn.visible_text) :] | |
| 665 | + turn.visible_text = visible | |
| 666 | + if delta: | |
| 667 | + await self._send_frame( | |
| 668 | + frame( | |
| 669 | + "content.delta", | |
| 670 | + threadId=turn.thread_id, | |
| 671 | + sessionId=turn.session_id, | |
| 672 | + turnId=turn.turn_id, | |
| 673 | + itemId=turn.message_id, | |
| 674 | + streamKind="assistant_text", | |
| 675 | + delta=delta, | |
| 676 | + contentIndex=0, | |
| 677 | + ) | |
| 678 | + ) | |
| 679 | + elif visible != turn.visible_text: | |
| 680 | + # Hermes' public platform edit hook exposes cumulative rendered text, | |
| 681 | + # but the v1 T3 delta contract has no replacement operation. Preserve | |
| 682 | + # the valid prefix and report the rare rewrite as generic activity. | |
| 683 | + await self._emit_generic_activity( | |
| 684 | + turn, "Hermes revised already-streamed text; replacement is deferred." | |
| 685 | + ) | |
| 686 | + | |
| 687 | + async def _complete_turn(self, turn: _TurnState) -> None: | |
| 688 | + if self._active_turns.get(turn.thread_id) is not turn: | |
| 689 | + return | |
| 690 | + if turn.assistant_started: | |
| 691 | + await self._send_frame( | |
| 692 | + frame( | |
| 693 | + "item.completed", | |
| 694 | + threadId=turn.thread_id, | |
| 695 | + sessionId=turn.session_id, | |
| 696 | + turnId=turn.turn_id, | |
| 697 | + itemId=turn.message_id, | |
| 698 | + itemType="assistant_message", | |
| 699 | + status="completed", | |
| 700 | + title="Hermes response", | |
| 701 | + ) | |
| 702 | + ) | |
| 703 | + if turn.generic_activity_id is not None: | |
| 704 | + await self._send_frame( | |
| 705 | + frame( | |
| 706 | + "item.completed", | |
| 707 | + threadId=turn.thread_id, | |
| 708 | + sessionId=turn.session_id, | |
| 709 | + turnId=turn.turn_id, | |
| 710 | + itemId=turn.generic_activity_id, | |
| 711 | + itemType="unknown", | |
| 712 | + status="completed", | |
| 713 | + title="Hermes activity", | |
| 714 | + **( | |
| 715 | + {"detail": turn.generic_activity_detail} | |
| 716 | + if turn.generic_activity_detail | |
| 717 | + else {} | |
| 718 | + ), | |
| 719 | + ) | |
| 720 | + ) | |
| 721 | + await self._send_frame( | |
| 722 | + frame( | |
| 723 | + "turn.completed", | |
| 724 | + threadId=turn.thread_id, | |
| 725 | + sessionId=turn.session_id, | |
| 726 | + turnId=turn.turn_id, | |
| 727 | + state="completed", | |
| 728 | + stopReason=None, | |
| 729 | + ) | |
| 730 | + ) | |
| 731 | + self._active_turns.pop(turn.thread_id, None) | |
| 732 | + await self._send_status() | |
| 733 | + | |
| 734 | + async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: | |
| 735 | + if not detail: | |
| 736 | + return | |
| 737 | + normalized_detail = str(detail)[:2_000] | |
| 738 | + if turn.generic_activity_detail == normalized_detail: | |
| 739 | + return | |
| 740 | + turn.generic_activity_detail = normalized_detail | |
| 741 | + if turn.generic_activity_id is None: | |
| 742 | + turn.generic_activity_id = item_id() | |
| 743 | + event_type = "item.started" | |
| 744 | + else: | |
| 745 | + event_type = "item.updated" | |
| 746 | + await self._send_frame( | |
| 747 | + frame( | |
| 748 | + event_type, | |
| 749 | + threadId=turn.thread_id, | |
| 750 | + sessionId=turn.session_id, | |
| 751 | + turnId=turn.turn_id, | |
| 752 | + itemId=turn.generic_activity_id, | |
| 753 | + itemType="unknown", | |
| 754 | + status="inProgress", | |
| 755 | + title="Hermes activity", | |
| 756 | + detail=normalized_detail, | |
| 757 | + ) | |
| 758 | + ) | |
| 759 | + | |
| 760 | + def emit_tool_started( | |
| 761 | + self, | |
| 762 | + session_id: str, | |
| 763 | + tool_name: str, | |
| 764 | + args: dict[str, Any], | |
| 765 | + tool_call_id: str = "", | |
| 766 | + ) -> None: | |
| 767 | + thread_id = self._thread_by_session.get(str(session_id)) | |
| 768 | + turn = self._active_turns.get(thread_id or "") | |
| 769 | + if turn is None: | |
| 770 | + return | |
| 771 | + tool_item_id = item_id() | |
| 772 | + correlation_key = tool_call_id or tool_name | |
| 773 | + turn.tool_items[correlation_key] = tool_item_id | |
| 774 | + data = canonical_tool_data(tool_name, args) | |
| 775 | + payload: dict[str, Any] = { | |
| 776 | + "threadId": turn.thread_id, | |
| 777 | + "sessionId": turn.session_id, | |
| 778 | + "turnId": turn.turn_id, | |
| 779 | + "itemId": tool_item_id, | |
| 780 | + "itemType": canonical_tool_item_type(tool_name), | |
| 781 | + "status": "inProgress", | |
| 782 | + "title": tool_name, | |
| 783 | + } | |
| 784 | + if data is not None: | |
| 785 | + payload["data"] = data | |
| 786 | + self._schedule( | |
| 787 | + self._send_frame( | |
| 788 | + frame( | |
| 789 | + "item.started", | |
| 790 | + **payload, | |
| 791 | + ) | |
| 792 | + ) | |
| 793 | + ) | |
| 794 | + | |
| 795 | + def emit_tool_completed( | |
| 796 | + self, | |
| 797 | + session_id: str, | |
| 798 | + tool_name: str, | |
| 799 | + result: str, | |
| 800 | + duration_ms: int | None, | |
| 801 | + tool_call_id: str = "", | |
| 802 | + status: str = "", | |
| 803 | + ) -> None: | |
| 804 | + thread_id = self._thread_by_session.get(str(session_id)) | |
| 805 | + turn = self._active_turns.get(thread_id or "") | |
| 806 | + if turn is None: | |
| 807 | + return | |
| 808 | + correlation_key = tool_call_id or tool_name | |
| 809 | + tool_item_id = turn.tool_items.pop(correlation_key, None) or item_id() | |
| 810 | + del result | |
| 811 | + payload: dict[str, Any] = { | |
| 812 | + "threadId": turn.thread_id, | |
| 813 | + "sessionId": turn.session_id, | |
| 814 | + "turnId": turn.turn_id, | |
| 815 | + "itemId": tool_item_id, | |
| 816 | + "itemType": canonical_tool_item_type(tool_name), | |
| 817 | + "status": "failed" if status == "error" else "completed", | |
| 818 | + "title": tool_name, | |
| 819 | + } | |
| 820 | + if duration_ms is not None: | |
| 821 | + payload["detail"] = f"Completed in {duration_ms} ms" | |
| 822 | + payload["data"] = {"durationMs": duration_ms} | |
| 823 | + self._schedule( | |
| 824 | + self._send_frame( | |
| 825 | + frame( | |
| 826 | + "item.completed", | |
| 827 | + **payload, | |
| 828 | + ) | |
| 829 | + ) | |
| 830 | + ) | |
| 831 | + | |
| 832 | + @classmethod | |
| 833 | + def route_tool_started( | |
| 834 | + cls, | |
| 835 | + tool_name: str, | |
| 836 | + args: dict[str, Any], | |
| 837 | + session_id: str, | |
| 838 | + tool_call_id: str = "", | |
| 839 | + ) -> None: | |
| 840 | + for instance in list(cls._instances): | |
| 841 | + instance.emit_tool_started(session_id, tool_name, args, tool_call_id) | |
| 842 | + | |
| 843 | + @classmethod | |
| 844 | + def route_tool_completed( | |
| 845 | + cls, | |
| 846 | + tool_name: str, | |
| 847 | + result: str, | |
| 848 | + session_id: str, | |
| 849 | + duration_ms: int | None, | |
| 850 | + tool_call_id: str = "", | |
| 851 | + status: str = "", | |
| 852 | + ) -> None: | |
| 853 | + for instance in list(cls._instances): | |
| 854 | + instance.emit_tool_completed( | |
| 855 | + session_id, | |
| 856 | + tool_name, | |
| 857 | + result, | |
| 858 | + duration_ms, | |
| 859 | + tool_call_id, | |
| 860 | + status, | |
| 861 | + ) | |
| 862 | + | |
| 863 | + def _source(self, thread_id: str, message_id: str): | |
| 864 | + return self.build_source( | |
| 865 | + chat_id=thread_id, | |
| 866 | + chat_name=f"T3 thread {thread_id}", | |
| 867 | + chat_type="dm", | |
| 868 | + user_id="t3-code", | |
| 869 | + user_name="T3 Code", | |
| 870 | + message_id=message_id, | |
| 871 | + ) | |
| 872 | + | |
| 873 | + async def _send_frame(self, message: dict[str, Any]) -> None: | |
| 874 | + connection = self._connection | |
| 875 | + if connection is None: | |
| 876 | + raise ConnectionError("T3 Code gateway is offline") | |
| 877 | + await connection.send(message) | |
| 878 | + | |
| 879 | + async def _send_status(self) -> None: | |
| 880 | + if self._connection is None or not self._connection.connected: | |
| 881 | + return | |
| 882 | + await self._send_frame( | |
| 883 | + frame( | |
| 884 | + "connection.status", | |
| 885 | + activeSessionCount=len(self._active_session_threads), | |
| 886 | + ) | |
| 887 | + ) | |
| 888 | + | |
| 889 | + async def _handle_connection_state( | |
| 890 | + self, connected: bool, reason: str | None | |
| 891 | + ) -> None: | |
| 892 | + if connected: | |
| 893 | + self._mark_connected() | |
| 894 | + await self._send_status() | |
| 895 | + return | |
| 896 | + self._mark_disconnected() | |
| 897 | + if reason: | |
| 898 | + logger.warning("T3 gateway offline: %s", reason) | |
| 899 | + | |
| 900 | + def _schedule(self, coroutine) -> None: | |
| 901 | + loop = self._event_loop | |
| 902 | + if loop is None or loop.is_closed(): | |
| 903 | + coroutine.close() | |
| 904 | + return | |
| 905 | + if threading.current_thread() is threading.main_thread(): | |
| 906 | + try: | |
| 907 | + asyncio.get_running_loop() | |
| 908 | + except RuntimeError: | |
| 909 | + asyncio.run_coroutine_threadsafe(coroutine, loop) | |
| 910 | + else: | |
| 911 | + loop.create_task(coroutine) | |
| 912 | + else: | |
| 913 | + asyncio.run_coroutine_threadsafe(coroutine, loop) | |
| 914 | + | |
| 915 | + | |
| 916 | +def check_requirements() -> bool: | |
| 917 | + return dependency_available() | |
| 918 | + | |
| 919 | + | |
| 920 | +def validate_config(config: PlatformConfig) -> bool: | |
| 921 | + extra = getattr(config, "extra", {}) or {} | |
| 922 | + return ( | |
| 923 | + bool(extra.get("url") or os.environ.get(URL_ENV, "")) | |
| 924 | + and bool(extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "")) | |
| 925 | + and bool(extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "")) | |
| 926 | + ) | |
| 927 | + | |
| 928 | + | |
| 929 | +def env_enablement() -> dict[str, str] | None: | |
| 930 | + url = os.environ.get(URL_ENV, "").strip() | |
| 931 | + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() | |
| 932 | + credential = os.environ.get(CREDENTIAL_ENV, "").strip() | |
| 933 | + if not (url and instance_id and credential): | |
| 934 | + return None | |
| 935 | + return { | |
| 936 | + "url": url, | |
| 937 | + "instance_id": instance_id, | |
| 938 | + "credential": credential, | |
| 939 | + "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes", | |
| 940 | + } | |
| @@ -0,0 +1,125 @@ | ||
| 1 | +"""`hermes t3 connect` enrollment command.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import argparse | |
| 6 | +import asyncio | |
| 7 | +import os | |
| 8 | +import sys | |
| 9 | + | |
| 10 | +from .connection import ConnectionRejected, enroll_once, websocket_url | |
| 11 | + | |
| 12 | +URL_ENV = "HERMES_T3_GATEWAY_URL" | |
| 13 | +INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID" | |
| 14 | +CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL" | |
| 15 | +NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME" | |
| 16 | + | |
| 17 | + | |
| 18 | +def _hermes_version() -> str: | |
| 19 | + try: | |
| 20 | + from hermes_cli import __version__ | |
| 21 | + | |
| 22 | + return str(__version__) | |
| 23 | + except Exception: # noqa: BLE001 - version discovery must not block enrollment | |
| 24 | + return "unknown" | |
| 25 | + | |
| 26 | + | |
| 27 | +def register_cli(parser: argparse.ArgumentParser) -> None: | |
| 28 | + commands = parser.add_subparsers(dest="t3_command") | |
| 29 | + connect = commands.add_parser( | |
| 30 | + "connect", | |
| 31 | + help="Pair this Hermes process with a named T3 Code provider instance", | |
| 32 | + ) | |
| 33 | + connect.add_argument( | |
| 34 | + "--url", | |
| 35 | + required=True, | |
| 36 | + help="T3 browser origin or explicit ws(s) gateway URL", | |
| 37 | + ) | |
| 38 | + connect.add_argument( | |
| 39 | + "--token", | |
| 40 | + required=True, | |
| 41 | + help="Short-lived, one-time enrollment token generated by T3 Code", | |
| 42 | + ) | |
| 43 | + connect.set_defaults(func=t3_command) | |
| 44 | + | |
| 45 | + status = commands.add_parser("status", help="Show local T3 enrollment state") | |
| 46 | + status.set_defaults(func=t3_command) | |
| 47 | + | |
| 48 | + | |
| 49 | +def t3_command(args) -> None: | |
| 50 | + command = getattr(args, "t3_command", None) | |
| 51 | + if command == "status": | |
| 52 | + _print_status() | |
| 53 | + return | |
| 54 | + if command != "connect": | |
| 55 | + print("Usage: hermes t3 connect --url <t3-url> --token <one-time-token>") | |
| 56 | + return | |
| 57 | + | |
| 58 | + url = str(getattr(args, "url", "") or "").strip() | |
| 59 | + token = str(getattr(args, "token", "") or "").strip() | |
| 60 | + try: | |
| 61 | + normalized_url = websocket_url(url) | |
| 62 | + accepted = asyncio.run( | |
| 63 | + enroll_once( | |
| 64 | + url=normalized_url, | |
| 65 | + token=token, | |
| 66 | + hermes_version=_hermes_version(), | |
| 67 | + ) | |
| 68 | + ) | |
| 69 | + except ConnectionRejected as exc: | |
| 70 | + print(f"✗ T3 enrollment rejected ({exc.code}): {exc}") | |
| 71 | + raise SystemExit(1) from exc | |
| 72 | + except Exception as exc: | |
| 73 | + print(f"✗ Could not enroll with T3 Code: {exc}") | |
| 74 | + raise SystemExit(1) from exc | |
| 75 | + | |
| 76 | + values = { | |
| 77 | + URL_ENV: normalized_url, | |
| 78 | + INSTANCE_ID_ENV: str(accepted["instanceId"]), | |
| 79 | + CREDENTIAL_ENV: str(accepted["credential"]), | |
| 80 | + NICKNAME_ENV: str(accepted.get("nickname") or "Hermes"), | |
| 81 | + } | |
| 82 | + try: | |
| 83 | + from hermes_cli.config import get_env_path, save_env_value | |
| 84 | + | |
| 85 | + for key, value in values.items(): | |
| 86 | + save_env_value(key, value) | |
| 87 | + env_path = get_env_path() | |
| 88 | + except Exception as exc: | |
| 89 | + print(f"✗ Enrollment succeeded, but credentials could not be saved: {exc}") | |
| 90 | + print(" The credential was not printed. Revoke and re-enroll this instance.") | |
| 91 | + raise SystemExit(1) from exc | |
| 92 | + | |
| 93 | + # Mirror the newly written values into this process for status output and | |
| 94 | + # tests. A running gateway still needs a restart to construct the adapter. | |
| 95 | + os.environ.update(values) | |
| 96 | + print(f'✓ Connected Hermes to T3 Code as "{values[NICKNAME_ENV]}"') | |
| 97 | + print(f" Instance: {values[INSTANCE_ID_ENV]}") | |
| 98 | + print(f" Gateway: {normalized_url}") | |
| 99 | + print(f" Saved the credential securely in {env_path} (value hidden).") | |
| 100 | + print(" Restart `hermes gateway` to activate the connection.") | |
| 101 | + | |
| 102 | + | |
| 103 | +def _print_status() -> None: | |
| 104 | + url = os.environ.get(URL_ENV, "").strip() | |
| 105 | + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() | |
| 106 | + credential = os.environ.get(CREDENTIAL_ENV, "").strip() | |
| 107 | + nickname = os.environ.get(NICKNAME_ENV, "").strip() or "Hermes" | |
| 108 | + if not (url and instance_id and credential): | |
| 109 | + print("T3 Code: not enrolled") | |
| 110 | + print("Run: hermes t3 connect --url <t3-url> --token <one-time-token>") | |
| 111 | + return | |
| 112 | + print(f"T3 Code: enrolled as {nickname}") | |
| 113 | + print(f" Instance: {instance_id}") | |
| 114 | + print(f" Gateway: {url}") | |
| 115 | + print(" Credential: configured (hidden)") | |
| 116 | + | |
| 117 | + | |
| 118 | +if __name__ == "__main__": # pragma: no cover - executable fallback | |
| 119 | + parser = argparse.ArgumentParser(prog="python -m hermes_t3_gateway.cli") | |
| 120 | + register_cli(parser) | |
| 121 | + parsed = parser.parse_args() | |
| 122 | + if not hasattr(parsed, "func"): | |
| 123 | + parser.print_help() | |
| 124 | + sys.exit(2) | |
| 125 | + parsed.func(parsed) | |
| @@ -0,0 +1,247 @@ | ||
| 1 | +"""Outbound authenticated WebSocket connection to a T3 Code server.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import json | |
| 7 | +import logging | |
| 8 | +from collections.abc import Awaitable, Callable | |
| 9 | +from contextlib import suppress | |
| 10 | +from typing import Any | |
| 11 | +from urllib.parse import urlsplit, urlunsplit | |
| 12 | + | |
| 13 | +from .protocol import WEBSOCKET_PATH, connection_hello | |
| 14 | + | |
| 15 | +logger = logging.getLogger(__name__) | |
| 16 | + | |
| 17 | +try: | |
| 18 | + import websockets | |
| 19 | +except ImportError: # pragma: no cover - Hermes currently installs websockets | |
| 20 | + websockets = None | |
| 21 | + | |
| 22 | +MessageHandler = Callable[[dict[str, Any]], Awaitable[None]] | |
| 23 | +StateHandler = Callable[[bool, str | None], Awaitable[None] | None] | |
| 24 | + | |
| 25 | + | |
| 26 | +class ConnectionRejected(RuntimeError): | |
| 27 | + def __init__(self, code: str, message: str): | |
| 28 | + super().__init__(message) | |
| 29 | + self.code = code | |
| 30 | + | |
| 31 | + | |
| 32 | +def websocket_url(url: str) -> str: | |
| 33 | + """Normalize an HTTP(S) browser origin or WS(S) URL to the gateway route.""" | |
| 34 | + raw = (url or "").strip() | |
| 35 | + parsed = urlsplit(raw) | |
| 36 | + scheme = parsed.scheme.lower() | |
| 37 | + if scheme == "https": | |
| 38 | + scheme = "wss" | |
| 39 | + elif scheme == "http": | |
| 40 | + scheme = "ws" | |
| 41 | + if scheme not in {"ws", "wss"} or not parsed.netloc: | |
| 42 | + raise ValueError("URL must use http://, https://, ws://, or wss://") | |
| 43 | + path = parsed.path.rstrip("/") | |
| 44 | + if path != WEBSOCKET_PATH: | |
| 45 | + path = WEBSOCKET_PATH | |
| 46 | + return urlunsplit((scheme, parsed.netloc, path, "", "")) | |
| 47 | + | |
| 48 | + | |
| 49 | +def dependency_available() -> bool: | |
| 50 | + return websockets is not None | |
| 51 | + | |
| 52 | + | |
| 53 | +async def _open_socket(url: str): | |
| 54 | + if websockets is None: | |
| 55 | + raise RuntimeError( | |
| 56 | + "The `websockets` package is unavailable. Install the standard " | |
| 57 | + "Hermes Agent dependencies and retry." | |
| 58 | + ) | |
| 59 | + return await websockets.connect( # type: ignore[union-attr] | |
| 60 | + websocket_url(url), | |
| 61 | + open_timeout=20, | |
| 62 | + ping_interval=20, | |
| 63 | + ping_timeout=20, | |
| 64 | + close_timeout=5, | |
| 65 | + max_size=2 * 1024 * 1024, | |
| 66 | + ) | |
| 67 | + | |
| 68 | + | |
| 69 | +async def authenticate_socket( | |
| 70 | + socket: Any, | |
| 71 | + *, | |
| 72 | + authentication: dict[str, str], | |
| 73 | + hermes_version: str, | |
| 74 | + timeout: float = 20, | |
| 75 | +) -> dict[str, Any]: | |
| 76 | + hello = connection_hello( | |
| 77 | + hermes_version=hermes_version, | |
| 78 | + authentication=authentication, | |
| 79 | + ) | |
| 80 | + await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False)) | |
| 81 | + raw = await asyncio.wait_for(socket.recv(), timeout=timeout) | |
| 82 | + message = json.loads(raw) | |
| 83 | + if not isinstance(message, dict): | |
| 84 | + raise TypeError("T3 returned a non-object handshake frame") | |
| 85 | + if message.get("requestId") != hello["requestId"]: | |
| 86 | + raise RuntimeError("T3 returned a handshake with an unexpected requestId") | |
| 87 | + if message.get("type") == "connection.rejected": | |
| 88 | + raise ConnectionRejected( | |
| 89 | + str(message.get("code") or "internal-error"), | |
| 90 | + str(message.get("message") or "T3 rejected the gateway connection"), | |
| 91 | + ) | |
| 92 | + if message.get("type") != "connection.accepted": | |
| 93 | + raise RuntimeError( | |
| 94 | + f"expected connection.accepted, received {message.get('type')!r}" | |
| 95 | + ) | |
| 96 | + if message.get("protocolVersion") != 1: | |
| 97 | + raise RuntimeError("T3 accepted the connection with an incompatible version") | |
| 98 | + return message | |
| 99 | + | |
| 100 | + | |
| 101 | +async def enroll_once( | |
| 102 | + *, | |
| 103 | + url: str, | |
| 104 | + token: str, | |
| 105 | + hermes_version: str, | |
| 106 | +) -> dict[str, Any]: | |
| 107 | + socket = await _open_socket(url) | |
| 108 | + try: | |
| 109 | + accepted = await authenticate_socket( | |
| 110 | + socket, | |
| 111 | + authentication={"type": "enrollment-token", "token": token}, | |
| 112 | + hermes_version=hermes_version, | |
| 113 | + ) | |
| 114 | + if not accepted.get("instanceId") or not accepted.get("credential"): | |
| 115 | + raise RuntimeError( | |
| 116 | + "T3 accepted enrollment without returning an instance credential" | |
| 117 | + ) | |
| 118 | + return accepted | |
| 119 | + finally: | |
| 120 | + await socket.close() | |
| 121 | + | |
| 122 | + | |
| 123 | +class T3GatewayConnection: | |
| 124 | + """Reconnectable runtime connection authenticated by an instance credential.""" | |
| 125 | + | |
| 126 | + def __init__( | |
| 127 | + self, | |
| 128 | + *, | |
| 129 | + url: str, | |
| 130 | + instance_id: str, | |
| 131 | + credential: str, | |
| 132 | + hermes_version: str, | |
| 133 | + on_message: MessageHandler, | |
| 134 | + on_state: StateHandler | None = None, | |
| 135 | + ): | |
| 136 | + self.url = websocket_url(url) | |
| 137 | + self.instance_id = instance_id | |
| 138 | + self.credential = credential | |
| 139 | + self.hermes_version = hermes_version | |
| 140 | + self._on_message = on_message | |
| 141 | + self._on_state = on_state | |
| 142 | + self._socket: Any = None | |
| 143 | + self._supervisor: asyncio.Task[None] | None = None | |
| 144 | + self._send_lock = asyncio.Lock() | |
| 145 | + self._connected = asyncio.Event() | |
| 146 | + self._first_result: asyncio.Future[bool] | None = None | |
| 147 | + self._stopping = False | |
| 148 | + | |
| 149 | + @property | |
| 150 | + def connected(self) -> bool: | |
| 151 | + return self._connected.is_set() | |
| 152 | + | |
| 153 | + async def connect(self, timeout: float = 30) -> bool: | |
| 154 | + if self._supervisor is not None and not self._supervisor.done(): | |
| 155 | + return self.connected | |
| 156 | + self._stopping = False | |
| 157 | + self._first_result = asyncio.get_running_loop().create_future() | |
| 158 | + self._supervisor = asyncio.create_task( | |
| 159 | + self._supervise(), name="hermes-t3-gateway" | |
| 160 | + ) | |
| 161 | + try: | |
| 162 | + return await asyncio.wait_for(asyncio.shield(self._first_result), timeout) | |
| 163 | + except TimeoutError: | |
| 164 | + await self.disconnect() | |
| 165 | + return False | |
| 166 | + | |
| 167 | + async def disconnect(self) -> None: | |
| 168 | + self._stopping = True | |
| 169 | + self._connected.clear() | |
| 170 | + if self._socket is not None: | |
| 171 | + with suppress(Exception): | |
| 172 | + await self._socket.close() | |
| 173 | + self._socket = None | |
| 174 | + if self._supervisor is not None: | |
| 175 | + self._supervisor.cancel() | |
| 176 | + with suppress(asyncio.CancelledError): | |
| 177 | + await self._supervisor | |
| 178 | + self._supervisor = None | |
| 179 | + await self._notify_state(False, None) | |
| 180 | + | |
| 181 | + async def send(self, message: dict[str, Any]) -> None: | |
| 182 | + if not self.connected or self._socket is None: | |
| 183 | + raise ConnectionError("T3 Code gateway is offline") | |
| 184 | + encoded = json.dumps(message, separators=(",", ":"), ensure_ascii=False) | |
| 185 | + async with self._send_lock: | |
| 186 | + await self._socket.send(encoded) | |
| 187 | + | |
| 188 | + async def _supervise(self) -> None: | |
| 189 | + delay = 1.0 | |
| 190 | + while not self._stopping: | |
| 191 | + reason: str | None = None | |
| 192 | + try: | |
| 193 | + socket = await _open_socket(self.url) | |
| 194 | + self._socket = socket | |
| 195 | + await authenticate_socket( | |
| 196 | + socket, | |
| 197 | + authentication={ | |
| 198 | + "type": "instance-credential", | |
| 199 | + "instanceId": self.instance_id, | |
| 200 | + "credential": self.credential, | |
| 201 | + }, | |
| 202 | + hermes_version=self.hermes_version, | |
| 203 | + ) | |
| 204 | + self._connected.set() | |
| 205 | + if self._first_result is not None and not self._first_result.done(): | |
| 206 | + self._first_result.set_result(True) | |
| 207 | + await self._notify_state(True, None) | |
| 208 | + delay = 1.0 | |
| 209 | + async for raw in socket: | |
| 210 | + message = json.loads(raw) | |
| 211 | + if isinstance(message, dict): | |
| 212 | + await self._on_message(message) | |
| 213 | + except asyncio.CancelledError: | |
| 214 | + raise | |
| 215 | + except ConnectionRejected as exc: | |
| 216 | + reason = f"{exc.code}: {exc}" | |
| 217 | + if self._first_result is not None and not self._first_result.done(): | |
| 218 | + self._first_result.set_exception(exc) | |
| 219 | + # Revoked credentials and version mismatches need operator | |
| 220 | + # action; reconnecting the same secret can never recover. | |
| 221 | + if exc.code in { | |
| 222 | + "instance-revoked", | |
| 223 | + "invalid-authentication", | |
| 224 | + "version-incompatible", | |
| 225 | + }: | |
| 226 | + self._stopping = True | |
| 227 | + except Exception as exc: # noqa: BLE001 - reconnect every transient transport failure | |
| 228 | + reason = str(exc) | |
| 229 | + logger.warning("T3 gateway connection dropped: %s", exc) | |
| 230 | + finally: | |
| 231 | + self._connected.clear() | |
| 232 | + self._socket = None | |
| 233 | + await self._notify_state(False, reason) | |
| 234 | + if self._stopping: | |
| 235 | + break | |
| 236 | + await asyncio.sleep(delay) | |
| 237 | + delay = min(delay * 2, 30.0) | |
| 238 | + | |
| 239 | + async def _notify_state(self, connected: bool, reason: str | None) -> None: | |
| 240 | + if self._on_state is None: | |
| 241 | + return | |
| 242 | + try: | |
| 243 | + result = self._on_state(connected, reason) | |
| 244 | + if asyncio.iscoroutine(result): | |
| 245 | + await result | |
| 246 | + except Exception: | |
| 247 | + logger.debug("T3 connection state callback failed", exc_info=True) | |
| @@ -0,0 +1,24 @@ | ||
| 1 | +name: hermes-t3-gateway | |
| 2 | +label: T3 Code | |
| 3 | +kind: platform | |
| 4 | +version: 0.1.0 | |
| 5 | +description: > | |
| 6 | + Experimental outbound gateway that exposes one Hermes Agent process as a | |
| 7 | + named T3 Code provider instance over an authenticated WebSocket. | |
| 8 | +author: T3 Tools | |
| 9 | +provides_hooks: | |
| 10 | + - pre_tool_call | |
| 11 | + - post_tool_call | |
| 12 | +optional_env: | |
| 13 | + - name: HERMES_T3_GATEWAY_URL | |
| 14 | + description: "T3 Code server URL used by the outbound gateway connection" | |
| 15 | + prompt: "T3 Code URL" | |
| 16 | + password: false | |
| 17 | + - name: HERMES_T3_GATEWAY_INSTANCE_ID | |
| 18 | + description: "Opaque T3 Code Hermes instance identifier issued at enrollment" | |
| 19 | + prompt: "T3 Code Hermes instance ID" | |
| 20 | + password: false | |
| 21 | + - name: HERMES_T3_GATEWAY_CREDENTIAL | |
| 22 | + description: "Long-lived per-instance credential issued at enrollment" | |
| 23 | + prompt: "T3 Code Hermes credential" | |
| 24 | + password: true | |
| @@ -0,0 +1,168 @@ | ||
| 1 | +"""Pure-Python helpers for the T3 Code ↔ Hermes gateway wire contract.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import uuid | |
| 6 | +from datetime import datetime, timezone | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +PROTOCOL_VERSION = 1 | |
| 10 | +PLUGIN_VERSION = "0.1.0" | |
| 11 | +WEBSOCKET_PATH = "/api/hermes-gateway/ws" | |
| 12 | + | |
| 13 | +CAPABILITIES = { | |
| 14 | + "protocolVersion": PROTOCOL_VERSION, | |
| 15 | + "streaming": True, | |
| 16 | + "activity": True, | |
| 17 | + "approvals": True, | |
| 18 | + "userInput": True, | |
| 19 | + # First post-stability feature: advertise false until binary/media framing | |
| 20 | + # and bounded attachment transfer are implemented end to end. | |
| 21 | + "attachments": False, | |
| 22 | +} | |
| 23 | + | |
| 24 | +SERVER_COMMANDS = frozenset( | |
| 25 | + { | |
| 26 | + "session.ensure", | |
| 27 | + "turn.start", | |
| 28 | + "turn.steer", | |
| 29 | + "turn.interrupt", | |
| 30 | + "approval.respond", | |
| 31 | + "user-input.respond", | |
| 32 | + "session.stop", | |
| 33 | + "ping", | |
| 34 | + } | |
| 35 | +) | |
| 36 | + | |
| 37 | + | |
| 38 | +def request_id() -> str: | |
| 39 | + return str(uuid.uuid4()) | |
| 40 | + | |
| 41 | + | |
| 42 | +def item_id() -> str: | |
| 43 | + return str(uuid.uuid4()) | |
| 44 | + | |
| 45 | + | |
| 46 | +def iso_now() -> str: | |
| 47 | + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") | |
| 48 | + | |
| 49 | + | |
| 50 | +def frame(frame_type: str, **payload: Any) -> dict[str, Any]: | |
| 51 | + return { | |
| 52 | + "type": frame_type, | |
| 53 | + "protocolVersion": PROTOCOL_VERSION, | |
| 54 | + **payload, | |
| 55 | + } | |
| 56 | + | |
| 57 | + | |
| 58 | +def connection_hello( | |
| 59 | + *, | |
| 60 | + hermes_version: str, | |
| 61 | + authentication: dict[str, str], | |
| 62 | + hello_request_id: str | None = None, | |
| 63 | +) -> dict[str, Any]: | |
| 64 | + return { | |
| 65 | + "type": "connection.hello", | |
| 66 | + "requestId": hello_request_id or request_id(), | |
| 67 | + "protocolVersion": PROTOCOL_VERSION, | |
| 68 | + "pluginVersion": PLUGIN_VERSION, | |
| 69 | + "hermesVersion": hermes_version, | |
| 70 | + "capabilities": dict(CAPABILITIES), | |
| 71 | + "authentication": authentication, | |
| 72 | + } | |
| 73 | + | |
| 74 | + | |
| 75 | +def protocol_error( | |
| 76 | + code: str, | |
| 77 | + message: str, | |
| 78 | + *, | |
| 79 | + recoverable: bool, | |
| 80 | + related_request_id: str | None = None, | |
| 81 | +) -> dict[str, Any]: | |
| 82 | + payload: dict[str, Any] = { | |
| 83 | + "code": code, | |
| 84 | + "message": message, | |
| 85 | + "recoverable": recoverable, | |
| 86 | + } | |
| 87 | + if related_request_id: | |
| 88 | + payload["requestId"] = related_request_id | |
| 89 | + return frame("protocol.error", **payload) | |
| 90 | + | |
| 91 | + | |
| 92 | +def validate_server_frame(message: Any) -> dict[str, Any]: | |
| 93 | + if not isinstance(message, dict): | |
| 94 | + raise TypeError("gateway frame must be a JSON object") | |
| 95 | + frame_type = message.get("type") | |
| 96 | + if frame_type not in SERVER_COMMANDS: | |
| 97 | + raise ValueError(f"unsupported T3 gateway frame: {frame_type!r}") | |
| 98 | + if message.get("protocolVersion") != PROTOCOL_VERSION: | |
| 99 | + raise ValueError( | |
| 100 | + f"unsupported protocol version: {message.get('protocolVersion')!r}" | |
| 101 | + ) | |
| 102 | + return message | |
| 103 | + | |
| 104 | + | |
| 105 | +def canonical_tool_item_type(tool_name: str) -> str: | |
| 106 | + normalized = (tool_name or "").strip().lower() | |
| 107 | + if normalized in {"terminal", "execute_code", "shell", "bash"}: | |
| 108 | + return "command_execution" | |
| 109 | + if normalized in { | |
| 110 | + "apply_patch", | |
| 111 | + "write_file", | |
| 112 | + "edit_file", | |
| 113 | + "delete_file", | |
| 114 | + "move_file", | |
| 115 | + }: | |
| 116 | + return "file_change" | |
| 117 | + if normalized.startswith(("mcp", "mcp__")): | |
| 118 | + return "mcp_tool_call" | |
| 119 | + if normalized in {"delegate_task", "spawn_agent", "send_message"}: | |
| 120 | + return "collab_agent_tool_call" | |
| 121 | + if "search" in normalized or normalized in {"web_fetch", "fetch_url"}: | |
| 122 | + return "web_search" | |
| 123 | + if normalized in {"view_image", "open_image"}: | |
| 124 | + return "image_view" | |
| 125 | + return "dynamic_tool_call" | |
| 126 | + | |
| 127 | + | |
| 128 | +def canonical_tool_data(tool_name: str, args: Any) -> dict[str, Any] | None: | |
| 129 | + """Project known-safe, canonical fields; never forward arbitrary tool args.""" | |
| 130 | + if not isinstance(args, dict): | |
| 131 | + return None | |
| 132 | + item_type = canonical_tool_item_type(tool_name) | |
| 133 | + if item_type == "command_execution": | |
| 134 | + command = args.get("command") | |
| 135 | + cwd = args.get("cwd") or args.get("workdir") | |
| 136 | + projected = {} | |
| 137 | + if isinstance(command, str) and command.strip(): | |
| 138 | + projected["command"] = command[:4_000] | |
| 139 | + if isinstance(cwd, str) and cwd.strip(): | |
| 140 | + projected["cwd"] = cwd[:1_000] | |
| 141 | + return projected or None | |
| 142 | + if item_type == "file_change": | |
| 143 | + path = args.get("path") or args.get("file_path") or args.get("filename") | |
| 144 | + return ( | |
| 145 | + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None | |
| 146 | + ) | |
| 147 | + if item_type == "web_search": | |
| 148 | + query = args.get("query") or args.get("q") or args.get("url") | |
| 149 | + return ( | |
| 150 | + {"query": query[:2_000]} | |
| 151 | + if isinstance(query, str) and query.strip() | |
| 152 | + else None | |
| 153 | + ) | |
| 154 | + if item_type == "image_view": | |
| 155 | + path = args.get("path") or args.get("image_path") | |
| 156 | + return ( | |
| 157 | + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None | |
| 158 | + ) | |
| 159 | + if item_type == "mcp_tool_call": | |
| 160 | + server = args.get("server") | |
| 161 | + operation = args.get("tool") or args.get("operation") | |
| 162 | + projected = {} | |
| 163 | + if isinstance(server, str) and server.strip(): | |
| 164 | + projected["server"] = server[:200] | |
| 165 | + if isinstance(operation, str) and operation.strip(): | |
| 166 | + projected["operation"] = operation[:200] | |
| 167 | + return projected or None | |
| 168 | + return None | |
| @@ -0,0 +1,411 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import dataclasses | |
| 4 | +import enum | |
| 5 | +import importlib.util | |
| 6 | +import pathlib | |
| 7 | +import sys | |
| 8 | +import types | |
| 9 | +import unittest | |
| 10 | + | |
| 11 | +ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 12 | +PACKAGE = "hermes_t3_gateway_adapter_test" | |
| 13 | + | |
| 14 | + | |
| 15 | +class Platform(str, enum.Enum): | |
| 16 | + T3 = "t3" | |
| 17 | + | |
| 18 | + @classmethod | |
| 19 | + def _missing_(cls, value): | |
| 20 | + if value == "t3": | |
| 21 | + return cls.T3 | |
| 22 | + return None | |
| 23 | + | |
| 24 | + | |
| 25 | +@dataclasses.dataclass | |
| 26 | +class PlatformConfig: | |
| 27 | + enabled: bool = True | |
| 28 | + extra: dict = dataclasses.field(default_factory=dict) | |
| 29 | + | |
| 30 | + | |
| 31 | +class MessageType(enum.Enum): | |
| 32 | + TEXT = "text" | |
| 33 | + COMMAND = "command" | |
| 34 | + | |
| 35 | + | |
| 36 | +@dataclasses.dataclass | |
| 37 | +class MessageEvent: | |
| 38 | + text: str | |
| 39 | + message_type: MessageType | |
| 40 | + source: object | |
| 41 | + message_id: str | |
| 42 | + metadata: dict | |
| 43 | + | |
| 44 | + | |
| 45 | +@dataclasses.dataclass | |
| 46 | +class SendResult: | |
| 47 | + success: bool | |
| 48 | + message_id: str | None = None | |
| 49 | + error: str | None = None | |
| 50 | + | |
| 51 | + | |
| 52 | +@dataclasses.dataclass | |
| 53 | +class Source: | |
| 54 | + platform: Platform | |
| 55 | + chat_id: str | |
| 56 | + message_id: str | |
| 57 | + | |
| 58 | + | |
| 59 | +class BasePlatformAdapter: | |
| 60 | + def __init__(self, config, platform): | |
| 61 | + self.config = config | |
| 62 | + self.platform = platform | |
| 63 | + self._status_text = {} | |
| 64 | + self.messages = [] | |
| 65 | + self._running = False | |
| 66 | + self._message_handler = None | |
| 67 | + | |
| 68 | + def build_source(self, *, chat_id, message_id, **kwargs): | |
| 69 | + return Source(self.platform, str(chat_id), str(message_id)) | |
| 70 | + | |
| 71 | + async def handle_message(self, event): | |
| 72 | + self.messages.append(event) | |
| 73 | + if ( | |
| 74 | + self._message_handler is not None | |
| 75 | + and event.message_type == MessageType.COMMAND | |
| 76 | + and event.text.startswith("/steer ") | |
| 77 | + ): | |
| 78 | + # Faithful model of Hermes BasePlatformAdapter's active-command | |
| 79 | + # path: the gateway handler returns a control acknowledgement, | |
| 80 | + # then BasePlatformAdapter sends it through the platform adapter | |
| 81 | + # with notify=True. | |
| 82 | + response = await self._message_handler(event) | |
| 83 | + if response: | |
| 84 | + await self.send( | |
| 85 | + event.source.chat_id, | |
| 86 | + response, | |
| 87 | + metadata={"notify": True}, | |
| 88 | + ) | |
| 89 | + | |
| 90 | + async def interrupt_session_activity(self, session_key, chat_id): | |
| 91 | + self.interrupted = (session_key, chat_id) | |
| 92 | + | |
| 93 | + def set_status_text(self, chat_id, text): | |
| 94 | + if text: | |
| 95 | + self._status_text[str(chat_id)] = text | |
| 96 | + else: | |
| 97 | + self._status_text.pop(str(chat_id), None) | |
| 98 | + | |
| 99 | + def _mark_connected(self): | |
| 100 | + self._running = True | |
| 101 | + | |
| 102 | + def _mark_disconnected(self): | |
| 103 | + self._running = False | |
| 104 | + | |
| 105 | + def _set_fatal_error(self, *args, **kwargs): | |
| 106 | + self.fatal_error = (args, kwargs) | |
| 107 | + | |
| 108 | + | |
| 109 | +def build_session_key(source): | |
| 110 | + return f"agent:main:t3:dm:{source.chat_id}" | |
| 111 | + | |
| 112 | + | |
| 113 | +def install_fake_hermes_modules(): | |
| 114 | + gateway = types.ModuleType("gateway") | |
| 115 | + config = types.ModuleType("gateway.config") | |
| 116 | + config.Platform = Platform | |
| 117 | + config.PlatformConfig = PlatformConfig | |
| 118 | + platforms = types.ModuleType("gateway.platforms") | |
| 119 | + base = types.ModuleType("gateway.platforms.base") | |
| 120 | + base.BasePlatformAdapter = BasePlatformAdapter | |
| 121 | + base.MessageEvent = MessageEvent | |
| 122 | + base.MessageType = MessageType | |
| 123 | + base.SendResult = SendResult | |
| 124 | + session = types.ModuleType("gateway.session") | |
| 125 | + session.build_session_key = build_session_key | |
| 126 | + sys.modules.update( | |
| 127 | + { | |
| 128 | + "gateway": gateway, | |
| 129 | + "gateway.config": config, | |
| 130 | + "gateway.platforms": platforms, | |
| 131 | + "gateway.platforms.base": base, | |
| 132 | + "gateway.session": session, | |
| 133 | + } | |
| 134 | + ) | |
| 135 | + | |
| 136 | + | |
| 137 | +def load_plugin_modules(): | |
| 138 | + install_fake_hermes_modules() | |
| 139 | + package = types.ModuleType(PACKAGE) | |
| 140 | + package.__path__ = [str(ROOT)] | |
| 141 | + sys.modules[PACKAGE] = package | |
| 142 | + for name in ("protocol", "connection", "cli", "adapter"): | |
| 143 | + spec = importlib.util.spec_from_file_location( | |
| 144 | + f"{PACKAGE}.{name}", ROOT / f"{name}.py" | |
| 145 | + ) | |
| 146 | + assert spec and spec.loader | |
| 147 | + module = importlib.util.module_from_spec(spec) | |
| 148 | + sys.modules[f"{PACKAGE}.{name}"] = module | |
| 149 | + spec.loader.exec_module(module) | |
| 150 | + return sys.modules[f"{PACKAGE}.adapter"] | |
| 151 | + | |
| 152 | + | |
| 153 | +adapter_module = load_plugin_modules() | |
| 154 | + | |
| 155 | + | |
| 156 | +class FakeConnection: | |
| 157 | + def __init__(self): | |
| 158 | + self.connected = True | |
| 159 | + self.messages = [] | |
| 160 | + | |
| 161 | + async def send(self, message): | |
| 162 | + self.messages.append(message) | |
| 163 | + | |
| 164 | + | |
| 165 | +class AdapterTests(unittest.IsolatedAsyncioTestCase): | |
| 166 | + async def asyncSetUp(self): | |
| 167 | + self.adapter = adapter_module.T3PlatformAdapter( | |
| 168 | + PlatformConfig( | |
| 169 | + extra={ | |
| 170 | + "url": "wss://t3.example/api/hermes-gateway/ws", | |
| 171 | + "instance_id": "instance", | |
| 172 | + "credential": "credential", | |
| 173 | + } | |
| 174 | + ) | |
| 175 | + ) | |
| 176 | + self.connection = FakeConnection() | |
| 177 | + self.adapter._connection = self.connection | |
| 178 | + | |
| 179 | + async def test_thread_ensure_start_stream_and_complete(self): | |
| 180 | + await self.adapter._handle_server_frame( | |
| 181 | + { | |
| 182 | + "type": "session.ensure", | |
| 183 | + "protocolVersion": 1, | |
| 184 | + "requestId": "ensure-1", | |
| 185 | + "threadId": "thread-1", | |
| 186 | + } | |
| 187 | + ) | |
| 188 | + ready = self.connection.messages[-2] | |
| 189 | + self.assertEqual(ready["type"], "session.ready") | |
| 190 | + self.assertEqual(ready["sessionId"], "agent:main:t3:dm:thread-1") | |
| 191 | + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) | |
| 192 | + | |
| 193 | + await self.adapter._handle_server_frame( | |
| 194 | + { | |
| 195 | + "type": "turn.start", | |
| 196 | + "protocolVersion": 1, | |
| 197 | + "requestId": "start-1", | |
| 198 | + "threadId": "thread-1", | |
| 199 | + "sessionId": ready["sessionId"], | |
| 200 | + "turnId": "turn-1", | |
| 201 | + "text": "Hello Hermes", | |
| 202 | + } | |
| 203 | + ) | |
| 204 | + self.assertEqual(self.adapter.messages[-1].text, "Hello Hermes") | |
| 205 | + await self.adapter.send("thread-1", "Hello", metadata={"expect_edits": True}) | |
| 206 | + await self.adapter.edit_message( | |
| 207 | + "thread-1", "message", "Hello world", finalize=True | |
| 208 | + ) | |
| 209 | + types_seen = [message["type"] for message in self.connection.messages] | |
| 210 | + self.assertIn("content.delta", types_seen) | |
| 211 | + self.assertIn("turn.completed", types_seen) | |
| 212 | + deltas = [ | |
| 213 | + message["delta"] | |
| 214 | + for message in self.connection.messages | |
| 215 | + if message["type"] == "content.delta" | |
| 216 | + ] | |
| 217 | + self.assertEqual(deltas, ["Hello", " world"]) | |
| 218 | + | |
| 219 | + async def test_steer_uses_official_hermes_command(self): | |
| 220 | + await self.adapter._handle_server_frame( | |
| 221 | + { | |
| 222 | + "type": "session.ensure", | |
| 223 | + "protocolVersion": 1, | |
| 224 | + "requestId": "ensure-2", | |
| 225 | + "threadId": "thread-2", | |
| 226 | + } | |
| 227 | + ) | |
| 228 | + session_id = self.adapter._sessions["thread-2"] | |
| 229 | + await self.adapter._handle_server_frame( | |
| 230 | + { | |
| 231 | + "type": "turn.start", | |
| 232 | + "protocolVersion": 1, | |
| 233 | + "requestId": "start-2", | |
| 234 | + "threadId": "thread-2", | |
| 235 | + "sessionId": session_id, | |
| 236 | + "turnId": "turn-2", | |
| 237 | + "text": "Start", | |
| 238 | + } | |
| 239 | + ) | |
| 240 | + messages_before_steer = len(self.connection.messages) | |
| 241 | + | |
| 242 | + async def accept_steer(_event): | |
| 243 | + return ( | |
| 244 | + "⏩ Steer queued — arrives after the next tool call: 'Focus on tests'" | |
| 245 | + ) | |
| 246 | + | |
| 247 | + self.adapter._message_handler = accept_steer | |
| 248 | + await self.adapter._handle_server_frame( | |
| 249 | + { | |
| 250 | + "type": "turn.steer", | |
| 251 | + "protocolVersion": 1, | |
| 252 | + "requestId": "steer-2", | |
| 253 | + "threadId": "thread-2", | |
| 254 | + "sessionId": session_id, | |
| 255 | + "turnId": "turn-2", | |
| 256 | + "text": "Focus on tests", | |
| 257 | + } | |
| 258 | + ) | |
| 259 | + self.assertEqual(self.adapter.messages[-1].text, "/steer Focus on tests") | |
| 260 | + self.assertEqual(self.adapter.messages[-1].message_type, MessageType.COMMAND) | |
| 261 | + steer_messages = self.connection.messages[messages_before_steer:] | |
| 262 | + self.assertEqual( | |
| 263 | + [message["type"] for message in steer_messages], ["turn.started"] | |
| 264 | + ) | |
| 265 | + self.assertEqual(steer_messages[0]["requestId"], "steer-2") | |
| 266 | + self.assertIn("thread-2", self.adapter._active_turns) | |
| 267 | + | |
| 268 | + await self.adapter.edit_message( | |
| 269 | + "thread-2", | |
| 270 | + "message", | |
| 271 | + "Actual response after steering", | |
| 272 | + finalize=True, | |
| 273 | + ) | |
| 274 | + deltas = [ | |
| 275 | + message["delta"] | |
| 276 | + for message in self.connection.messages | |
| 277 | + if message["type"] == "content.delta" | |
| 278 | + ] | |
| 279 | + self.assertEqual(deltas, ["Actual response after steering"]) | |
| 280 | + self.assertNotIn("thread-2", self.adapter._active_turns) | |
| 281 | + | |
| 282 | + async def test_rejected_steer_emits_error_without_completing_active_turn(self): | |
| 283 | + await self.adapter._handle_server_frame( | |
| 284 | + { | |
| 285 | + "type": "session.ensure", | |
| 286 | + "protocolVersion": 1, | |
| 287 | + "requestId": "ensure-rejected-steer", | |
| 288 | + "threadId": "thread-rejected-steer", | |
| 289 | + } | |
| 290 | + ) | |
| 291 | + session_id = self.adapter._sessions["thread-rejected-steer"] | |
| 292 | + await self.adapter._handle_server_frame( | |
| 293 | + { | |
| 294 | + "type": "turn.start", | |
| 295 | + "protocolVersion": 1, | |
| 296 | + "requestId": "start-rejected-steer", | |
| 297 | + "threadId": "thread-rejected-steer", | |
| 298 | + "sessionId": session_id, | |
| 299 | + "turnId": "turn-rejected-steer", | |
| 300 | + "text": "Start", | |
| 301 | + } | |
| 302 | + ) | |
| 303 | + messages_before_steer = len(self.connection.messages) | |
| 304 | + | |
| 305 | + async def reject_steer(_event): | |
| 306 | + return "Steer rejected (empty payload)." | |
| 307 | + | |
| 308 | + self.adapter._message_handler = reject_steer | |
| 309 | + await self.adapter._handle_server_frame( | |
| 310 | + { | |
| 311 | + "type": "turn.steer", | |
| 312 | + "protocolVersion": 1, | |
| 313 | + "requestId": "steer-rejected", | |
| 314 | + "threadId": "thread-rejected-steer", | |
| 315 | + "sessionId": session_id, | |
| 316 | + "turnId": "turn-rejected-steer", | |
| 317 | + "text": "Focus on tests", | |
| 318 | + } | |
| 319 | + ) | |
| 320 | + | |
| 321 | + steer_messages = self.connection.messages[messages_before_steer:] | |
| 322 | + self.assertEqual( | |
| 323 | + [message["type"] for message in steer_messages], ["protocol.error"] | |
| 324 | + ) | |
| 325 | + self.assertEqual(steer_messages[0]["requestId"], "steer-rejected") | |
| 326 | + self.assertEqual(steer_messages[0]["code"], "invalid-message") | |
| 327 | + self.assertIn("thread-rejected-steer", self.adapter._active_turns) | |
| 328 | + | |
| 329 | + await self.adapter.edit_message( | |
| 330 | + "thread-rejected-steer", | |
| 331 | + "message", | |
| 332 | + "Actual response after rejected steering", | |
| 333 | + finalize=True, | |
| 334 | + ) | |
| 335 | + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") | |
| 336 | + self.assertNotIn("thread-rejected-steer", self.adapter._active_turns) | |
| 337 | + | |
| 338 | + async def test_failed_steer_emits_correlated_internal_error(self): | |
| 339 | + await self.adapter._handle_server_frame( | |
| 340 | + { | |
| 341 | + "type": "session.ensure", | |
| 342 | + "protocolVersion": 1, | |
| 343 | + "requestId": "ensure-failed-steer", | |
| 344 | + "threadId": "thread-failed-steer", | |
| 345 | + } | |
| 346 | + ) | |
| 347 | + session_id = self.adapter._sessions["thread-failed-steer"] | |
| 348 | + await self.adapter._handle_server_frame( | |
| 349 | + { | |
| 350 | + "type": "turn.start", | |
| 351 | + "protocolVersion": 1, | |
| 352 | + "requestId": "start-failed-steer", | |
| 353 | + "threadId": "thread-failed-steer", | |
| 354 | + "sessionId": session_id, | |
| 355 | + "turnId": "turn-failed-steer", | |
| 356 | + "text": "Start", | |
| 357 | + } | |
| 358 | + ) | |
| 359 | + messages_before_steer = len(self.connection.messages) | |
| 360 | + | |
| 361 | + async def fail_steer(_event): | |
| 362 | + raise RuntimeError("running agent rejected steering") | |
| 363 | + | |
| 364 | + self.adapter._message_handler = fail_steer | |
| 365 | + await self.adapter._handle_server_frame( | |
| 366 | + { | |
| 367 | + "type": "turn.steer", | |
| 368 | + "protocolVersion": 1, | |
| 369 | + "requestId": "steer-failed", | |
| 370 | + "threadId": "thread-failed-steer", | |
| 371 | + "sessionId": session_id, | |
| 372 | + "turnId": "turn-failed-steer", | |
| 373 | + "text": "Focus on tests", | |
| 374 | + } | |
| 375 | + ) | |
| 376 | + | |
| 377 | + steer_messages = self.connection.messages[messages_before_steer:] | |
| 378 | + self.assertEqual( | |
| 379 | + [message["type"] for message in steer_messages], ["protocol.error"] | |
| 380 | + ) | |
| 381 | + self.assertEqual(steer_messages[0]["requestId"], "steer-failed") | |
| 382 | + self.assertEqual(steer_messages[0]["code"], "internal-error") | |
| 383 | + self.assertIn("thread-failed-steer", self.adapter._active_turns) | |
| 384 | + | |
| 385 | + async def test_session_status_counts_ready_sessions_and_stop_decrements(self): | |
| 386 | + await self.adapter._handle_server_frame( | |
| 387 | + { | |
| 388 | + "type": "session.ensure", | |
| 389 | + "protocolVersion": 1, | |
| 390 | + "requestId": "ensure-3", | |
| 391 | + "threadId": "thread-3", | |
| 392 | + } | |
| 393 | + ) | |
| 394 | + session_id = self.adapter._sessions["thread-3"] | |
| 395 | + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) | |
| 396 | + await self.adapter._handle_server_frame( | |
| 397 | + { | |
| 398 | + "type": "session.stop", | |
| 399 | + "protocolVersion": 1, | |
| 400 | + "requestId": "stop-3", | |
| 401 | + "threadId": "thread-3", | |
| 402 | + "sessionId": session_id, | |
| 403 | + } | |
| 404 | + ) | |
| 405 | + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") | |
| 406 | + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 0) | |
| 407 | + self.assertEqual(self.adapter._sessions["thread-3"], session_id) | |
| 408 | + | |
| 409 | + | |
| 410 | +if __name__ == "__main__": | |
| 411 | + unittest.main() | |
| @@ -0,0 +1,103 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import importlib.util | |
| 4 | +import json | |
| 5 | +import pathlib | |
| 6 | +import sys | |
| 7 | +import types | |
| 8 | +import unittest | |
| 9 | + | |
| 10 | +ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 11 | +PACKAGE = "hermes_t3_gateway_test" | |
| 12 | + | |
| 13 | +package = types.ModuleType(PACKAGE) | |
| 14 | +package.__path__ = [str(ROOT)] | |
| 15 | +sys.modules.setdefault(PACKAGE, package) | |
| 16 | + | |
| 17 | +for name in ("protocol", "connection"): | |
| 18 | + spec = importlib.util.spec_from_file_location( | |
| 19 | + f"{PACKAGE}.{name}", ROOT / f"{name}.py" | |
| 20 | + ) | |
| 21 | + assert spec and spec.loader | |
| 22 | + module = importlib.util.module_from_spec(spec) | |
| 23 | + sys.modules[f"{PACKAGE}.{name}"] = module | |
| 24 | + spec.loader.exec_module(module) | |
| 25 | + | |
| 26 | +connection = sys.modules[f"{PACKAGE}.connection"] | |
| 27 | + | |
| 28 | + | |
| 29 | +class FakeSocket: | |
| 30 | + def __init__(self, response): | |
| 31 | + self.response = response | |
| 32 | + self.sent = [] | |
| 33 | + self.closed = False | |
| 34 | + | |
| 35 | + async def send(self, value): | |
| 36 | + self.sent.append(json.loads(value)) | |
| 37 | + | |
| 38 | + async def recv(self): | |
| 39 | + request_id = self.sent[0]["requestId"] | |
| 40 | + return json.dumps({**self.response, "requestId": request_id}) | |
| 41 | + | |
| 42 | + async def close(self): | |
| 43 | + self.closed = True | |
| 44 | + | |
| 45 | + | |
| 46 | +class ConnectionTests(unittest.IsolatedAsyncioTestCase): | |
| 47 | + def test_url_normalization(self): | |
| 48 | + self.assertEqual( | |
| 49 | + connection.websocket_url("https://t3.example"), | |
| 50 | + "wss://t3.example/api/hermes-gateway/ws", | |
| 51 | + ) | |
| 52 | + self.assertEqual( | |
| 53 | + connection.websocket_url("http://siva.davis7.space:8484/"), | |
| 54 | + "ws://siva.davis7.space:8484/api/hermes-gateway/ws", | |
| 55 | + ) | |
| 56 | + with self.assertRaises(ValueError): | |
| 57 | + connection.websocket_url("ftp://invalid.example") | |
| 58 | + | |
| 59 | + async def test_enrollment_handshake_returns_credential(self): | |
| 60 | + socket = FakeSocket( | |
| 61 | + { | |
| 62 | + "type": "connection.accepted", | |
| 63 | + "protocolVersion": 1, | |
| 64 | + "instanceId": "provider-instance", | |
| 65 | + "nickname": "Research", | |
| 66 | + "credential": "persistent-secret", | |
| 67 | + } | |
| 68 | + ) | |
| 69 | + accepted = await connection.authenticate_socket( | |
| 70 | + socket, | |
| 71 | + authentication={"type": "enrollment-token", "token": "once"}, | |
| 72 | + hermes_version="0.19.0", | |
| 73 | + ) | |
| 74 | + self.assertEqual(accepted["credential"], "persistent-secret") | |
| 75 | + self.assertEqual( | |
| 76 | + socket.sent[0]["authentication"], | |
| 77 | + {"type": "enrollment-token", "token": "once"}, | |
| 78 | + ) | |
| 79 | + | |
| 80 | + async def test_rejected_handshake_fails_closed(self): | |
| 81 | + socket = FakeSocket( | |
| 82 | + { | |
| 83 | + "type": "connection.rejected", | |
| 84 | + "code": "version-incompatible", | |
| 85 | + "message": "upgrade required", | |
| 86 | + "expectedProtocolVersion": 1, | |
| 87 | + } | |
| 88 | + ) | |
| 89 | + with self.assertRaises(connection.ConnectionRejected) as raised: | |
| 90 | + await connection.authenticate_socket( | |
| 91 | + socket, | |
| 92 | + authentication={ | |
| 93 | + "type": "instance-credential", | |
| 94 | + "instanceId": "provider-instance", | |
| 95 | + "credential": "secret", | |
| 96 | + }, | |
| 97 | + hermes_version="0.19.0", | |
| 98 | + ) | |
| 99 | + self.assertEqual(raised.exception.code, "version-incompatible") | |
| 100 | + | |
| 101 | + | |
| 102 | +if __name__ == "__main__": | |
| 103 | + unittest.main() | |
| @@ -0,0 +1,63 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import importlib.util | |
| 4 | +import pathlib | |
| 5 | +import unittest | |
| 6 | + | |
| 7 | +ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 8 | +SPEC = importlib.util.spec_from_file_location( | |
| 9 | + "t3_gateway_protocol", ROOT / "protocol.py" | |
| 10 | +) | |
| 11 | +assert SPEC and SPEC.loader | |
| 12 | +protocol = importlib.util.module_from_spec(SPEC) | |
| 13 | +SPEC.loader.exec_module(protocol) | |
| 14 | + | |
| 15 | + | |
| 16 | +class ProtocolTests(unittest.TestCase): | |
| 17 | + def test_hello_matches_v1_contract(self): | |
| 18 | + hello = protocol.connection_hello( | |
| 19 | + hermes_version="0.19.0", | |
| 20 | + authentication={"type": "enrollment-token", "token": "once"}, | |
| 21 | + hello_request_id="request-1", | |
| 22 | + ) | |
| 23 | + self.assertEqual(hello["type"], "connection.hello") | |
| 24 | + self.assertEqual(hello["requestId"], "request-1") | |
| 25 | + self.assertEqual(hello["protocolVersion"], 1) | |
| 26 | + self.assertFalse(hello["capabilities"]["attachments"]) | |
| 27 | + self.assertTrue(hello["capabilities"]["streaming"]) | |
| 28 | + | |
| 29 | + def test_server_frame_validation_is_closed(self): | |
| 30 | + with self.assertRaisesRegex(ValueError, "unsupported"): | |
| 31 | + protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1}) | |
| 32 | + with self.assertRaisesRegex(ValueError, "version"): | |
| 33 | + protocol.validate_server_frame({"type": "ping", "protocolVersion": 2}) | |
| 34 | + | |
| 35 | + def test_tool_types_map_to_canonical_items(self): | |
| 36 | + self.assertEqual( | |
| 37 | + protocol.canonical_tool_item_type("terminal"), "command_execution" | |
| 38 | + ) | |
| 39 | + self.assertEqual( | |
| 40 | + protocol.canonical_tool_item_type("apply_patch"), "file_change" | |
| 41 | + ) | |
| 42 | + self.assertEqual( | |
| 43 | + protocol.canonical_tool_item_type("custom_vendor_tool"), | |
| 44 | + "dynamic_tool_call", | |
| 45 | + ) | |
| 46 | + | |
| 47 | + def test_tool_data_never_forwards_arbitrary_args(self): | |
| 48 | + self.assertEqual( | |
| 49 | + protocol.canonical_tool_data( | |
| 50 | + "terminal", | |
| 51 | + {"command": "pytest", "cwd": "/repo", "credential": "secret"}, | |
| 52 | + ), | |
| 53 | + {"command": "pytest", "cwd": "/repo"}, | |
| 54 | + ) | |
| 55 | + self.assertIsNone( | |
| 56 | + protocol.canonical_tool_data( | |
| 57 | + "custom_vendor_tool", {"credential": "must-not-cross"} | |
| 58 | + ) | |
| 59 | + ) | |
| 60 | + | |
| 61 | + | |
| 62 | +if __name__ == "__main__": | |
| 63 | + unittest.main() | |
| @@ -315,6 +315,22 @@ export function createServerEnvironmentAtoms<R, E>( · 314 unmodified lines | ||
| 315 | 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 | 334 | updateProvider: createEnvironmentRpcCommand(runtime, { |
| 319 | 335 | label: "environment-data:server:update-provider", |
| 320 | 336 | tag: WS_METHODS.serverUpdateProvider, |
| @@ -0,0 +1,414 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + DEFAULT_HERMES_MODEL, | |
| 6 | + DEFAULT_MODEL_BY_PROVIDER, | |
| 7 | + HERMES_DRIVER_KIND, | |
| 8 | + PROVIDER_DISPLAY_NAMES, | |
| 9 | +} from "./model.ts"; | |
| 10 | +import { | |
| 11 | + HermesGatewayCapabilities, | |
| 12 | + HermesGatewayConnectionHello, | |
| 13 | + HermesGatewayCreateEnrollmentInput, | |
| 14 | + HermesGatewayInstanceStatus, | |
| 15 | + HermesGatewayPluginToT3Message, | |
| 16 | + HermesGatewayResumeCursor, | |
| 17 | + HermesGatewayT3ToPluginMessage, | |
| 18 | +} from "./hermesGateway.ts"; | |
| 19 | +import { WS_METHODS } from "./rpc.ts"; | |
| 20 | +import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts"; | |
| 21 | + | |
| 22 | +const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput); | |
| 23 | +const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities); | |
| 24 | +const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus); | |
| 25 | +const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello); | |
| 26 | +const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor); | |
| 27 | +const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage); | |
| 28 | +const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message); | |
| 29 | +const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings); | |
| 30 | + | |
| 31 | +describe("Hermes gateway management contracts", () => { | |
| 32 | + it("decodes an enrollment request without deriving identity from the nickname", () => { | |
| 33 | + expect( | |
| 34 | + decodeCreateEnrollment({ | |
| 35 | + instanceId: "hermes-research", | |
| 36 | + nickname: " Research ", | |
| 37 | + connectorUrl: " https://siva.davis7.space:3774/hermes ", | |
| 38 | + }), | |
| 39 | + ).toEqual({ | |
| 40 | + instanceId: "hermes-research", | |
| 41 | + nickname: "Research", | |
| 42 | + connectorUrl: "https://siva.davis7.space:3774/hermes", | |
| 43 | + }); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("rejects invalid provider ids and non-connector URL schemes", () => { | |
| 47 | + expect(() => | |
| 48 | + decodeCreateEnrollment({ | |
| 49 | + instanceId: "1-hermes", | |
| 50 | + nickname: "Research", | |
| 51 | + connectorUrl: "wss://siva.davis7.space/hermes", | |
| 52 | + }), | |
| 53 | + ).toThrow(); | |
| 54 | + expect(() => | |
| 55 | + decodeCreateEnrollment({ | |
| 56 | + instanceId: "hermes-research", | |
| 57 | + nickname: "Research", | |
| 58 | + connectorUrl: "ftp://siva.davis7.space/hermes", | |
| 59 | + }), | |
| 60 | + ).toThrow(); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("represents connected and upgrade-required instances for the web UI", () => { | |
| 64 | + const connected = decodeInstanceStatus({ | |
| 65 | + instanceId: "hermes-research", | |
| 66 | + nickname: "Research", | |
| 67 | + status: "connected", | |
| 68 | + connectorUrl: "wss://siva.davis7.space/hermes", | |
| 69 | + lastConnectedAt: "2026-07-23T12:00:00.000Z", | |
| 70 | + pluginVersion: "0.1.0", | |
| 71 | + hermesVersion: "1.2.3", | |
| 72 | + activeSessionCount: 2, | |
| 73 | + protocolVersion: 1, | |
| 74 | + capabilities: { | |
| 75 | + protocolVersion: 1, | |
| 76 | + streaming: true, | |
| 77 | + activity: true, | |
| 78 | + approvals: true, | |
| 79 | + userInput: true, | |
| 80 | + attachments: false, | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + expect(connected.status).toBe("connected"); | |
| 84 | + expect(connected.activeSessionCount).toBe(2); | |
| 85 | + | |
| 86 | + const upgradeRequired = decodeInstanceStatus({ | |
| 87 | + ...connected, | |
| 88 | + status: "upgrade-required", | |
| 89 | + protocolVersion: 2, | |
| 90 | + capabilities: null, | |
| 91 | + }); | |
| 92 | + expect(upgradeRequired.protocolVersion).toBe(2); | |
| 93 | + expect(upgradeRequired.capabilities).toBeNull(); | |
| 94 | + }); | |
| 95 | +}); | |
| 96 | + | |
| 97 | +describe("Hermes gateway handshake", () => { | |
| 98 | + it("accepts one-time enrollment authentication", () => { | |
| 99 | + const hello = decodeHello({ | |
| 100 | + type: "connection.hello", | |
| 101 | + requestId: "hello-1", | |
| 102 | + protocolVersion: 1, | |
| 103 | + pluginVersion: "0.1.0", | |
| 104 | + hermesVersion: "1.2.3", | |
| 105 | + capabilities: { | |
| 106 | + protocolVersion: 1, | |
| 107 | + streaming: true, | |
| 108 | + activity: true, | |
| 109 | + approvals: true, | |
| 110 | + userInput: true, | |
| 111 | + attachments: false, | |
| 112 | + }, | |
| 113 | + authentication: { | |
| 114 | + type: "enrollment-token", | |
| 115 | + token: "enroll-secret", | |
| 116 | + }, | |
| 117 | + }); | |
| 118 | + | |
| 119 | + expect(hello.authentication.type).toBe("enrollment-token"); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + it("accepts persistent instance authentication", () => { | |
| 123 | + const hello = decodeHello({ | |
| 124 | + type: "connection.hello", | |
| 125 | + requestId: "hello-2", | |
| 126 | + protocolVersion: 1, | |
| 127 | + pluginVersion: "0.1.0", | |
| 128 | + hermesVersion: "1.2.3", | |
| 129 | + capabilities: { | |
| 130 | + protocolVersion: 1, | |
| 131 | + streaming: true, | |
| 132 | + activity: true, | |
| 133 | + approvals: true, | |
| 134 | + userInput: true, | |
| 135 | + attachments: false, | |
| 136 | + }, | |
| 137 | + authentication: { | |
| 138 | + type: "instance-credential", | |
| 139 | + instanceId: "hermes-research", | |
| 140 | + credential: "persistent-secret", | |
| 141 | + }, | |
| 142 | + }); | |
| 143 | + | |
| 144 | + expect(hello.authentication.type).toBe("instance-credential"); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + it("decodes a future-version hello so the broker can reject it explicitly", () => { | |
| 148 | + const hello = decodeHello({ | |
| 149 | + type: "connection.hello", | |
| 150 | + requestId: "hello-future", | |
| 151 | + protocolVersion: 2, | |
| 152 | + pluginVersion: "0.2.0", | |
| 153 | + hermesVersion: "2.0.0", | |
| 154 | + capabilities: { | |
| 155 | + protocolVersion: 2, | |
| 156 | + streaming: true, | |
| 157 | + activity: true, | |
| 158 | + approvals: true, | |
| 159 | + userInput: true, | |
| 160 | + attachments: true, | |
| 161 | + }, | |
| 162 | + authentication: { | |
| 163 | + type: "enrollment-token", | |
| 164 | + token: "enroll-secret", | |
| 165 | + }, | |
| 166 | + }); | |
| 167 | + | |
| 168 | + expect(hello.protocolVersion).toBe(2); | |
| 169 | + expect(hello.capabilities.protocolVersion).toBe(2); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + it("reserves attachments for a future protocol version", () => { | |
| 173 | + expect(() => | |
| 174 | + decodeCapabilities({ | |
| 175 | + protocolVersion: 1, | |
| 176 | + streaming: true, | |
| 177 | + activity: true, | |
| 178 | + approvals: true, | |
| 179 | + userInput: true, | |
| 180 | + attachments: true, | |
| 181 | + }), | |
| 182 | + ).toThrow(); | |
| 183 | + }); | |
| 184 | +}); | |
| 185 | + | |
| 186 | +describe("T3 to Hermes v1 messages", () => { | |
| 187 | + it("decodes session creation and opaque resume cursors", () => { | |
| 188 | + expect( | |
| 189 | + decodeT3Message({ | |
| 190 | + type: "session.ensure", | |
| 191 | + protocolVersion: 1, | |
| 192 | + requestId: "ensure-1", | |
| 193 | + threadId: "thread-1", | |
| 194 | + resumeSessionId: "opaque/hermes/session/value", | |
| 195 | + }).type, | |
| 196 | + ).toBe("session.ensure"); | |
| 197 | + | |
| 198 | + expect( | |
| 199 | + decodeResumeCursor({ | |
| 200 | + protocolVersion: 1, | |
| 201 | + sessionId: "opaque/hermes/session/value", | |
| 202 | + }).sessionId, | |
| 203 | + ).toBe("opaque/hermes/session/value"); | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it("decodes start and steering as distinct turn operations", () => { | |
| 207 | + const context = { | |
| 208 | + protocolVersion: 1, | |
| 209 | + requestId: "turn-command-1", | |
| 210 | + threadId: "thread-1", | |
| 211 | + sessionId: "session-1", | |
| 212 | + turnId: "turn-1", | |
| 213 | + text: "Keep the current turn running, but use this guidance.", | |
| 214 | + }; | |
| 215 | + | |
| 216 | + expect(decodeT3Message({ type: "turn.start", ...context }).type).toBe("turn.start"); | |
| 217 | + expect(decodeT3Message({ type: "turn.steer", ...context }).type).toBe("turn.steer"); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("decodes interrupt, approval, structured input, stop, and ping", () => { | |
| 221 | + const turnContext = { | |
| 222 | + protocolVersion: 1, | |
| 223 | + threadId: "thread-1", | |
| 224 | + sessionId: "session-1", | |
| 225 | + turnId: "turn-1", | |
| 226 | + }; | |
| 227 | + | |
| 228 | + expect( | |
| 229 | + decodeT3Message({ | |
| 230 | + type: "turn.interrupt", | |
| 231 | + requestId: "interrupt-1", | |
| 232 | + ...turnContext, | |
| 233 | + }).type, | |
| 234 | + ).toBe("turn.interrupt"); | |
| 235 | + expect( | |
| 236 | + decodeT3Message({ | |
| 237 | + type: "approval.respond", | |
| 238 | + requestId: "approval-1", | |
| 239 | + decision: "acceptForSession", | |
| 240 | + ...turnContext, | |
| 241 | + }).type, | |
| 242 | + ).toBe("approval.respond"); | |
| 243 | + expect( | |
| 244 | + decodeT3Message({ | |
| 245 | + type: "user-input.respond", | |
| 246 | + requestId: "question-1", | |
| 247 | + answers: { environment: "production" }, | |
| 248 | + ...turnContext, | |
| 249 | + }).type, | |
| 250 | + ).toBe("user-input.respond"); | |
| 251 | + expect( | |
| 252 | + decodeT3Message({ | |
| 253 | + type: "session.stop", | |
| 254 | + protocolVersion: 1, | |
| 255 | + requestId: "stop-1", | |
| 256 | + threadId: "thread-1", | |
| 257 | + sessionId: "session-1", | |
| 258 | + }).type, | |
| 259 | + ).toBe("session.stop"); | |
| 260 | + expect( | |
| 261 | + decodeT3Message({ | |
| 262 | + type: "ping", | |
| 263 | + protocolVersion: 1, | |
| 264 | + requestId: "ping-1", | |
| 265 | + sentAt: "2026-07-23T12:00:00.000Z", | |
| 266 | + }).type, | |
| 267 | + ).toBe("ping"); | |
| 268 | + }); | |
| 269 | + | |
| 270 | + it("rejects post-handshake frames from another protocol version", () => { | |
| 271 | + expect(() => | |
| 272 | + decodeT3Message({ | |
| 273 | + type: "ping", | |
| 274 | + protocolVersion: 2, | |
| 275 | + requestId: "ping-1", | |
| 276 | + sentAt: "2026-07-23T12:00:00.000Z", | |
| 277 | + }), | |
| 278 | + ).toThrow(); | |
| 279 | + }); | |
| 280 | +}); | |
| 281 | + | |
| 282 | +describe("Hermes to T3 v1 events", () => { | |
| 283 | + const turnContext = { | |
| 284 | + protocolVersion: 1, | |
| 285 | + threadId: "thread-1", | |
| 286 | + sessionId: "session-1", | |
| 287 | + turnId: "turn-1", | |
| 288 | + }; | |
| 289 | + | |
| 290 | + it("decodes session readiness, turn start, streaming text, and completion", () => { | |
| 291 | + expect( | |
| 292 | + decodePluginMessage({ | |
| 293 | + type: "session.ready", | |
| 294 | + protocolVersion: 1, | |
| 295 | + requestId: "ensure-1", | |
| 296 | + threadId: "thread-1", | |
| 297 | + sessionId: "session-1", | |
| 298 | + resumed: false, | |
| 299 | + }).type, | |
| 300 | + ).toBe("session.ready"); | |
| 301 | + expect( | |
| 302 | + decodePluginMessage({ | |
| 303 | + type: "turn.started", | |
| 304 | + requestId: "turn-command-1", | |
| 305 | + ...turnContext, | |
| 306 | + }).type, | |
| 307 | + ).toBe("turn.started"); | |
| 308 | + expect( | |
| 309 | + decodePluginMessage({ | |
| 310 | + type: "content.delta", | |
| 311 | + streamKind: "assistant_text", | |
| 312 | + delta: "Hello", | |
| 313 | + ...turnContext, | |
| 314 | + }).type, | |
| 315 | + ).toBe("content.delta"); | |
| 316 | + expect( | |
| 317 | + decodePluginMessage({ | |
| 318 | + type: "turn.completed", | |
| 319 | + state: "completed", | |
| 320 | + ...turnContext, | |
| 321 | + }).type, | |
| 322 | + ).toBe("turn.completed"); | |
| 323 | + }); | |
| 324 | + | |
| 325 | + it("decodes activity lifecycle events with normalized and generic data", () => { | |
| 326 | + for (const type of ["item.started", "item.updated", "item.completed"] as const) { | |
| 327 | + expect( | |
| 328 | + decodePluginMessage({ | |
| 329 | + type, | |
| 330 | + itemId: "tool-1", | |
| 331 | + itemType: "mcp_tool_call", | |
| 332 | + status: type === "item.completed" ? "completed" : "inProgress", | |
| 333 | + title: "Search", | |
| 334 | + detail: "Looking up the requested information", | |
| 335 | + data: { providerKind: "hermes-native-event" }, | |
| 336 | + ...turnContext, | |
| 337 | + }).type, | |
| 338 | + ).toBe(type); | |
| 339 | + } | |
| 340 | + }); | |
| 341 | + | |
| 342 | + it("decodes approvals and structured user-input lifecycle events", () => { | |
| 343 | + expect( | |
| 344 | + decodePluginMessage({ | |
| 345 | + type: "request.opened", | |
| 346 | + requestId: "approval-1", | |
| 347 | + requestType: "command_execution_approval", | |
| 348 | + detail: "Run the command?", | |
| 349 | + args: { command: "git status" }, | |
| 350 | + ...turnContext, | |
| 351 | + }).type, | |
| 352 | + ).toBe("request.opened"); | |
| 353 | + expect( | |
| 354 | + decodePluginMessage({ | |
| 355 | + type: "request.resolved", | |
| 356 | + requestId: "approval-1", | |
| 357 | + requestType: "command_execution_approval", | |
| 358 | + decision: "accept", | |
| 359 | + ...turnContext, | |
| 360 | + }).type, | |
| 361 | + ).toBe("request.resolved"); | |
| 362 | + expect( | |
| 363 | + decodePluginMessage({ | |
| 364 | + type: "user-input.requested", | |
| 365 | + requestId: "question-1", | |
| 366 | + questions: [ | |
| 367 | + { | |
| 368 | + id: "environment", | |
| 369 | + header: "Target", | |
| 370 | + question: "Which environment?", | |
| 371 | + options: [ | |
| 372 | + { | |
| 373 | + label: "Staging", | |
| 374 | + description: "Deploy to the staging environment.", | |
| 375 | + }, | |
| 376 | + ], | |
| 377 | + }, | |
| 378 | + ], | |
| 379 | + ...turnContext, | |
| 380 | + }).type, | |
| 381 | + ).toBe("user-input.requested"); | |
| 382 | + expect( | |
| 383 | + decodePluginMessage({ | |
| 384 | + type: "user-input.resolved", | |
| 385 | + requestId: "question-1", | |
| 386 | + answers: { environment: "Staging" }, | |
| 387 | + ...turnContext, | |
| 388 | + }).type, | |
| 389 | + ).toBe("user-input.resolved"); | |
| 390 | + }); | |
| 391 | +}); | |
| 392 | + | |
| 393 | +describe("Hermes provider integration constants", () => { | |
| 394 | + it("exposes Hermes as a single opaque model in the normal provider picker", () => { | |
| 395 | + expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL); | |
| 396 | + expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes"); | |
| 397 | + }); | |
| 398 | + | |
| 399 | + it("keeps Hermes server settings remote-only", () => { | |
| 400 | + expect(decodeHermesSettings({})).toEqual({ | |
| 401 | + enabled: true, | |
| 402 | + }); | |
| 403 | + expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({ | |
| 404 | + enabled: true, | |
| 405 | + }); | |
| 406 | + }); | |
| 407 | + | |
| 408 | + it("registers the web-management RPC method names", () => { | |
| 409 | + expect(WS_METHODS.hermesGatewayCreateEnrollment).toBe("hermesGateway.createEnrollment"); | |
| 410 | + expect(WS_METHODS.hermesGatewayGetInstanceStatus).toBe("hermesGateway.getInstanceStatus"); | |
| 411 | + expect(WS_METHODS.hermesGatewayListInstances).toBe("hermesGateway.listInstances"); | |
| 412 | + expect(WS_METHODS.hermesGatewayRevokeInstance).toBe("hermesGateway.revokeInstance"); | |
| 413 | + }); | |
| 414 | +}); | |
| @@ -0,0 +1,574 @@ | ||
| 1 | +/** | |
| 2 | + * Versioned contracts for the T3 Code gateway plugin hosted by Hermes. | |
| 3 | + * | |
| 4 | + * The web-management schemas are intentionally separate from the plugin wire | |
| 5 | + * protocol. Browser clients may receive one-time enrollment tokens, but never | |
| 6 | + * the persistent credential issued directly to the plugin after enrollment. | |
| 7 | + * | |
| 8 | + * @module hermesGateway | |
| 9 | + */ | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +import { | |
| 13 | + IsoDateTime, | |
| 14 | + NonNegativeInt, | |
| 15 | + PositiveInt, | |
| 16 | + ThreadId, | |
| 17 | + TrimmedNonEmptyString, | |
| 18 | + TurnId, | |
| 19 | +} from "./baseSchemas.ts"; | |
| 20 | +import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts"; | |
| 21 | +import { ProviderInstanceId } from "./providerInstance.ts"; | |
| 22 | +import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; | |
| 23 | + | |
| 24 | +export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const; | |
| 25 | + | |
| 26 | +export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); | |
| 27 | +export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; | |
| 28 | + | |
| 29 | +export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe( | |
| 30 | + Schema.brand("HermesGatewayRequestId"), | |
| 31 | +); | |
| 32 | +export type HermesGatewayRequestId = typeof HermesGatewayRequestId.Type; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * An opaque identifier owned entirely by Hermes. T3 persists and echoes it, | |
| 36 | + * but must not derive routing or other semantics from its contents. | |
| 37 | + */ | |
| 38 | +export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe( | |
| 39 | + Schema.brand("HermesGatewaySessionId"), | |
| 40 | +); | |
| 41 | +export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type; | |
| 42 | + | |
| 43 | +export const HermesGatewayResumeCursor = Schema.Struct({ | |
| 44 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 45 | + sessionId: HermesGatewaySessionId, | |
| 46 | +}); | |
| 47 | +export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type; | |
| 48 | + | |
| 49 | +export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId")); | |
| 50 | +export type HermesGatewayItemId = typeof HermesGatewayItemId.Type; | |
| 51 | + | |
| 52 | +export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe( | |
| 53 | + Schema.brand("HermesGatewayEnrollmentToken"), | |
| 54 | +); | |
| 55 | +export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type; | |
| 56 | + | |
| 57 | +export const HermesGatewayCredential = TrimmedNonEmptyString.pipe( | |
| 58 | + Schema.brand("HermesGatewayCredential"), | |
| 59 | +); | |
| 60 | +export type HermesGatewayCredential = typeof HermesGatewayCredential.Type; | |
| 61 | + | |
| 62 | +export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); | |
| 63 | +export type HermesGatewayNickname = typeof HermesGatewayNickname.Type; | |
| 64 | + | |
| 65 | +/** | |
| 66 | + * T3 accepts ordinary HTTP(S) URLs because the plugin command may normalize | |
| 67 | + * them to WebSocket URLs, as well as explicit WS(S) connector URLs. | |
| 68 | + */ | |
| 69 | +export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check( | |
| 70 | + Schema.isMaxLength(2_048), | |
| 71 | + Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i), | |
| 72 | +); | |
| 73 | +export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type; | |
| 74 | + | |
| 75 | +export const HermesGatewayCapabilities = Schema.Struct({ | |
| 76 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 77 | + streaming: Schema.Boolean, | |
| 78 | + activity: Schema.Boolean, | |
| 79 | + approvals: Schema.Boolean, | |
| 80 | + userInput: Schema.Boolean, | |
| 81 | + attachments: Schema.Literal(false), | |
| 82 | +}); | |
| 83 | +export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * Capability advertisement accepted at the initial handshake boundary. | |
| 87 | + * | |
| 88 | + * This deliberately permits capability shapes from a newer protocol so T3 can | |
| 89 | + * return a structured `version-incompatible` rejection instead of failing the | |
| 90 | + * WebSocket frame decoder. Accepted v1 connections must subsequently validate | |
| 91 | + * this advertisement with `HermesGatewayCapabilities`. | |
| 92 | + */ | |
| 93 | +export const HermesGatewayHelloCapabilities = Schema.Struct({ | |
| 94 | + protocolVersion: PositiveInt, | |
| 95 | + streaming: Schema.Boolean, | |
| 96 | + activity: Schema.Boolean, | |
| 97 | + approvals: Schema.Boolean, | |
| 98 | + userInput: Schema.Boolean, | |
| 99 | + attachments: Schema.Boolean, | |
| 100 | +}); | |
| 101 | +export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type; | |
| 102 | + | |
| 103 | +export const HermesGatewayConnectionState = Schema.Literals([ | |
| 104 | + "offline", | |
| 105 | + "connecting", | |
| 106 | + "connected", | |
| 107 | + "upgrade-required", | |
| 108 | + "revoked", | |
| 109 | +]); | |
| 110 | +export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.Type; | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * Public instance state used by settings and provider-picker surfaces. | |
| 114 | + * | |
| 115 | + * `protocolVersion` is not restricted to v1 here so the UI can report the | |
| 116 | + * unsupported version observed from a plugin that needs an upgrade. | |
| 117 | + */ | |
| 118 | +export const HermesGatewayInstanceStatus = Schema.Struct({ | |
| 119 | + instanceId: ProviderInstanceId, | |
| 120 | + nickname: HermesGatewayNickname, | |
| 121 | + status: HermesGatewayConnectionState, | |
| 122 | + connectorUrl: HermesGatewayConnectorUrl, | |
| 123 | + lastConnectedAt: Schema.NullOr(IsoDateTime), | |
| 124 | + pluginVersion: Schema.NullOr(TrimmedNonEmptyString), | |
| 125 | + hermesVersion: Schema.NullOr(TrimmedNonEmptyString), | |
| 126 | + activeSessionCount: NonNegativeInt, | |
| 127 | + protocolVersion: Schema.NullOr(PositiveInt), | |
| 128 | + capabilities: Schema.NullOr(HermesGatewayCapabilities), | |
| 129 | +}); | |
| 130 | +export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type; | |
| 131 | + | |
| 132 | +export const HermesGatewayCreateEnrollmentInput = Schema.Struct({ | |
| 133 | + instanceId: ProviderInstanceId, | |
| 134 | + nickname: HermesGatewayNickname, | |
| 135 | + connectorUrl: HermesGatewayConnectorUrl, | |
| 136 | +}); | |
| 137 | +export type HermesGatewayCreateEnrollmentInput = typeof HermesGatewayCreateEnrollmentInput.Type; | |
| 138 | + | |
| 139 | +/** | |
| 140 | + * Returned exactly once to the web client. The long-lived plugin credential | |
| 141 | + * is intentionally absent and is delivered only over the authenticated | |
| 142 | + * enrollment socket. | |
| 143 | + */ | |
| 144 | +export const HermesGatewayEnrollmentResult = Schema.Struct({ | |
| 145 | + instanceId: ProviderInstanceId, | |
| 146 | + expiresAt: IsoDateTime, | |
| 147 | + connectorUrl: HermesGatewayConnectorUrl, | |
| 148 | + command: TrimmedNonEmptyString, | |
| 149 | + oneTimeToken: HermesGatewayEnrollmentToken, | |
| 150 | +}); | |
| 151 | +export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type; | |
| 152 | + | |
| 153 | +export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus); | |
| 154 | +export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type; | |
| 155 | + | |
| 156 | +export const HermesGatewayGetInstanceStatusInput = Schema.Struct({ | |
| 157 | + instanceId: ProviderInstanceId, | |
| 158 | +}); | |
| 159 | +export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type; | |
| 160 | + | |
| 161 | +export const HermesGatewayRenameInstanceInput = Schema.Struct({ | |
| 162 | + instanceId: ProviderInstanceId, | |
| 163 | + nickname: HermesGatewayNickname, | |
| 164 | +}); | |
| 165 | +export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type; | |
| 166 | + | |
| 167 | +export const HermesGatewayRevokeInstanceInput = Schema.Struct({ | |
| 168 | + instanceId: ProviderInstanceId, | |
| 169 | +}); | |
| 170 | +export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type; | |
| 171 | + | |
| 172 | +export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus; | |
| 173 | +export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type; | |
| 174 | + | |
| 175 | +export const HermesGatewayManagementOperation = Schema.Literals([ | |
| 176 | + "create-enrollment", | |
| 177 | + "get-status", | |
| 178 | + "list-instances", | |
| 179 | + "revoke-instance", | |
| 180 | +]); | |
| 181 | +export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type; | |
| 182 | + | |
| 183 | +export const HermesGatewayManagementErrorCode = Schema.Literals([ | |
| 184 | + "instance-not-found", | |
| 185 | + "nickname-conflict", | |
| 186 | + "invalid-connector-url", | |
| 187 | + "instance-revoked", | |
| 188 | + "persistence-failed", | |
| 189 | + "internal-error", | |
| 190 | +]); | |
| 191 | +export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type; | |
| 192 | + | |
| 193 | +export class HermesGatewayManagementError extends Schema.TaggedErrorClass<HermesGatewayManagementError>()( | |
| 194 | + "HermesGatewayManagementError", | |
| 195 | + { | |
| 196 | + operation: HermesGatewayManagementOperation, | |
| 197 | + code: HermesGatewayManagementErrorCode, | |
| 198 | + message: TrimmedNonEmptyString, | |
| 199 | + instanceId: Schema.optional(ProviderInstanceId), | |
| 200 | + }, | |
| 201 | +) {} | |
| 202 | + | |
| 203 | +const HermesGatewayEnrollmentAuthentication = Schema.Struct({ | |
| 204 | + type: Schema.Literal("enrollment-token"), | |
| 205 | + token: HermesGatewayEnrollmentToken, | |
| 206 | +}); | |
| 207 | +export type HermesGatewayEnrollmentAuthentication = | |
| 208 | + typeof HermesGatewayEnrollmentAuthentication.Type; | |
| 209 | + | |
| 210 | +const HermesGatewayCredentialAuthentication = Schema.Struct({ | |
| 211 | + type: Schema.Literal("instance-credential"), | |
| 212 | + instanceId: ProviderInstanceId, | |
| 213 | + credential: HermesGatewayCredential, | |
| 214 | +}); | |
| 215 | +export type HermesGatewayCredentialAuthentication = | |
| 216 | + typeof HermesGatewayCredentialAuthentication.Type; | |
| 217 | + | |
| 218 | +export const HermesGatewayAuthentication = Schema.Union([ | |
| 219 | + HermesGatewayEnrollmentAuthentication, | |
| 220 | + HermesGatewayCredentialAuthentication, | |
| 221 | +]); | |
| 222 | +export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type; | |
| 223 | + | |
| 224 | +/** | |
| 225 | + * `protocolVersion` accepts any positive integer at the initial boundary so | |
| 226 | + * T3 can reject incompatible plugins with a structured upgrade response. | |
| 227 | + * Once accepted, all remaining v1 frames use the literal v1 schema. | |
| 228 | + */ | |
| 229 | +export const HermesGatewayConnectionHello = Schema.Struct({ | |
| 230 | + type: Schema.Literal("connection.hello"), | |
| 231 | + requestId: HermesGatewayRequestId, | |
| 232 | + protocolVersion: PositiveInt, | |
| 233 | + pluginVersion: TrimmedNonEmptyString, | |
| 234 | + hermesVersion: TrimmedNonEmptyString, | |
| 235 | + capabilities: HermesGatewayHelloCapabilities, | |
| 236 | + authentication: HermesGatewayAuthentication, | |
| 237 | +}); | |
| 238 | +export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type; | |
| 239 | + | |
| 240 | +export const HermesGatewayConnectionAccepted = Schema.Struct({ | |
| 241 | + type: Schema.Literal("connection.accepted"), | |
| 242 | + requestId: HermesGatewayRequestId, | |
| 243 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 244 | + instanceId: ProviderInstanceId, | |
| 245 | + nickname: HermesGatewayNickname, | |
| 246 | + credential: Schema.optional(HermesGatewayCredential), | |
| 247 | +}); | |
| 248 | +export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type; | |
| 249 | + | |
| 250 | +export const HermesGatewayConnectionRejectionCode = Schema.Literals([ | |
| 251 | + "invalid-authentication", | |
| 252 | + "enrollment-expired", | |
| 253 | + "instance-revoked", | |
| 254 | + "version-incompatible", | |
| 255 | + "internal-error", | |
| 256 | +]); | |
| 257 | +export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type; | |
| 258 | + | |
| 259 | +export const HermesGatewayConnectionRejected = Schema.Struct({ | |
| 260 | + type: Schema.Literal("connection.rejected"), | |
| 261 | + requestId: HermesGatewayRequestId, | |
| 262 | + code: HermesGatewayConnectionRejectionCode, | |
| 263 | + message: TrimmedNonEmptyString, | |
| 264 | + expectedProtocolVersion: HermesGatewayProtocolVersion, | |
| 265 | +}); | |
| 266 | +export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type; | |
| 267 | + | |
| 268 | +export const HermesGatewayConnectionStatus = Schema.Struct({ | |
| 269 | + type: Schema.Literal("connection.status"), | |
| 270 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 271 | + activeSessionCount: NonNegativeInt, | |
| 272 | +}); | |
| 273 | +export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type; | |
| 274 | + | |
| 275 | +const HermesGatewaySessionContext = Schema.Struct({ | |
| 276 | + threadId: ThreadId, | |
| 277 | + sessionId: HermesGatewaySessionId, | |
| 278 | +}); | |
| 279 | + | |
| 280 | +const HermesGatewayTurnContext = Schema.Struct({ | |
| 281 | + ...HermesGatewaySessionContext.fields, | |
| 282 | + turnId: TurnId, | |
| 283 | +}); | |
| 284 | + | |
| 285 | +const HermesGatewayTurnText = Schema.String.check( | |
| 286 | + Schema.isMinLength(1), | |
| 287 | + Schema.isMaxLength(120_000), | |
| 288 | +); | |
| 289 | + | |
| 290 | +export const HermesGatewaySessionEnsure = Schema.Struct({ | |
| 291 | + type: Schema.Literal("session.ensure"), | |
| 292 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 293 | + requestId: HermesGatewayRequestId, | |
| 294 | + threadId: ThreadId, | |
| 295 | + resumeSessionId: Schema.optional(HermesGatewaySessionId), | |
| 296 | +}); | |
| 297 | +export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type; | |
| 298 | + | |
| 299 | +export const HermesGatewayTurnStart = Schema.Struct({ | |
| 300 | + type: Schema.Literal("turn.start"), | |
| 301 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 302 | + requestId: HermesGatewayRequestId, | |
| 303 | + ...HermesGatewayTurnContext.fields, | |
| 304 | + text: HermesGatewayTurnText, | |
| 305 | +}); | |
| 306 | +export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type; | |
| 307 | + | |
| 308 | +export const HermesGatewayTurnSteer = Schema.Struct({ | |
| 309 | + type: Schema.Literal("turn.steer"), | |
| 310 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 311 | + requestId: HermesGatewayRequestId, | |
| 312 | + ...HermesGatewayTurnContext.fields, | |
| 313 | + text: HermesGatewayTurnText, | |
| 314 | +}); | |
| 315 | +export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type; | |
| 316 | + | |
| 317 | +export const HermesGatewayTurnInterrupt = Schema.Struct({ | |
| 318 | + type: Schema.Literal("turn.interrupt"), | |
| 319 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 320 | + requestId: HermesGatewayRequestId, | |
| 321 | + ...HermesGatewayTurnContext.fields, | |
| 322 | +}); | |
| 323 | +export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type; | |
| 324 | + | |
| 325 | +export const HermesGatewayApprovalResponse = Schema.Struct({ | |
| 326 | + type: Schema.Literal("approval.respond"), | |
| 327 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 328 | + ...HermesGatewayTurnContext.fields, | |
| 329 | + requestId: HermesGatewayRequestId, | |
| 330 | + decision: ProviderApprovalDecision, | |
| 331 | +}); | |
| 332 | +export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type; | |
| 333 | + | |
| 334 | +export const HermesGatewayUserInputResponse = Schema.Struct({ | |
| 335 | + type: Schema.Literal("user-input.respond"), | |
| 336 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 337 | + ...HermesGatewayTurnContext.fields, | |
| 338 | + requestId: HermesGatewayRequestId, | |
| 339 | + answers: ProviderUserInputAnswers, | |
| 340 | +}); | |
| 341 | +export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type; | |
| 342 | + | |
| 343 | +export const HermesGatewaySessionStop = Schema.Struct({ | |
| 344 | + type: Schema.Literal("session.stop"), | |
| 345 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 346 | + requestId: HermesGatewayRequestId, | |
| 347 | + ...HermesGatewaySessionContext.fields, | |
| 348 | +}); | |
| 349 | +export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type; | |
| 350 | + | |
| 351 | +export const HermesGatewayPing = Schema.Struct({ | |
| 352 | + type: Schema.Literal("ping"), | |
| 353 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 354 | + requestId: HermesGatewayRequestId, | |
| 355 | + sentAt: IsoDateTime, | |
| 356 | +}); | |
| 357 | +export type HermesGatewayPing = typeof HermesGatewayPing.Type; | |
| 358 | + | |
| 359 | +export const HermesGatewaySessionReady = Schema.Struct({ | |
| 360 | + type: Schema.Literal("session.ready"), | |
| 361 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 362 | + requestId: HermesGatewayRequestId, | |
| 363 | + threadId: ThreadId, | |
| 364 | + sessionId: HermesGatewaySessionId, | |
| 365 | + resumed: Schema.Boolean, | |
| 366 | +}); | |
| 367 | +export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; | |
| 368 | + | |
| 369 | +export const HermesGatewayTurnStarted = Schema.Struct({ | |
| 370 | + type: Schema.Literal("turn.started"), | |
| 371 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 372 | + requestId: HermesGatewayRequestId, | |
| 373 | + ...HermesGatewayTurnContext.fields, | |
| 374 | +}); | |
| 375 | +export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type; | |
| 376 | + | |
| 377 | +export const HermesGatewayContentStreamKind = Schema.Literals([ | |
| 378 | + "assistant_text", | |
| 379 | + "reasoning_text", | |
| 380 | + "reasoning_summary_text", | |
| 381 | + "plan_text", | |
| 382 | + "command_output", | |
| 383 | + "unknown", | |
| 384 | +]); | |
| 385 | +export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type; | |
| 386 | + | |
| 387 | +export const HermesGatewayContentDelta = Schema.Struct({ | |
| 388 | + type: Schema.Literal("content.delta"), | |
| 389 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 390 | + ...HermesGatewayTurnContext.fields, | |
| 391 | + itemId: Schema.optional(HermesGatewayItemId), | |
| 392 | + streamKind: HermesGatewayContentStreamKind, | |
| 393 | + delta: Schema.String, | |
| 394 | + contentIndex: Schema.optional(NonNegativeInt), | |
| 395 | +}); | |
| 396 | +export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type; | |
| 397 | + | |
| 398 | +export const HermesGatewayItemStatus = Schema.Literals([ | |
| 399 | + "inProgress", | |
| 400 | + "completed", | |
| 401 | + "failed", | |
| 402 | + "declined", | |
| 403 | +]); | |
| 404 | +export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type; | |
| 405 | + | |
| 406 | +const HermesGatewayItemLifecycleFields = { | |
| 407 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 408 | + ...HermesGatewayTurnContext.fields, | |
| 409 | + itemId: HermesGatewayItemId, | |
| 410 | + itemType: CanonicalItemType, | |
| 411 | + status: Schema.optional(HermesGatewayItemStatus), | |
| 412 | + title: Schema.optional(TrimmedNonEmptyString), | |
| 413 | + detail: Schema.optional(TrimmedNonEmptyString), | |
| 414 | + data: Schema.optional(Schema.Unknown), | |
| 415 | +}; | |
| 416 | + | |
| 417 | +export const HermesGatewayItemStarted = Schema.Struct({ | |
| 418 | + type: Schema.Literal("item.started"), | |
| 419 | + ...HermesGatewayItemLifecycleFields, | |
| 420 | +}); | |
| 421 | +export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type; | |
| 422 | + | |
| 423 | +export const HermesGatewayItemUpdated = Schema.Struct({ | |
| 424 | + type: Schema.Literal("item.updated"), | |
| 425 | + ...HermesGatewayItemLifecycleFields, | |
| 426 | +}); | |
| 427 | +export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type; | |
| 428 | + | |
| 429 | +export const HermesGatewayItemCompleted = Schema.Struct({ | |
| 430 | + type: Schema.Literal("item.completed"), | |
| 431 | + ...HermesGatewayItemLifecycleFields, | |
| 432 | +}); | |
| 433 | +export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type; | |
| 434 | + | |
| 435 | +const HermesGatewayInteractionContext = Schema.Struct({ | |
| 436 | + ...HermesGatewayTurnContext.fields, | |
| 437 | + requestId: HermesGatewayRequestId, | |
| 438 | +}); | |
| 439 | + | |
| 440 | +export const HermesGatewayRequestOpened = Schema.Struct({ | |
| 441 | + type: Schema.Literal("request.opened"), | |
| 442 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 443 | + ...HermesGatewayInteractionContext.fields, | |
| 444 | + requestType: CanonicalRequestType, | |
| 445 | + detail: Schema.optional(TrimmedNonEmptyString), | |
| 446 | + args: Schema.optional(Schema.Unknown), | |
| 447 | +}); | |
| 448 | +export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type; | |
| 449 | + | |
| 450 | +export const HermesGatewayRequestResolved = Schema.Struct({ | |
| 451 | + type: Schema.Literal("request.resolved"), | |
| 452 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 453 | + ...HermesGatewayInteractionContext.fields, | |
| 454 | + requestType: CanonicalRequestType, | |
| 455 | + decision: Schema.optional(TrimmedNonEmptyString), | |
| 456 | + resolution: Schema.optional(Schema.Unknown), | |
| 457 | +}); | |
| 458 | +export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type; | |
| 459 | + | |
| 460 | +export const HermesGatewayUserInputRequested = Schema.Struct({ | |
| 461 | + type: Schema.Literal("user-input.requested"), | |
| 462 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 463 | + ...HermesGatewayInteractionContext.fields, | |
| 464 | + questions: Schema.Array(UserInputQuestion), | |
| 465 | +}); | |
| 466 | +export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type; | |
| 467 | + | |
| 468 | +export const HermesGatewayUserInputResolved = Schema.Struct({ | |
| 469 | + type: Schema.Literal("user-input.resolved"), | |
| 470 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 471 | + ...HermesGatewayInteractionContext.fields, | |
| 472 | + answers: ProviderUserInputAnswers, | |
| 473 | +}); | |
| 474 | +export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type; | |
| 475 | + | |
| 476 | +export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]); | |
| 477 | +export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type; | |
| 478 | + | |
| 479 | +export const HermesGatewayTurnCompleted = Schema.Struct({ | |
| 480 | + type: Schema.Literal("turn.completed"), | |
| 481 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 482 | + ...HermesGatewayTurnContext.fields, | |
| 483 | + state: HermesGatewayTurnCompletionState, | |
| 484 | + stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 485 | + usage: Schema.optional(Schema.Unknown), | |
| 486 | + errorMessage: Schema.optional(TrimmedNonEmptyString), | |
| 487 | +}); | |
| 488 | +export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type; | |
| 489 | + | |
| 490 | +export const HermesGatewayTurnAborted = Schema.Struct({ | |
| 491 | + type: Schema.Literal("turn.aborted"), | |
| 492 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 493 | + ...HermesGatewayTurnContext.fields, | |
| 494 | + reason: TrimmedNonEmptyString, | |
| 495 | +}); | |
| 496 | +export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type; | |
| 497 | + | |
| 498 | +export const HermesGatewaySessionExited = Schema.Struct({ | |
| 499 | + type: Schema.Literal("session.exited"), | |
| 500 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 501 | + ...HermesGatewaySessionContext.fields, | |
| 502 | + reason: Schema.optional(TrimmedNonEmptyString), | |
| 503 | + recoverable: Schema.Boolean, | |
| 504 | +}); | |
| 505 | +export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type; | |
| 506 | + | |
| 507 | +export const HermesGatewayPong = Schema.Struct({ | |
| 508 | + type: Schema.Literal("pong"), | |
| 509 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 510 | + requestId: HermesGatewayRequestId, | |
| 511 | + sentAt: IsoDateTime, | |
| 512 | +}); | |
| 513 | +export type HermesGatewayPong = typeof HermesGatewayPong.Type; | |
| 514 | + | |
| 515 | +export const HermesGatewayProtocolErrorCode = Schema.Literals([ | |
| 516 | + "invalid-message", | |
| 517 | + "unsupported-message", | |
| 518 | + "session-not-found", | |
| 519 | + "turn-not-active", | |
| 520 | + "request-not-found", | |
| 521 | + "internal-error", | |
| 522 | +]); | |
| 523 | +export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type; | |
| 524 | + | |
| 525 | +export const HermesGatewayProtocolError = Schema.Struct({ | |
| 526 | + type: Schema.Literal("protocol.error"), | |
| 527 | + protocolVersion: HermesGatewayProtocolVersion, | |
| 528 | + requestId: Schema.optional(HermesGatewayRequestId), | |
| 529 | + code: HermesGatewayProtocolErrorCode, | |
| 530 | + message: TrimmedNonEmptyString, | |
| 531 | + recoverable: Schema.Boolean, | |
| 532 | +}); | |
| 533 | +export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type; | |
| 534 | + | |
| 535 | +export const HermesGatewayT3ToPluginMessage = Schema.Union([ | |
| 536 | + HermesGatewayConnectionAccepted, | |
| 537 | + HermesGatewayConnectionRejected, | |
| 538 | + HermesGatewaySessionEnsure, | |
| 539 | + HermesGatewayTurnStart, | |
| 540 | + HermesGatewayTurnSteer, | |
| 541 | + HermesGatewayTurnInterrupt, | |
| 542 | + HermesGatewayApprovalResponse, | |
| 543 | + HermesGatewayUserInputResponse, | |
| 544 | + HermesGatewaySessionStop, | |
| 545 | + HermesGatewayPing, | |
| 546 | +]); | |
| 547 | +export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type; | |
| 548 | + | |
| 549 | +export const HermesGatewayPluginToT3Message = Schema.Union([ | |
| 550 | + HermesGatewayConnectionHello, | |
| 551 | + HermesGatewayConnectionStatus, | |
| 552 | + HermesGatewaySessionReady, | |
| 553 | + HermesGatewayTurnStarted, | |
| 554 | + HermesGatewayContentDelta, | |
| 555 | + HermesGatewayItemStarted, | |
| 556 | + HermesGatewayItemUpdated, | |
| 557 | + HermesGatewayItemCompleted, | |
| 558 | + HermesGatewayRequestOpened, | |
| 559 | + HermesGatewayRequestResolved, | |
| 560 | + HermesGatewayUserInputRequested, | |
| 561 | + HermesGatewayUserInputResolved, | |
| 562 | + HermesGatewayTurnCompleted, | |
| 563 | + HermesGatewayTurnAborted, | |
| 564 | + HermesGatewaySessionExited, | |
| 565 | + HermesGatewayPong, | |
| 566 | + HermesGatewayProtocolError, | |
| 567 | +]); | |
| 568 | +export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type; | |
| 569 | + | |
| 570 | +export const HermesGatewayWireMessage = Schema.Union([ | |
| 571 | + HermesGatewayT3ToPluginMessage, | |
| 572 | + HermesGatewayPluginToT3Message, | |
| 573 | +]); | |
| 574 | +export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type; | |
| @@ -27,3 +27,4 @@ export * from "./review.ts"; · 26 unmodified lines | ||
| 27 | 27 | export * from "./preview.ts"; |
| 28 | 28 | export * from "./previewAutomation.ts"; |
| 29 | 29 | export * from "./rpc.ts"; |
| 30 | +export * from "./hermesGateway.ts"; | |
| @@ -132,8 +132,10 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); · 131 unmodified lines | ||
| 132 | 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 134 | 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 135 | +export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); | |
| 135 | 136 | |
| 136 | 137 | export const DEFAULT_MODEL = "gpt-5.6-sol"; |
| 138 | +export const DEFAULT_HERMES_MODEL = "hermes"; | |
| 137 | 139 | |
| 138 | 140 | /** |
| 139 | 141 | * 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 | 154 | [CURSOR_DRIVER_KIND]: "auto", |
| 153 | 155 | [GROK_DRIVER_KIND]: "grok-build", |
| 154 | 156 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", |
| 157 | + [HERMES_DRIVER_KIND]: DEFAULT_HERMES_MODEL, | |
| 155 | 158 | }; |
| 156 | 159 | |
| 157 | 160 | /** Per-provider text generation model defaults. */ |
| @@ -218,4 +221,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 60 unmodified lines | ||
| 218 | 221 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 219 | 222 | [GROK_DRIVER_KIND]: "Grok", |
| 220 | 223 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 224 | + [HERMES_DRIVER_KIND]: "Hermes", | |
| 221 | 225 | }; |
| @@ -59,6 +59,16 @@ import { · 58 unmodified lines | ||
| 59 | 59 | OrchestrationRpcSchemas, |
| 60 | 60 | } from "./orchestration.ts"; |
| 61 | 61 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 62 | +import { | |
| 63 | + HermesGatewayCreateEnrollmentInput, | |
| 64 | + HermesGatewayEnrollmentResult, | |
| 65 | + HermesGatewayGetInstanceStatusInput, | |
| 66 | + HermesGatewayInstanceStatus, | |
| 67 | + HermesGatewayListInstancesResult, | |
| 68 | + HermesGatewayManagementError, | |
| 69 | + HermesGatewayRevokeInstanceInput, | |
| 70 | + HermesGatewayRevokeInstanceResult, | |
| 71 | +} from "./hermesGateway.ts"; | |
| 62 | 72 | import { |
| 63 | 73 | RelayClientInstallFailedError, |
| 64 | 74 | RelayClientInstallProgressEventSchema, |
| @@ -219,6 +229,12 @@ export const WS_METHODS = { · 154 unmodified lines | ||
| 219 | 229 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 220 | 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 | 238 | // Cloud environment methods |
| 223 | 239 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", |
| 224 | 240 | cloudInstallRelayClient: "cloud.installRelayClient", |
| @@ -334,6 +350,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, · 109 unmodified lines | ||
| 334 | 350 | error: EnvironmentAuthorizationError, |
| 335 | 351 | }); |
| 336 | 352 | |
| 353 | +export const WsHermesGatewayCreateEnrollmentRpc = Rpc.make( | |
| 354 | + WS_METHODS.hermesGatewayCreateEnrollment, | |
| 355 | + { | |
| 356 | + payload: HermesGatewayCreateEnrollmentInput, | |
| 357 | + success: HermesGatewayEnrollmentResult, | |
| 358 | + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), | |
| 359 | + }, | |
| 360 | +); | |
| 361 | + | |
| 362 | +export const WsHermesGatewayGetInstanceStatusRpc = Rpc.make( | |
| 363 | + WS_METHODS.hermesGatewayGetInstanceStatus, | |
| 364 | + { | |
| 365 | + payload: HermesGatewayGetInstanceStatusInput, | |
| 366 | + success: HermesGatewayInstanceStatus, | |
| 367 | + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), | |
| 368 | + }, | |
| 369 | +); | |
| 370 | + | |
| 371 | +export const WsHermesGatewayListInstancesRpc = Rpc.make(WS_METHODS.hermesGatewayListInstances, { | |
| 372 | + payload: Schema.Struct({}), | |
| 373 | + success: HermesGatewayListInstancesResult, | |
| 374 | + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), | |
| 375 | +}); | |
| 376 | + | |
| 377 | +export const WsHermesGatewayRevokeInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRevokeInstance, { | |
| 378 | + payload: HermesGatewayRevokeInstanceInput, | |
| 379 | + success: HermesGatewayRevokeInstanceResult, | |
| 380 | + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), | |
| 381 | +}); | |
| 382 | + | |
| 337 | 383 | export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { |
| 338 | 384 | payload: Schema.Struct({}), |
| 339 | 385 | success: RelayClientStatusSchema, |
| @@ -713,6 +759,10 @@ export const WsRpcGroup = RpcGroup.make( · 373 unmodified lines | ||
| 713 | 759 | WsServerGetProcessDiagnosticsRpc, |
| 714 | 760 | WsServerGetProcessResourceHistoryRpc, |
| 715 | 761 | WsServerSignalProcessRpc, |
| 762 | + WsHermesGatewayCreateEnrollmentRpc, | |
| 763 | + WsHermesGatewayGetInstanceStatusRpc, | |
| 764 | + WsHermesGatewayListInstancesRpc, | |
| 765 | + WsHermesGatewayRevokeInstanceRpc, | |
| 716 | 766 | WsCloudGetRelayClientStatusRpc, |
| 717 | 767 | WsCloudInstallRelayClientRpc, |
| 718 | 768 | WsSourceControlLookupRepositoryRpc, |
| @@ -389,6 +389,19 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 388 unmodified lines | ||
| 389 | 389 | ); |
| 390 | 390 | export 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 | + */ | |
| 397 | +export const HermesSettings = makeProviderSettingsSchema({ | |
| 398 | + enabled: Schema.Boolean.pipe( | |
| 399 | + Schema.withDecodingDefault(Effect.succeed(true)), | |
| 400 | + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 401 | + ), | |
| 402 | +}); | |
| 403 | +export type HermesSettings = typeof HermesSettings.Type; | |
| 404 | + | |
| 392 | 405 | export const ObservabilitySettings = Schema.Struct({ |
| 393 | 406 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 394 | 407 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| @@ -433,6 +446,7 @@ export const ServerSettings = Schema.Struct({ · 38 unmodified lines | ||
| 433 | 446 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 434 | 447 | grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 435 | 448 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 449 | + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 436 | 450 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 437 | 451 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 438 | 452 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob |
| @@ -536,6 +550,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 97 unmodified lines | ||
| 536 | 550 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 537 | 551 | }); |
| 538 | 552 | |
| 553 | +const HermesSettingsPatch = Schema.Struct({ | |
| 554 | + enabled: Schema.optionalKey(Schema.Boolean), | |
| 555 | +}); | |
| 556 | + | |
| 539 | 557 | export const ServerSettingsPatch = Schema.Struct({ |
| 540 | 558 | // Server settings |
| 541 | 559 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| @@ -558,6 +576,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 16 unmodified lines | ||
| 558 | 576 | cursor: Schema.optionalKey(CursorSettingsPatch), |
| 559 | 577 | grok: Schema.optionalKey(GrokSettingsPatch), |
| 560 | 578 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), |
| 579 | + hermes: Schema.optionalKey(HermesSettingsPatch), | |
| 561 | 580 | }), |
| 562 | 581 | ), |
| 563 | 582 | // Whole-map replacement for the new instance config. Patching individual |