Unified · Split
| @@ -1181,6 +1181,62 @@ describe("ProviderCommandReactor", () => { · 1180 unmodified lines | |||
| 1181 | expect(harness.stopSession.mock.calls.length).toBe(0); | 1181 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 1182 | }); | 1182 | }); |
| 1183 | 1183 | ||
| 1184 | it("reuses a cwd-bookkept Hermes session for active follow-up guidance", async () => { | ||
| 1185 | const harness = await createHarness({ | ||
| 1186 | threadModelSelection: { | ||
| 1187 | instanceId: ProviderInstanceId.make("hermes"), | ||
| 1188 | model: "hermes", | ||
| 1189 | }, | ||
| 1190 | }); | ||
| 1191 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 1192 | |||
| 1193 | await Effect.runPromise( | ||
| 1194 | harness.engine.dispatch({ | ||
| 1195 | type: "thread.turn.start", | ||
| 1196 | commandId: CommandId.make("cmd-hermes-guidance-1"), | ||
| 1197 | threadId: ThreadId.make("thread-1"), | ||
| 1198 | message: { | ||
| 1199 | messageId: asMessageId("user-message-hermes-guidance-1"), | ||
| 1200 | role: "user", | ||
| 1201 | text: "first", | ||
| 1202 | attachments: [], | ||
| 1203 | }, | ||
| 1204 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1205 | runtimeMode: "approval-required", | ||
| 1206 | createdAt: now, | ||
| 1207 | }), | ||
| 1208 | ); | ||
| 1209 | |||
| 1210 | await waitFor(() => harness.startSession.mock.calls.length === 1); | ||
| 1211 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | ||
| 1212 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | ||
| 1213 | cwd: "/tmp/provider-project", | ||
| 1214 | provider: ProviderDriverKind.make("hermes"), | ||
| 1215 | providerInstanceId: ProviderInstanceId.make("hermes"), | ||
| 1216 | }); | ||
| 1217 | |||
| 1218 | await Effect.runPromise( | ||
| 1219 | harness.engine.dispatch({ | ||
| 1220 | type: "thread.turn.start", | ||
| 1221 | commandId: CommandId.make("cmd-hermes-guidance-2"), | ||
| 1222 | threadId: ThreadId.make("thread-1"), | ||
| 1223 | message: { | ||
| 1224 | messageId: asMessageId("user-message-hermes-guidance-2"), | ||
| 1225 | role: "user", | ||
| 1226 | text: "follow up", | ||
| 1227 | attachments: [], | ||
| 1228 | }, | ||
| 1229 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1230 | runtimeMode: "approval-required", | ||
| 1231 | createdAt: now, | ||
| 1232 | }), | ||
| 1233 | ); | ||
| 1234 | |||
| 1235 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | ||
| 1236 | expect(harness.startSession).toHaveBeenCalledTimes(1); | ||
| 1237 | expect(harness.stopSession).not.toHaveBeenCalled(); | ||
| 1238 | }); | ||
| 1239 | |||
| 1184 | it("restarts an existing Codex thread on a compatible requested instance", async () => { | 1240 | it("restarts an existing Codex thread on a compatible requested instance", async () => { |
| 1185 | const harness = await createHarness(); | 1241 | const harness = await createHarness(); |
| 1186 | const now = "2026-01-01T00:00:00.000Z"; | 1242 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -1873,6 +1873,97 @@ describe("ProviderRuntimeIngestion", () => { · 1872 unmodified lines | |||
| 1873 | expect(message?.streaming).toBe(false); | 1873 | expect(message?.streaming).toBe(false); |
| 1874 | }); | 1874 | }); |
| 1875 | 1875 | ||
| 1876 | it("treats turn.aborted as terminal and finalizes buffered assistant text", async () => { | ||
| 1877 | const harness = await createHarness(); | ||
| 1878 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 1879 | const turnId = asTurnId("turn-aborted-buffered"); | ||
| 1880 | const itemId = asItemId("item-aborted-buffered"); | ||
| 1881 | |||
| 1882 | harness.emit({ | ||
| 1883 | type: "turn.started", | ||
| 1884 | eventId: asEventId("evt-turn-started-before-abort"), | ||
| 1885 | provider: ProviderDriverKind.make("hermes"), | ||
| 1886 | createdAt: now, | ||
| 1887 | threadId: asThreadId("thread-1"), | ||
| 1888 | turnId, | ||
| 1889 | }); | ||
| 1890 | await waitForThread( | ||
| 1891 | harness.readModel, | ||
| 1892 | (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId, | ||
| 1893 | ); | ||
| 1894 | |||
| 1895 | harness.emit({ | ||
| 1896 | type: "content.delta", | ||
| 1897 | eventId: asEventId("evt-content-before-abort"), | ||
| 1898 | provider: ProviderDriverKind.make("hermes"), | ||
| 1899 | createdAt: now, | ||
| 1900 | threadId: asThreadId("thread-1"), | ||
| 1901 | turnId, | ||
| 1902 | itemId, | ||
| 1903 | payload: { | ||
| 1904 | streamKind: "assistant_text", | ||
| 1905 | delta: "partial response", | ||
| 1906 | }, | ||
| 1907 | }); | ||
| 1908 | harness.emit({ | ||
| 1909 | type: "turn.aborted", | ||
| 1910 | eventId: asEventId("evt-turn-aborted"), | ||
| 1911 | provider: ProviderDriverKind.make("hermes"), | ||
| 1912 | createdAt: now, | ||
| 1913 | threadId: asThreadId("thread-1"), | ||
| 1914 | turnId, | ||
| 1915 | payload: { | ||
| 1916 | reason: "Interrupted by user.", | ||
| 1917 | }, | ||
| 1918 | }); | ||
| 1919 | |||
| 1920 | const thread = await waitForThread( | ||
| 1921 | harness.readModel, | ||
| 1922 | (entry) => | ||
| 1923 | entry.session?.status === "ready" && | ||
| 1924 | entry.session.activeTurnId === null && | ||
| 1925 | entry.messages.some( | ||
| 1926 | (message: ProviderRuntimeTestMessage) => | ||
| 1927 | message.id === `assistant:${itemId}` && | ||
| 1928 | message.text === "partial response" && | ||
| 1929 | !message.streaming, | ||
| 1930 | ), | ||
| 1931 | ); | ||
| 1932 | expect(thread.session?.lastError).toBeNull(); | ||
| 1933 | |||
| 1934 | const nextTurnId = asTurnId("turn-after-abort"); | ||
| 1935 | harness.emit({ | ||
| 1936 | type: "turn.started", | ||
| 1937 | eventId: asEventId("evt-turn-started-after-abort"), | ||
| 1938 | provider: ProviderDriverKind.make("hermes"), | ||
| 1939 | createdAt: now, | ||
| 1940 | threadId: asThreadId("thread-1"), | ||
| 1941 | turnId: nextTurnId, | ||
| 1942 | }); | ||
| 1943 | await waitForThread( | ||
| 1944 | harness.readModel, | ||
| 1945 | (entry) => entry.session?.status === "running" && entry.session.activeTurnId === nextTurnId, | ||
| 1946 | ); | ||
| 1947 | harness.emit({ | ||
| 1948 | type: "turn.aborted", | ||
| 1949 | eventId: asEventId("evt-stale-turn-aborted"), | ||
| 1950 | provider: ProviderDriverKind.make("hermes"), | ||
| 1951 | createdAt: now, | ||
| 1952 | threadId: asThreadId("thread-1"), | ||
| 1953 | turnId, | ||
| 1954 | payload: { | ||
| 1955 | reason: "Late abort for the previous turn.", | ||
| 1956 | }, | ||
| 1957 | }); | ||
| 1958 | await harness.drain(); | ||
| 1959 | const afterStaleAbort = await harness.readModel(); | ||
| 1960 | const activeThread = afterStaleAbort.threads.find( | ||
| 1961 | (entry) => entry.id === ThreadId.make("thread-1"), | ||
| 1962 | ); | ||
| 1963 | expect(activeThread?.session?.status).toBe("running"); | ||
| 1964 | expect(activeThread?.session?.activeTurnId).toBe(nextTurnId); | ||
| 1965 | }); | ||
| 1966 | |||
| 1876 | it("flushes and completes buffered assistant text when an approval request opens", async () => { | 1967 | it("flushes and completes buffered assistant text when an approval request opens", async () => { |
| 1877 | const harness = await createHarness(); | 1968 | const harness = await createHarness(); |
| 1878 | const now = "2026-01-01T00:00:00.000Z"; | 1969 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -1343,6 +1343,7 @@ const make = Effect.gen(function* () { · 1342 unmodified lines | |||
| 1343 | case "turn.started": | 1343 | case "turn.started": |
| 1344 | return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; | 1344 | return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; |
| 1345 | case "turn.completed": | 1345 | case "turn.completed": |
| 1346 | case "turn.aborted": | ||
| 1346 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { | 1347 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { |
| 1347 | return false; | 1348 | return false; |
| 1348 | } | 1349 | } |
| @@ -1367,7 +1368,8 @@ const make = Effect.gen(function* () { · 18 unmodified lines | |||
| 1367 | event.type === "session.exited" || | 1368 | event.type === "session.exited" || |
| 1368 | event.type === "thread.started" || | 1369 | event.type === "thread.started" || |
| 1369 | event.type === "turn.started" || | 1370 | event.type === "turn.started" || |
| 1370 | event.type === "turn.completed" | 1371 | event.type === "turn.completed" || |
| 1372 | event.type === "turn.aborted" | ||
| 1371 | ) { | 1373 | ) { |
| 1372 | const status = (() => { | 1374 | const status = (() => { |
| 1373 | switch (event.type) { | 1375 | switch (event.type) { |
| @@ -1383,6 +1385,8 @@ const make = Effect.gen(function* () { · 9 unmodified lines | |||
| 1383 | return normalizeRuntimeTurnState(event.payload.state) === "failed" | 1385 | return normalizeRuntimeTurnState(event.payload.state) === "failed" |
| 1384 | ? "error" | 1386 | ? "error" |
| 1385 | : "ready"; | 1387 | : "ready"; |
| 1388 | case "turn.aborted": | ||
| 1389 | return "ready"; | ||
| 1386 | case "session.started": | 1390 | case "session.started": |
| 1387 | case "thread.started": | 1391 | case "thread.started": |
| 1388 | // Provider thread/session start notifications can arrive during an | 1392 | // Provider thread/session start notifications can arrive during an |
| @@ -1393,7 +1397,9 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 1393 | const nextActiveTurnId = | 1397 | const nextActiveTurnId = |
| 1394 | event.type === "turn.started" | 1398 | event.type === "turn.started" |
| 1395 | ? (eventTurnId ?? null) | 1399 | ? (eventTurnId ?? null) |
| 1396 | : event.type === "turn.completed" || event.type === "session.exited" | 1400 | : event.type === "turn.completed" || |
| 1401 | event.type === "turn.aborted" || | ||
| 1402 | event.type === "session.exited" | ||
| 1397 | ? null | 1403 | ? null |
| 1398 | : event.type === "session.state.changed" && | 1404 | : event.type === "session.state.changed" && |
| 1399 | !sessionStatusAllowsActiveTurn( | 1405 | !sessionStatusAllowsActiveTurn( |
| @@ -1634,7 +1640,7 @@ const make = Effect.gen(function* () { · 234 unmodified lines | |||
| 1634 | }); | 1640 | }); |
| 1635 | } | 1641 | } |
| 1636 | 1642 | ||
| 1637 | if (event.type === "turn.completed") { | 1643 | if (event.type === "turn.completed" || event.type === "turn.aborted") { |
| 1638 | const detailedThread = yield* getLoadedThreadDetail(); | 1644 | const detailedThread = yield* getLoadedThreadDetail(); |
| 1639 | const messages = detailedThread?.messages ?? []; | 1645 | const messages = detailedThread?.messages ?? []; |
| 1640 | const proposedPlans = detailedThread?.proposedPlans ?? []; | 1646 | const proposedPlans = detailedThread?.proposedPlans ?? []; |
| @@ -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 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | 24 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 27 | import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; | ||
| 27 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | 28 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 28 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; | 29 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; |
| 29 | 30 | ||
| @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = · 7 unmodified lines | |||
| 37 | | CodexDriverEnv | 38 | | CodexDriverEnv |
| 38 | | CursorDriverEnv | 39 | | CursorDriverEnv |
| 39 | | GrokDriverEnv | 40 | | GrokDriverEnv |
| 41 | | HermesDriverEnv | ||
| 40 | | OpenCodeDriverEnv; | 42 | | OpenCodeDriverEnv; |
| 41 | 43 | ||
| 42 | /** | 44 | /** |
| @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv · 7 unmodified lines | |||
| 50 | CursorDriver, | 52 | CursorDriver, |
| 51 | GrokDriver, | 53 | GrokDriver, |
| 52 | OpenCodeDriver, | 54 | OpenCodeDriver, |
| 55 | HermesDriver, | ||
| 53 | ]; | 56 | ]; |
| @@ -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 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; | 21 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; |
| 22 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; | 22 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; |
| 23 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 23 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 24 | import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts"; | ||
| 25 | import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts"; | ||
| 24 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 26 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 25 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 27 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 26 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; | 28 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| @@ -287,6 +289,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( · 260 unmodified lines | |||
| 287 | Layer.provideMerge(OrchestrationLayerLive), | 289 | Layer.provideMerge(OrchestrationLayerLive), |
| 288 | ); | 290 | ); |
| 289 | 291 | ||
| 292 | const HermesProviderRuntimeLive = ProviderInstanceRegistryHydrationLive.pipe( | ||
| 293 | Layer.provideMerge(HermesGatewayBrokerLive), | ||
| 294 | ); | ||
| 295 | |||
| 290 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | 296 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 291 | // Core Services | 297 | // Core Services |
| 292 | Layer.provideMerge(CheckpointingLayerLive), | 298 | Layer.provideMerge(CheckpointingLayerLive), |
| @@ -303,7 +309,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 10 unmodified lines | |||
| 303 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; | 309 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; |
| 304 | // `providerInstances` hydration merges `settings.providers.<kind>` | 310 | // `providerInstances` hydration merges `settings.providers.<kind>` |
| 305 | // with explicit `providerInstances` entries on boot. | 311 | // with explicit `providerInstances` entries on boot. |
| 306 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | 312 | Layer.provideMerge(HermesProviderRuntimeLive), |
| 307 | // Shared native/canonical NDJSON writers used by both the per-instance | 313 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 308 | // drivers (native stream, written from inside each `<X>Adapter`) and | 314 | // drivers (native stream, written from inside each `<X>Adapter`) and |
| 309 | // `ProviderService` (canonical stream, written after event normalization). | 315 | // `ProviderService` (canonical stream, written after event normalization). |
| @@ -362,6 +368,7 @@ export const makeRoutesLayer = Layer.mergeAll( · 52 unmodified lines | |||
| 362 | assetRouteLayer, | 368 | assetRouteLayer, |
| 363 | staticAndDevRouteLayer, | 369 | staticAndDevRouteLayer, |
| 364 | websocketRpcRouteLayer, | 370 | websocketRpcRouteLayer, |
| 371 | hermesGatewayWebSocketRouteLayer, | ||
| 365 | ), | 372 | ), |
| 366 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), | 373 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), |
| 367 | ).pipe( | 374 | ).pipe( |
| @@ -78,6 +78,7 @@ import { · 77 unmodified lines | |||
| 78 | } from "./observability/RpcInstrumentation.ts"; | 78 | } from "./observability/RpcInstrumentation.ts"; |
| 79 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 79 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 80 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 80 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 81 | import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts"; | ||
| 81 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | 82 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 82 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 83 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 83 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 84 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| @@ -307,6 +308,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 223 unmodified lines | |||
| 307 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], | 308 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], |
| 308 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], | 309 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], |
| 309 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], | 310 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], |
| 311 | [WS_METHODS.hermesGatewayCreateEnrollment, AuthOrchestrationOperateScope], | ||
| 312 | [WS_METHODS.hermesGatewayGetInstanceStatus, AuthOrchestrationReadScope], | ||
| 313 | [WS_METHODS.hermesGatewayListInstances, AuthOrchestrationReadScope], | ||
| 314 | [WS_METHODS.hermesGatewayRevokeInstance, AuthOrchestrationOperateScope], | ||
| 310 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], | 315 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], |
| 311 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], | 316 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], |
| 312 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], | 317 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], |
| @@ -420,6 +425,7 @@ const makeWsRpcLayer = ( · 107 unmodified lines | |||
| 420 | const portDiscovery = yield* PortScanner.PortDiscovery; | 425 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 421 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; | 426 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 422 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 427 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 428 | const hermesGatewayBroker = yield* HermesGatewayBroker; | ||
| 423 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | 429 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; |
| 424 | const config = yield* ServerConfig.ServerConfig; | 430 | const config = yield* ServerConfig.ServerConfig; |
| 425 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; | 431 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| @@ -1521,6 +1527,30 @@ const makeWsRpcLayer = ( · 1095 unmodified lines | |||
| 1521 | "rpc.aggregate": "server", | 1527 | "rpc.aggregate": "server", |
| 1522 | }, | 1528 | }, |
| 1523 | ), | 1529 | ), |
| 1530 | [WS_METHODS.hermesGatewayCreateEnrollment]: (input) => | ||
| 1531 | observeRpcEffect( | ||
| 1532 | WS_METHODS.hermesGatewayCreateEnrollment, | ||
| 1533 | hermesGatewayBroker.createEnrollment(input), | ||
| 1534 | { "rpc.aggregate": "hermes-gateway" }, | ||
| 1535 | ), | ||
| 1536 | [WS_METHODS.hermesGatewayGetInstanceStatus]: ({ instanceId }) => | ||
| 1537 | observeRpcEffect( | ||
| 1538 | WS_METHODS.hermesGatewayGetInstanceStatus, | ||
| 1539 | hermesGatewayBroker.getInstanceStatus(instanceId), | ||
| 1540 | { "rpc.aggregate": "hermes-gateway" }, | ||
| 1541 | ), | ||
| 1542 | [WS_METHODS.hermesGatewayListInstances]: (_input) => | ||
| 1543 | observeRpcEffect( | ||
| 1544 | WS_METHODS.hermesGatewayListInstances, | ||
| 1545 | hermesGatewayBroker.listInstances, | ||
| 1546 | { "rpc.aggregate": "hermes-gateway" }, | ||
| 1547 | ), | ||
| 1548 | [WS_METHODS.hermesGatewayRevokeInstance]: ({ instanceId }) => | ||
| 1549 | observeRpcEffect( | ||
| 1550 | WS_METHODS.hermesGatewayRevokeInstance, | ||
| 1551 | hermesGatewayBroker.revokeInstance(instanceId), | ||
| 1552 | { "rpc.aggregate": "hermes-gateway" }, | ||
| 1553 | ), | ||
| 1524 | [WS_METHODS.serverDiscoverSourceControl]: (_input) => | 1554 | [WS_METHODS.serverDiscoverSourceControl]: (_input) => |
| 1525 | observeRpcEffect( | 1555 | observeRpcEffect( |
| 1526 | WS_METHODS.serverDiscoverSourceControl, | 1556 | WS_METHODS.serverDiscoverSourceControl, |
| @@ -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 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; | 75 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 76 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; | 76 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 77 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | 77 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 78 | import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; | 78 | import { |
| 79 | ComposerPrimaryActions, | ||
| 80 | shouldUseCollapsedHermesRunningActions, | ||
| 81 | } from "./ComposerPrimaryActions"; | ||
| 79 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; | 82 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; |
| 80 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | 83 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| 81 | import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; | 84 | import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; |
| @@ -171,9 +174,10 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ · 89 unmodified lines | |||
| 171 | import { | 174 | import { |
| 172 | applyProviderInstanceSettings, | 175 | applyProviderInstanceSettings, |
| 173 | deriveProviderInstanceEntries, | 176 | deriveProviderInstanceEntries, |
| 174 | NO_PROVIDER_MODEL_SELECTION, | 177 | getProviderInstanceComposerUnavailableMessage, |
| 178 | isProviderInstanceComposerAvailable, | ||
| 179 | resolveComposerProviderInstanceId, | ||
| 175 | resolveProviderDriverKindForInstanceSelection, | 180 | resolveProviderDriverKindForInstanceSelection, |
| 176 | resolveSelectableProviderInstanceEntry, | ||
| 177 | sortProviderInstanceEntries, | 181 | sortProviderInstanceEntries, |
| 178 | type ProviderInstanceEntry, | 182 | type ProviderInstanceEntry, |
| 179 | } from "../../providerInstances"; | 183 | } from "../../providerInstances"; |
| @@ -412,6 +416,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 232 unmodified lines | |||
| 412 | isComplete: boolean; | 416 | isComplete: boolean; |
| 413 | } | null; | 417 | } | null; |
| 414 | isRunning: boolean; | 418 | isRunning: boolean; |
| 419 | allowSendWhileRunning: boolean; | ||
| 415 | showPlanFollowUpPrompt: boolean; | 420 | showPlanFollowUpPrompt: boolean; |
| 416 | promptHasText: boolean; | 421 | promptHasText: boolean; |
| 417 | isSendBusy: boolean; | 422 | isSendBusy: boolean; |
| @@ -438,6 +443,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 20 unmodified lines | |||
| 438 | compact={props.compact} | 443 | compact={props.compact} |
| 439 | pendingAction={props.pendingAction} | 444 | pendingAction={props.pendingAction} |
| 440 | isRunning={props.isRunning} | 445 | isRunning={props.isRunning} |
| 446 | allowSendWhileRunning={props.allowSendWhileRunning} | ||
| 441 | showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} | 447 | showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} |
| 442 | promptHasText={props.promptHasText} | 448 | promptHasText={props.promptHasText} |
| 443 | isSendBusy={props.isSendBusy} | 449 | isSendBusy={props.isSendBusy} |
| @@ -776,52 +782,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 332 unmodified lines | |||
| 776 | 782 | ||
| 777 | // Resolve which configured instance the composer is currently targeting. | 783 | // Resolve which configured instance the composer is currently targeting. |
| 778 | // Priority: | 784 | // Priority: |
| 779 | // 1. The composer draft's `activeProvider` — the user's unsaved 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 | // from the model picker (must win, otherwise the UI appears to | 789 | // from the model picker (must win, otherwise the UI appears to |
| 781 | // ignore picker selections). | 790 | // ignore picker selections). |
| 782 | // 2. Thread's persisted instance id (server-side saved selection). | 791 | // 3. Thread's persisted instance id (server-side saved selection). |
| 783 | // 3. Project default's instance id. | 792 | // 4. Project default's instance id. |
| 784 | // 4. First enabled entry matching the current driver kind. | 793 | // 5. First enabled entry matching the current driver kind. |
| 785 | // 5. First enabled entry overall / default instance for the kind. | 794 | // 6. First enabled entry overall / default instance for the kind. |
| 786 | // | 795 | // |
| 787 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { | 796 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { |
| 788 | const candidates: Array<string | null | undefined> = [ | 797 | return resolveComposerProviderInstanceId({ |
| 789 | composerDraft.activeProvider, | 798 | entries: providerInstanceEntries, |
| 790 | activeThread?.session?.providerInstanceId, | 799 | draftInstanceId: composerDraft.activeProvider ?? null, |
| 791 | activeThreadModelSelection?.instanceId, | 800 | threadInstanceId: activeThread?.session?.providerInstanceId, |
| 792 | activeProjectDefaultModelSelection?.instanceId, | 801 | threadModelInstanceId: activeThreadModelSelection?.instanceId, |
| 793 | ]; | 802 | projectInstanceId: activeProjectDefaultModelSelection?.instanceId, |
| 794 | for (const candidate of candidates) { | 803 | requestedDriverKind, |
| 795 | if (!candidate) continue; | 804 | lockedProvider, |
| 796 | const match = providerInstanceEntries.find( | 805 | lockedContinuationGroupKey, |
| 797 | (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, | 806 | }); |
| 798 | ); | ||
| 799 | if (match) { | ||
| 800 | // When locked to a specific driver kind, ignore persisted instance | ||
| 801 | // ids from a different kind or continuation group. | ||
| 802 | if (lockedProvider && match.driverKind !== lockedProvider) continue; | ||
| 803 | if ( | ||
| 804 | lockedContinuationGroupKey && | ||
| 805 | match.continuationGroupKey !== lockedContinuationGroupKey | ||
| 806 | ) { | ||
| 807 | continue; | ||
| 808 | } | ||
| 809 | return match.instanceId; | ||
| 810 | } | ||
| 811 | } | ||
| 812 | const compatibleEntries = providerInstanceEntries.filter( | ||
| 813 | (entry) => | ||
| 814 | (!lockedProvider || entry.driverKind === lockedProvider) && | ||
| 815 | (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), | ||
| 816 | ); | ||
| 817 | const requestedDriverEntries = compatibleEntries.filter( | ||
| 818 | (entry) => entry.driverKind === requestedDriverKind, | ||
| 819 | ); | ||
| 820 | return ( | ||
| 821 | resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? | ||
| 822 | resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? | ||
| 823 | NO_PROVIDER_MODEL_SELECTION.instanceId | ||
| 824 | ); | ||
| 825 | }, [ | 807 | }, [ |
| 826 | activeProjectDefaultModelSelection?.instanceId, | 808 | activeProjectDefaultModelSelection?.instanceId, |
| 827 | activeThread?.session?.providerInstanceId, | 809 | activeThread?.session?.providerInstanceId, |
| @@ -840,12 +822,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines | |||
| 840 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), | 822 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), |
| 841 | [providerInstanceEntries, selectedInstanceId], | 823 | [providerInstanceEntries, selectedInstanceId], |
| 842 | ); | 824 | ); |
| 843 | const noProviderAvailable = selectedProviderEntry === undefined; | 825 | const noProviderAvailable = |
| 826 | selectedProviderEntry === undefined || | ||
| 827 | !isProviderInstanceComposerAvailable(selectedProviderEntry); | ||
| 828 | const missingBoundHermesInstanceId = | ||
| 829 | selectedProviderEntry === undefined && lockedProvider === "hermes" | ||
| 830 | ? (activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId) | ||
| 831 | : undefined; | ||
| 832 | const providerUnavailableMessage = getProviderInstanceComposerUnavailableMessage( | ||
| 833 | selectedProviderEntry, | ||
| 834 | missingBoundHermesInstanceId | ||
| 835 | ? { | ||
| 836 | driverKind: ProviderDriverKind.make("hermes"), | ||
| 837 | instanceId: missingBoundHermesInstanceId, | ||
| 838 | } | ||
| 839 | : undefined, | ||
| 840 | ); | ||
| 844 | // The driver kind follows the instance that will actually run the turn, | 841 | // The driver kind follows the instance that will actually run the turn, |
| 845 | // which can differ from the persisted selection when that selection is | 842 | // which can differ from the persisted selection when that selection is |
| 846 | // disabled. | 843 | // disabled. |
| 847 | const selectedProvider: ProviderDriverKind = | 844 | const selectedProvider: ProviderDriverKind = |
| 848 | selectedProviderEntry?.driverKind ?? requestedDriverKind; | 845 | selectedProviderEntry?.driverKind ?? requestedDriverKind; |
| 846 | const allowHermesSteering = phase === "running" && selectedProvider === "hermes"; | ||
| 849 | 847 | ||
| 850 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | 848 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 851 | threadRef: composerDraftTarget, | 849 | threadRef: composerDraftTarget, |
| @@ -1216,7 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 364 unmodified lines | |||
| 1216 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], | 1214 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], |
| 1217 | ); | 1215 | ); |
| 1218 | const collapsedComposerPrimaryActionDisabled = | 1216 | const collapsedComposerPrimaryActionDisabled = |
| 1219 | phase === "running" || | 1217 | (phase === "running" && !allowHermesSteering) || |
| 1220 | isSendBusy || | 1218 | isSendBusy || |
| 1221 | isConnecting || | 1219 | isConnecting || |
| 1222 | noProviderAvailable || | 1220 | noProviderAvailable || |
| @@ -1765,7 +1763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 542 unmodified lines | |||
| 1765 | isConnecting || | 1763 | isConnecting || |
| 1766 | noProviderAvailable || | 1764 | noProviderAvailable || |
| 1767 | environmentUnavailable !== null || | 1765 | environmentUnavailable !== null || |
| 1768 | phase === "running" | 1766 | (phase === "running" && !allowHermesSteering) |
| 1769 | ) { | 1767 | ) { |
| 1770 | return false; | 1768 | return false; |
| 1771 | } | 1769 | } |
| @@ -1783,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines | |||
| 1783 | isSendBusy, | 1781 | isSendBusy, |
| 1784 | noProviderAvailable, | 1782 | noProviderAvailable, |
| 1785 | phase, | 1783 | phase, |
| 1784 | allowHermesSteering, | ||
| 1786 | showPlanFollowUpPrompt, | 1785 | showPlanFollowUpPrompt, |
| 1787 | ]); | 1786 | ]); |
| 1788 | 1787 | ||
| @@ -2368,29 +2367,56 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 579 unmodified lines | |||
| 2368 | ? activePendingProgress.customAnswer || | 2367 | ? activePendingProgress.customAnswer || |
| 2369 | "Type your own answer, or leave this blank to use the selected option" | 2368 | "Type your own answer, or leave this blank to use the selected option" |
| 2370 | : prompt.trim() || | 2369 | : prompt.trim() || |
| 2371 | (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} | 2370 | (noProviderAvailable ? providerUnavailableMessage : "Ask anything...")} |
| 2372 | </button> | ||
| 2373 | <button | ||
| 2374 | type="button" | ||
| 2375 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" | ||
| 2376 | disabled={collapsedComposerPrimaryActionDisabled} | ||
| 2377 | aria-label={collapsedComposerPrimaryActionLabel} | ||
| 2378 | onPointerDown={(event) => event.preventDefault()} | ||
| 2379 | onClick={(event) => { | ||
| 2380 | event.stopPropagation(); | ||
| 2381 | submitComposer(); | ||
| 2382 | }} | ||
| 2383 | > | ||
| 2384 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | ||
| 2385 | <path | ||
| 2386 | d="M8 3L8 13M8 3L4 7M8 3L12 7" | ||
| 2387 | stroke="currentColor" | ||
| 2388 | strokeWidth="2" | ||
| 2389 | strokeLinecap="round" | ||
| 2390 | strokeLinejoin="round" | ||
| 2391 | /> | ||
| 2392 | </svg> | ||
| 2393 | </button> | 2371 | </button> |
| 2372 | {shouldUseCollapsedHermesRunningActions({ | ||
| 2373 | isRunning: phase === "running", | ||
| 2374 | allowSendWhileRunning: allowHermesSteering, | ||
| 2375 | }) ? ( | ||
| 2376 | <ComposerPrimaryActions | ||
| 2377 | compact | ||
| 2378 | pendingAction={null} | ||
| 2379 | isRunning | ||
| 2380 | allowSendWhileRunning | ||
| 2381 | showPlanFollowUpPrompt={false} | ||
| 2382 | promptHasText={prompt.trim().length > 0} | ||
| 2383 | isSendBusy={isSendBusy} | ||
| 2384 | isConnecting={isConnecting} | ||
| 2385 | isEnvironmentUnavailable={ | ||
| 2386 | environmentUnavailable !== null || | ||
| 2387 | noProviderAvailable || | ||
| 2388 | projectSelectionRequired | ||
| 2389 | } | ||
| 2390 | isPreparingWorktree={false} | ||
| 2391 | hasSendableContent={composerSendState.hasSendableContent} | ||
| 2392 | preserveComposerFocusOnPointerDown | ||
| 2393 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | ||
| 2394 | onInterrupt={handleInterruptPrimaryAction} | ||
| 2395 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | ||
| 2396 | /> | ||
| 2397 | ) : ( | ||
| 2398 | <button | ||
| 2399 | type="button" | ||
| 2400 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" | ||
| 2401 | disabled={collapsedComposerPrimaryActionDisabled} | ||
| 2402 | aria-label={collapsedComposerPrimaryActionLabel} | ||
| 2403 | onPointerDown={(event) => event.preventDefault()} | ||
| 2404 | onClick={(event) => { | ||
| 2405 | event.stopPropagation(); | ||
| 2406 | submitComposer(); | ||
| 2407 | }} | ||
| 2408 | > | ||
| 2409 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | ||
| 2410 | <path | ||
| 2411 | d="M8 3L8 13M8 3L4 7M8 3L12 7" | ||
| 2412 | stroke="currentColor" | ||
| 2413 | strokeWidth="2" | ||
| 2414 | strokeLinecap="round" | ||
| 2415 | strokeLinejoin="round" | ||
| 2416 | /> | ||
| 2417 | </svg> | ||
| 2418 | </button> | ||
| 2419 | )} | ||
| 2394 | </div> | 2420 | </div> |
| 2395 | ) : null} | 2421 | ) : null} |
| 2396 | 2422 | ||
| @@ -2575,7 +2601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 178 unmodified lines | |||
| 2575 | : projectSelectionRequired | 2601 | : projectSelectionRequired |
| 2576 | ? "Choose a project above to start a thread" | 2602 | ? "Choose a project above to start a thread" |
| 2577 | : noProviderAvailable | 2603 | : noProviderAvailable |
| 2578 | ? "Enable a provider in Settings to send a message" | 2604 | ? providerUnavailableMessage |
| 2579 | : phase === "disconnected" | 2605 | : phase === "disconnected" |
| 2580 | ? "Ask for follow-up changes or attach images" | 2606 | ? "Ask for follow-up changes or attach images" |
| 2581 | : "Ask anything, @tag files/folders, $use skills, or / for commands" | 2607 | : "Ask anything, @tag files/folders, $use skills, or / for commands" |
| @@ -2643,7 +2669,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 61 unmodified lines | |||
| 2643 | className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" | 2669 | className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" |
| 2644 | > | 2670 | > |
| 2645 | <CircleAlertIcon className="size-4" /> | 2671 | <CircleAlertIcon className="size-4" /> |
| 2646 | No provider available | 2672 | {providerUnavailableMessage} |
| 2647 | </Button> | 2673 | </Button> |
| 2648 | ) : ( | 2674 | ) : ( |
| 2649 | <ProviderModelPicker | 2675 | <ProviderModelPicker |
| @@ -2721,6 +2747,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 71 unmodified lines | |||
| 2721 | activeThreadProviderDisplayName={activeThreadProviderDisplayName} | 2747 | activeThreadProviderDisplayName={activeThreadProviderDisplayName} |
| 2722 | pendingAction={pendingPrimaryAction} | 2748 | pendingAction={pendingPrimaryAction} |
| 2723 | isRunning={phase === "running"} | 2749 | isRunning={phase === "running"} |
| 2750 | allowSendWhileRunning={allowHermesSteering} | ||
| 2724 | showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} | 2751 | showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} |
| 2725 | promptHasText={prompt.trim().length > 0} | 2752 | promptHasText={prompt.trim().length > 0} |
| 2726 | isSendBusy={isSendBusy} | 2753 | isSendBusy={isSendBusy} |
| @@ -1,6 +1,45 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("formatPendingPrimaryActionLabel", () => { | 44 | describe("formatPendingPrimaryActionLabel", () => { |
| 6 | it("returns 'Submitting...' while responding", () => { | 45 | it("returns 'Submitting...' while responding", () => { |
| @@ -18,6 +18,7 @@ interface ComposerPrimaryActionsProps { · 17 unmodified lines | |||
| 18 | compact: boolean; | 18 | compact: boolean; |
| 19 | pendingAction: PendingActionState | null; | 19 | pendingAction: PendingActionState | null; |
| 20 | isRunning: boolean; | 20 | isRunning: boolean; |
| 21 | allowSendWhileRunning?: boolean; | ||
| 21 | showPlanFollowUpPrompt: boolean; | 22 | showPlanFollowUpPrompt: boolean; |
| 22 | promptHasText: boolean; | 23 | promptHasText: boolean; |
| 23 | isSendBusy: boolean; | 24 | isSendBusy: boolean; |
| @@ -49,6 +50,21 @@ export const formatPendingPrimaryActionLabel = (input: { · 25 unmodified lines | |||
| 49 | return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; | 50 | return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; |
| 50 | }; | 51 | }; |
| 51 | 52 | ||
| 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 | const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => { | 68 | const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => { |
| 53 | event.preventDefault(); | 69 | event.preventDefault(); |
| 54 | }; | 70 | }; |
| @@ -57,6 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 2 unmodified lines | |||
| 57 | compact, | 73 | compact, |
| 58 | pendingAction, | 74 | pendingAction, |
| 59 | isRunning, | 75 | isRunning, |
| 76 | allowSendWhileRunning = false, | ||
| 60 | showPlanFollowUpPrompt, | 77 | showPlanFollowUpPrompt, |
| 61 | promptHasText, | 78 | promptHasText, |
| 62 | isSendBusy, | 79 | isSendBusy, |
| @@ -125,7 +142,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 62 unmodified lines | |||
| 125 | ); | 142 | ); |
| 126 | } | 143 | } |
| 127 | 144 | ||
| 128 | if (isRunning) { | 145 | const runningMode = resolveRunningPrimaryActionMode({ isRunning, allowSendWhileRunning }); |
| 146 | if (runningMode === "stop") { | ||
| 129 | return ( | 147 | return ( |
| 130 | <button | 148 | <button |
| 131 | type="button" | 149 | type="button" |
| @@ -141,6 +159,41 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines | |||
| 141 | ); | 159 | ); |
| 142 | } | 160 | } |
| 143 | 161 | ||
| 162 | if (runningMode === "steer") { | ||
| 163 | return ( | ||
| 164 | <div className={cn("flex items-center justify-end", compact ? "gap-1" : "gap-2")}> | ||
| 165 | <button | ||
| 166 | type="submit" | ||
| 167 | className="flex size-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground shadow-xs transition-all enabled:cursor-pointer hover:bg-primary disabled:pointer-events-none disabled:opacity-30" | ||
| 168 | {...pointerFocusProps} | ||
| 169 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} | ||
| 170 | aria-label="Send guidance to running Hermes turn" | ||
| 171 | > | ||
| 172 | <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> | ||
| 173 | <path | ||
| 174 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | ||
| 175 | stroke="currentColor" | ||
| 176 | strokeWidth="1.8" | ||
| 177 | strokeLinecap="round" | ||
| 178 | strokeLinejoin="round" | ||
| 179 | /> | ||
| 180 | </svg> | ||
| 181 | </button> | ||
| 182 | <button | ||
| 183 | type="button" | ||
| 184 | className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 transition-all hover:bg-destructive" | ||
| 185 | {...pointerFocusProps} | ||
| 186 | onClick={onInterrupt} | ||
| 187 | aria-label="Stop generation" | ||
| 188 | > | ||
| 189 | <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"> | ||
| 190 | <rect x="2" y="2" width="8" height="8" rx="1.5" /> | ||
| 191 | </svg> | ||
| 192 | </button> | ||
| 193 | </div> | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 144 | if (showPlanFollowUpPrompt) { | 197 | if (showPlanFollowUpPrompt) { |
| 145 | if (promptHasText) { | 198 | if (promptHasText) { |
| 146 | return ( | 199 | return ( |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 2 | import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; | 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; |
| 4 | import { HermesIcon } from "../HermesIcon"; | ||
| 4 | 5 | ||
| 5 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { | 6 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 6 | [ProviderDriverKind.make("codex")]: OpenAI, | 7 | [ProviderDriverKind.make("codex")]: OpenAI, |
| @@ -8,6 +9,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> · 1 unmodified line | |||
| 8 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, | 9 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, |
| 9 | [ProviderDriverKind.make("cursor")]: CursorIcon, | 10 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 10 | [ProviderDriverKind.make("grok")]: GrokIcon, | 11 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 12 | [ProviderDriverKind.make("hermes")]: HermesIcon, | ||
| 11 | }; | 13 | }; |
| 12 | 14 | ||
| 13 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | 15 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
| @@ -1,11 +1,50 @@ | |||
| 1 | import type { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | |||
| 1 | export type WizardNavigation = | 3 | export type WizardNavigation = |
| 2 | | { readonly kind: "navigate"; readonly step: number } | 4 | | { readonly kind: "navigate"; readonly step: number } |
| 3 | | { readonly kind: "blocked"; readonly step: number; readonly error: string }; | 5 | | { readonly kind: "blocked"; readonly step: number; readonly error: string }; |
| 4 | 6 | ||
| 5 | const IDENTITY_STEP = 1; | 7 | const IDENTITY_STEP = 1; |
| 8 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | ||
| 6 | 9 | ||
| 7 | export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; | 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 | * Resolve navigation within the add-provider wizard. | 49 | * Resolve navigation within the add-provider wizard. |
| 11 | * | 50 | * |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("resolveWizardNavigation", () => { | 11 | describe("resolveWizardNavigation", () => { |
| 6 | const invalidId = { instanceIdError: "Instance ID is required." }; | 12 | const invalidId = { instanceIdError: "Instance ID is required." }; |
| @@ -42,3 +48,52 @@ describe("resolveWizardNavigation", () => { · 35 unmodified lines | |||
| 42 | expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); | 48 | expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); |
| 43 | }); | 49 | }); |
| 44 | }); | 50 | }); |
| 51 | |||
| 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 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | 3 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 4 | import { CheckIcon } from "lucide-react"; | 4 | import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react"; |
| 5 | import { useMemo, useState } from "react"; | 5 | import { useEffect, useMemo, useState } from "react"; |
| 6 | import { | 6 | import { |
| 7 | type HermesGatewayEnrollmentResult, | ||
| 7 | ProviderInstanceId, | 8 | ProviderInstanceId, |
| 8 | ProviderDriverKind, | 9 | ProviderDriverKind, |
| 9 | type ProviderInstanceConfig, | 10 | type ProviderInstanceConfig, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 11 | 13 | ||
| 14 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 12 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 15 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 13 | import { cn } from "../../lib/utils"; | 16 | import { cn } from "../../lib/utils"; |
| 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 17 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| @@ -31,10 +34,20 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe · 16 unmodified lines | |||
| 31 | import { AnimatedHeight } from "../AnimatedHeight"; | 34 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 32 | import { | 35 | import { |
| 33 | ADD_PROVIDER_WIZARD_STEPS, | 36 | ADD_PROVIDER_WIZARD_STEPS, |
| 37 | isOwnedHermesEnrollmentRetry, | ||
| 34 | resolveWizardNavigation, | 38 | resolveWizardNavigation, |
| 35 | type WizardNavigation, | 39 | type WizardNavigation, |
| 40 | validateProviderInstanceIdForWizard, | ||
| 36 | } from "./AddProviderInstanceDialog.logic"; | 41 | } from "./AddProviderInstanceDialog.logic"; |
| 37 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; | 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 | const PROVIDER_ACCENT_SWATCHES = [ | 52 | const PROVIDER_ACCENT_SWATCHES = [ |
| 40 | "#2563eb", | 53 | "#2563eb", |
| @@ -63,10 +76,12 @@ function slugifyLabel(value: string): string { · 22 unmodified lines | |||
| 63 | 76 | ||
| 64 | function deriveInstanceId(driver: ProviderDriverKind, label: string): string { | 77 | function deriveInstanceId(driver: ProviderDriverKind, label: string): string { |
| 65 | const slug = slugifyLabel(label); | 78 | const slug = slugifyLabel(label); |
| 79 | if (driver === "hermes") { | ||
| 80 | return slug ? `hermes-${slug.replaceAll("_", "-")}` : ""; | ||
| 81 | } | ||
| 66 | return slug ? `${driver}_${slug}` : ""; | 82 | return slug ? `${driver}_${slug}` : ""; |
| 67 | } | 83 | } |
| 68 | 84 | ||
| 69 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | ||
| 70 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | 85 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 71 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; | 86 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; |
| 72 | const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {}; | 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 | interface AddProviderInstanceDialogProps { | 117 | interface AddProviderInstanceDialogProps { |
| 118 | open: boolean; | 118 | open: boolean; |
| 119 | onOpenChange: (open: boolean) => void; | 119 | onOpenChange: (open: boolean) => void; |
| @@ -122,6 +122,13 @@ interface AddProviderInstanceDialogProps { · 2 unmodified lines | |||
| 122 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { | 122 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { |
| 123 | const settings = usePrimarySettings(); | 123 | const settings = usePrimarySettings(); |
| 124 | const updateSettings = useUpdatePrimarySettings(); | 124 | const updateSettings = useUpdatePrimarySettings(); |
| 125 | const environmentId = usePrimaryEnvironment()?.environmentId ?? null; | ||
| 126 | const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, { | ||
| 127 | reportFailure: false, | ||
| 128 | }); | ||
| 129 | const createHermesEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, { | ||
| 130 | reportFailure: false, | ||
| 131 | }); | ||
| 125 | 132 | ||
| 126 | const [wizardStep, setWizardStep] = useState(0); | 133 | const [wizardStep, setWizardStep] = useState(0); |
| 127 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); | 134 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); |
| @@ -134,6 +141,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines | |||
| 134 | // Errors are suppressed until the user has tried to submit once. After that | 141 | // Errors are suppressed until the user has tried to submit once. After that |
| 135 | // they update live so fixing the problem clears the message in place. | 142 | // they update live so fixing the problem clears the message in place. |
| 136 | const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); | 143 | const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); |
| 144 | const [hermesConnectorUrl, setHermesConnectorUrl] = useState(() => | ||
| 145 | typeof window === "undefined" | ||
| 146 | ? "http://localhost/api/hermes-gateway/ws" | ||
| 147 | : defaultHermesConnectorUrl(window.location.origin), | ||
| 148 | ); | ||
| 149 | const [hermesEnrollment, setHermesEnrollment] = useState<HermesGatewayEnrollmentResult | null>( | ||
| 150 | null, | ||
| 151 | ); | ||
| 152 | const [saveError, setSaveError] = useState<string | null>(null); | ||
| 153 | const [isSaving, setIsSaving] = useState(false); | ||
| 154 | const [createdHermesIdentity, setCreatedHermesIdentity] = useState<{ | ||
| 155 | readonly instanceId: string; | ||
| 156 | readonly nickname: string; | ||
| 157 | } | null>(null); | ||
| 158 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | ||
| 159 | target: "Hermes enrollment command", | ||
| 160 | onCopy: () => | ||
| 161 | toastManager.add({ | ||
| 162 | type: "success", | ||
| 163 | title: "Hermes command copied", | ||
| 164 | description: "Run it in the terminal where Hermes is installed.", | ||
| 165 | }), | ||
| 166 | }); | ||
| 167 | |||
| 168 | useEffect(() => { | ||
| 169 | if (open) return; | ||
| 170 | setHermesEnrollment(null); | ||
| 171 | setSaveError(null); | ||
| 172 | setIsSaving(false); | ||
| 173 | setCreatedHermesIdentity(null); | ||
| 174 | }, [open]); | ||
| 137 | 175 | ||
| 138 | const existingIds = useMemo( | 176 | const existingIds = useMemo( |
| 139 | () => new Set(Object.keys(settings.providerInstances ?? {})), | 177 | () => new Set(Object.keys(settings.providerInstances ?? {})), |
| @@ -146,7 +184,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines | |||
| 146 | () => deriveProviderSettingsFields(driverOption), | 184 | () => deriveProviderSettingsFields(driverOption), |
| 147 | [driverOption], | 185 | [driverOption], |
| 148 | ); | 186 | ); |
| 149 | const instanceIdError = validateInstanceId(instanceId, existingIds); | 187 | const instanceIdError = |
| 188 | driver === "hermes" && label.trim().length === 0 | ||
| 189 | ? "Hermes nickname is required." | ||
| 190 | : validateProviderInstanceIdForWizard({ | ||
| 191 | driver, | ||
| 192 | instanceId, | ||
| 193 | existingIds, | ||
| 194 | createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, | ||
| 195 | }); | ||
| 150 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; | 196 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; |
| 151 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; | 197 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 152 | const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; | 198 | const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; |
| @@ -179,7 +225,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 26 unmodified lines | |||
| 179 | ); | 225 | ); |
| 180 | }; | 226 | }; |
| 181 | 227 | ||
| 182 | const handleSave = () => { | 228 | const handleSave = async () => { |
| 183 | setHasAttemptedSubmit(true); | 229 | setHasAttemptedSubmit(true); |
| 184 | if (instanceIdError !== null) return; | 230 | if (instanceIdError !== null) return; |
| 185 | 231 | ||
| @@ -195,15 +241,60 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 9 unmodified lines | |||
| 195 | ...(hasConfig ? { config } : {}), | 241 | ...(hasConfig ? { config } : {}), |
| 196 | }; | 242 | }; |
| 197 | // `ProviderInstanceId.make` revalidates the slug; we've already checked | 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 | // keeps the type boundary honest and guards against any future drift in | 245 | // keeps the type boundary honest and guards against any future drift in |
| 200 | // the slug rules. | 246 | // the slug rules. |
| 201 | const brandedId = ProviderInstanceId.make(instanceId); | 247 | const brandedId = ProviderInstanceId.make(instanceId); |
| 248 | const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({ | ||
| 249 | driver, | ||
| 250 | instanceId, | ||
| 251 | createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, | ||
| 252 | }); | ||
| 202 | const nextMap = { | 253 | const nextMap = { |
| 203 | ...settings.providerInstances, | 254 | ...settings.providerInstances, |
| 204 | [brandedId]: nextInstance, | 255 | [brandedId]: nextInstance, |
| 205 | }; | 256 | }; |
| 206 | try { | 257 | try { |
| 258 | setIsSaving(true); | ||
| 259 | setSaveError(null); | ||
| 260 | if (driver === "hermes") { | ||
| 261 | if (environmentId === null) { | ||
| 262 | throw new Error("Connect this browser to a T3 server before pairing Hermes."); | ||
| 263 | } | ||
| 264 | if (!isOwnedHermesRetry) { | ||
| 265 | const settingsResult = await updateServerSettings({ | ||
| 266 | environmentId, | ||
| 267 | input: { patch: { providerInstances: nextMap } }, | ||
| 268 | }); | ||
| 269 | if (settingsResult._tag === "Failure") { | ||
| 270 | throw squashAtomCommandFailure(settingsResult); | ||
| 271 | } | ||
| 272 | setCreatedHermesIdentity({ | ||
| 273 | instanceId, | ||
| 274 | nickname: label.trim(), | ||
| 275 | }); | ||
| 276 | } | ||
| 277 | const enrollmentResult = await createHermesEnrollment({ | ||
| 278 | environmentId, | ||
| 279 | input: { | ||
| 280 | instanceId: brandedId, | ||
| 281 | nickname: createdHermesIdentity?.nickname ?? label.trim(), | ||
| 282 | connectorUrl: hermesConnectorUrl, | ||
| 283 | }, | ||
| 284 | }); | ||
| 285 | if (enrollmentResult._tag === "Failure") { | ||
| 286 | throw squashAtomCommandFailure(enrollmentResult); | ||
| 287 | } | ||
| 288 | setHermesEnrollment(enrollmentResult.value); | ||
| 289 | toastManager.add({ | ||
| 290 | type: "success", | ||
| 291 | title: "Hermes instance added", | ||
| 292 | description: "Run the one-time command to connect it.", | ||
| 293 | }); | ||
| 294 | setIsSaving(false); | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | |||
| 207 | updateSettings({ providerInstances: nextMap }); | 298 | updateSettings({ providerInstances: nextMap }); |
| 208 | toastManager.add({ | 299 | toastManager.add({ |
| 209 | type: "success", | 300 | type: "success", |
| @@ -212,11 +303,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 2 unmodified lines | |||
| 212 | }); | 303 | }); |
| 213 | onOpenChange(false); | 304 | onOpenChange(false); |
| 214 | } catch (error) { | 305 | } catch (error) { |
| 306 | const message = messageFromUnknownError(error); | ||
| 307 | setSaveError(message); | ||
| 215 | toastManager.add({ | 308 | toastManager.add({ |
| 216 | type: "error", | 309 | type: "error", |
| 217 | title: "Could not add provider instance", | 310 | title: "Could not add provider instance", |
| 218 | description: error instanceof Error ? error.message : "Update failed.", | 311 | description: message, |
| 219 | }); | 312 | }); |
| 313 | } finally { | ||
| 314 | setIsSaving(false); | ||
| 220 | } | 315 | } |
| 221 | }; | 316 | }; |
| 222 | 317 | ||
| @@ -230,203 +325,280 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 7 unmodified lines | |||
| 230 | Configure an additional provider instance — for example, a second Codex install | 325 | Configure an additional provider instance — for example, a second Codex install |
| 231 | pointed at a different workspace. | 326 | pointed at a different workspace. |
| 232 | </DialogDescription> | 327 | </DialogDescription> |
| 233 | <AddProviderInstanceWizardSteps | 328 | {hermesEnrollment === null && createdHermesIdentity === null ? ( |
| 234 | currentStep={wizardStep} | 329 | <AddProviderInstanceWizardSteps |
| 235 | summaries={wizardStepSummaries} | 330 | currentStep={wizardStep} |
| 236 | instanceIdError={instanceIdError} | 331 | summaries={wizardStepSummaries} |
| 237 | onNavigation={applyWizardNavigation} | 332 | instanceIdError={instanceIdError} |
| 238 | /> | 333 | onNavigation={applyWizardNavigation} |
| 334 | /> | ||
| 335 | ) : null} | ||
| 239 | </DialogHeader> | 336 | </DialogHeader> |
| 240 | 337 | ||
| 241 | <div | 338 | <div |
| 242 | data-slot="dialog-panel" | 339 | data-slot="dialog-panel" |
| 243 | className="space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5" | 340 | className="space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5" |
| 244 | > | 341 | > |
| 245 | <AnimatedHeight> | 342 | {hermesEnrollment ? ( |
| 246 | <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> | 343 | <div className="grid gap-3"> |
| 247 | <div id="add-instance-driver-label" className="text-sm font-medium text-foreground"> | 344 | <div> |
| 248 | Driver | 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 | </div> | 350 | </div> |
| 250 | <RadioGroup | 351 | <div className="flex min-w-0 items-center gap-2"> |
| 251 | value={driver} | 352 | <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-2 text-[11px]"> |
| 252 | onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | 353 | {hermesEnrollment.command} |
| 253 | aria-labelledby="add-instance-driver-label" | 354 | </code> |
| 254 | className="grid grid-cols-1 gap-2 sm:grid-cols-2" | 355 | <Button |
| 255 | > | 356 | type="button" |
| 256 | {DRIVER_OPTIONS.map((option) => { | 357 | size="icon-sm" |
| 257 | const IconComponent = option.icon; | 358 | variant="outline" |
| 258 | return ( | 359 | onClick={() => copyToClipboard(hermesEnrollment.command, undefined)} |
| 259 | <RadioPrimitive.Root | 360 | aria-label="Copy Hermes enrollment command" |
| 260 | key={option.value} | 361 | > |
| 261 | value={option.value} | 362 | {isCopied ? <CheckIcon /> : <CopyIcon />} |
| 262 | className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15" | 363 | </Button> |
| 263 | > | 364 | </div> |
| 264 | <IconComponent className="size-4 shrink-0" aria-hidden /> | 365 | <p className="text-[11px] text-muted-foreground"> |
| 265 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | 366 | The persistent gateway credential is delivered directly to the plugin and is never |
| 266 | {option.label} | 367 | shown in T3 Code. |
| 267 | </span> | 368 | </p> |
| 268 | <RadioPrimitive.Indicator | 369 | </div> |
| 269 | className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground" | 370 | ) : ( |
| 270 | aria-hidden | 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" /> | 393 | <IconComponent className="size-4 shrink-0" aria-hidden /> |
| 273 | </RadioPrimitive.Indicator> | 394 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> |
| 274 | {option.badgeLabel ? ( | 395 | {option.label} |
| 396 | </span> | ||
| 397 | <RadioPrimitive.Indicator | ||
| 398 | className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground" | ||
| 399 | aria-hidden | ||
| 400 | > | ||
| 401 | <CheckIcon className="size-3.5 shrink-0" /> | ||
| 402 | </RadioPrimitive.Indicator> | ||
| 403 | {option.badgeLabel ? ( | ||
| 404 | <Badge variant="warning" size="sm"> | ||
| 405 | {option.badgeLabel} | ||
| 406 | </Badge> | ||
| 407 | ) : null} | ||
| 408 | </RadioPrimitive.Root> | ||
| 409 | ); | ||
| 410 | })} | ||
| 411 | {COMING_SOON_DRIVER_OPTIONS.map((option) => { | ||
| 412 | const IconComponent = option.icon; | ||
| 413 | return ( | ||
| 414 | <RadioPrimitive.Root | ||
| 415 | key={option.value} | ||
| 416 | value={option.value} | ||
| 417 | disabled | ||
| 418 | className={cn( | ||
| 419 | "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", | ||
| 420 | )} | ||
| 421 | > | ||
| 422 | <IconComponent | ||
| 423 | className="size-4 shrink-0 text-muted-foreground" | ||
| 424 | aria-hidden | ||
| 425 | /> | ||
| 426 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 427 | {option.label} | ||
| 428 | </span> | ||
| 275 | <Badge variant="warning" size="sm"> | 429 | <Badge variant="warning" size="sm"> |
| 276 | {option.badgeLabel} | 430 | Coming Soon |
| 277 | </Badge> | 431 | </Badge> |
| 278 | ) : null} | 432 | </RadioPrimitive.Root> |
| 279 | </RadioPrimitive.Root> | 433 | ); |
| 280 | ); | 434 | })} |
| 281 | })} | 435 | </RadioGroup> |
| 282 | {COMING_SOON_DRIVER_OPTIONS.map((option) => { | 436 | </div> |
| 283 | const IconComponent = option.icon; | ||
| 284 | return ( | ||
| 285 | <RadioPrimitive.Root | ||
| 286 | key={option.value} | ||
| 287 | value={option.value} | ||
| 288 | disabled | ||
| 289 | className={cn( | ||
| 290 | "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5", | ||
| 291 | )} | ||
| 292 | > | ||
| 293 | <IconComponent | ||
| 294 | className="size-4 shrink-0 text-muted-foreground" | ||
| 295 | aria-hidden | ||
| 296 | /> | ||
| 297 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 298 | {option.label} | ||
| 299 | </span> | ||
| 300 | <Badge variant="warning" size="sm"> | ||
| 301 | Coming Soon | ||
| 302 | </Badge> | ||
| 303 | </RadioPrimitive.Root> | ||
| 304 | ); | ||
| 305 | })} | ||
| 306 | </RadioGroup> | ||
| 307 | </div> | ||
| 308 | |||
| 309 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 310 | <span className="text-xs font-medium text-foreground">Label</span> | ||
| 311 | <Input | ||
| 312 | className="bg-background" | ||
| 313 | placeholder="e.g. Work" | ||
| 314 | value={label} | ||
| 315 | onChange={(event) => setLabel(event.target.value)} | ||
| 316 | /> | ||
| 317 | <span className="text-[11px] text-muted-foreground"> | ||
| 318 | Shown in the provider list. Optional. | ||
| 319 | </span> | ||
| 320 | </label> | ||
| 321 | 437 | ||
| 322 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | 438 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> |
| 323 | <span className="text-xs font-medium text-foreground">Instance ID</span> | 439 | <span className="text-xs font-medium text-foreground">Label</span> |
| 324 | <Input | 440 | <Input |
| 325 | className="bg-background" | 441 | className="bg-background" |
| 326 | placeholder={`${driver}_work`} | 442 | placeholder="e.g. Work" |
| 327 | value={instanceId} | 443 | value={label} |
| 328 | onChange={(event) => { | 444 | onChange={(event) => setLabel(event.target.value)} |
| 329 | setInstanceIdOverride(event.target.value); | 445 | /> |
| 330 | }} | ||
| 331 | aria-invalid={showInstanceIdError} | ||
| 332 | /> | ||
| 333 | {showInstanceIdError ? ( | ||
| 334 | <span className="text-[11px] text-destructive">{instanceIdError}</span> | ||
| 335 | ) : ( | ||
| 336 | <span className="text-[11px] text-muted-foreground"> | 446 | <span className="text-[11px] text-muted-foreground"> |
| 337 | Routing key used by threads and sessions. Letters, digits, '-', or '_'. | 447 | Shown in the provider list. Optional. |
| 338 | </span> | 448 | </span> |
| 339 | )} | 449 | </label> |
| 340 | </label> | ||
| 341 | 450 | ||
| 342 | <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | 451 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> |
| 343 | <span className="text-xs font-medium text-foreground">Accent color</span> | 452 | <span className="text-xs font-medium text-foreground">Instance ID</span> |
| 344 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | 453 | <Input |
| 345 | <input | 454 | className="bg-background" |
| 346 | type="color" | 455 | placeholder={`${driver}_work`} |
| 347 | value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]} | 456 | value={instanceId} |
| 348 | onChange={(event) => setAccentColor(event.target.value)} | 457 | onChange={(event) => { |
| 349 | aria-label="Provider instance accent color" | 458 | setInstanceIdOverride(event.target.value); |
| 350 | className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | 459 | }} |
| 460 | aria-invalid={showInstanceIdError} | ||
| 351 | /> | 461 | /> |
| 352 | <div className="flex flex-wrap gap-1.5"> | 462 | {showInstanceIdError ? ( |
| 353 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | 463 | <span className="text-[11px] text-destructive">{instanceIdError}</span> |
| 354 | const selected = accentColor.toLowerCase() === swatch; | 464 | ) : ( |
| 355 | return ( | 465 | <span className="text-[11px] text-muted-foreground"> |
| 356 | <button | 466 | Routing key used by threads and sessions. Letters, digits, '-', or '_'. |
| 357 | key={swatch} | 467 | </span> |
| 358 | type="button" | 468 | )} |
| 359 | className={cn( | 469 | </label> |
| 360 | "size-6 cursor-pointer rounded-full border transition", | 470 | |
| 361 | selected | 471 | <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> |
| 362 | ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | 472 | <span className="text-xs font-medium text-foreground">Accent color</span> |
| 363 | : "border-black/10 hover:scale-105 dark:border-white/20", | 473 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 364 | )} | 474 | <input |
| 365 | style={{ backgroundColor: swatch }} | 475 | type="color" |
| 366 | onClick={() => setAccentColor(swatch)} | 476 | value={ |
| 367 | aria-label={`Use ${swatch} accent`} | 477 | normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0] |
| 368 | /> | 478 | } |
| 369 | ); | 479 | onChange={(event) => setAccentColor(event.target.value)} |
| 370 | })} | 480 | aria-label="Provider instance accent color" |
| 481 | className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | ||
| 482 | /> | ||
| 483 | <div className="flex flex-wrap gap-1.5"> | ||
| 484 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | ||
| 485 | const selected = accentColor.toLowerCase() === swatch; | ||
| 486 | return ( | ||
| 487 | <button | ||
| 488 | key={swatch} | ||
| 489 | type="button" | ||
| 490 | className={cn( | ||
| 491 | "size-6 cursor-pointer rounded-full border transition", | ||
| 492 | selected | ||
| 493 | ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | ||
| 494 | : "border-black/10 hover:scale-105 dark:border-white/20", | ||
| 495 | )} | ||
| 496 | style={{ backgroundColor: swatch }} | ||
| 497 | onClick={() => setAccentColor(swatch)} | ||
| 498 | aria-label={`Use ${swatch} accent`} | ||
| 499 | /> | ||
| 500 | ); | ||
| 501 | })} | ||
| 502 | </div> | ||
| 503 | {accentColor ? ( | ||
| 504 | <Button | ||
| 505 | type="button" | ||
| 506 | size="sm" | ||
| 507 | variant="ghost" | ||
| 508 | className="h-7 px-2 text-xs text-muted-foreground" | ||
| 509 | onClick={() => setAccentColor("")} | ||
| 510 | > | ||
| 511 | Clear | ||
| 512 | </Button> | ||
| 513 | ) : null} | ||
| 371 | </div> | 514 | </div> |
| 372 | {accentColor ? ( | 515 | <span className="text-[11px] text-muted-foreground"> |
| 373 | <Button | 516 | Optional marker shown in the picker. |
| 374 | type="button" | 517 | </span> |
| 375 | size="sm" | ||
| 376 | variant="ghost" | ||
| 377 | className="h-7 px-2 text-xs text-muted-foreground" | ||
| 378 | onClick={() => setAccentColor("")} | ||
| 379 | > | ||
| 380 | Clear | ||
| 381 | </Button> | ||
| 382 | ) : null} | ||
| 383 | </div> | 518 | </div> |
| 384 | <span className="text-[11px] text-muted-foreground"> | ||
| 385 | Optional marker shown in the picker. | ||
| 386 | </span> | ||
| 387 | </div> | ||
| 388 | 519 | ||
| 389 | {driverSettingsFields.length > 0 ? ( | 520 | {driver === "hermes" && wizardStep === 2 ? ( |
| 390 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | 521 | <label className="grid gap-2"> |
| 391 | <ProviderSettingsForm | 522 | <span className="text-xs font-medium text-foreground">Connector URL</span> |
| 392 | definition={driverOption} | 523 | <Input |
| 393 | value={configDraft} | 524 | className="bg-background" |
| 394 | idPrefix={`add-provider-${driver}`} | 525 | value={hermesConnectorUrl} |
| 395 | variant="dialog" | 526 | onChange={(event) => setHermesConnectorUrl(event.target.value)} |
| 396 | onChange={setConfigDraft} | 527 | placeholder="wss://t3.example.com/api/hermes-gateway/ws" |
| 397 | /> | 528 | spellCheck={false} |
| 398 | </div> | 529 | /> |
| 399 | ) : wizardStep === 2 ? ( | 530 | <span className="text-[11px] text-muted-foreground"> |
| 400 | <div className="grid gap-2"> | 531 | Defaults to this browser's origin and works over Tailscale or any |
| 401 | <p className="text-sm text-muted-foreground"> | 532 | reachable network. |
| 402 | This driver has no required configuration. You can add the instance now. | 533 | </span> |
| 534 | </label> | ||
| 535 | ) : driverSettingsFields.length > 0 ? ( | ||
| 536 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | ||
| 537 | <ProviderSettingsForm | ||
| 538 | definition={driverOption} | ||
| 539 | value={configDraft} | ||
| 540 | idPrefix={`add-provider-${driver}`} | ||
| 541 | variant="dialog" | ||
| 542 | onChange={setConfigDraft} | ||
| 543 | /> | ||
| 544 | </div> | ||
| 545 | ) : wizardStep === 2 ? ( | ||
| 546 | <div className="grid gap-2"> | ||
| 547 | <p className="text-sm text-muted-foreground"> | ||
| 548 | This driver has no required configuration. You can add the instance now. | ||
| 549 | </p> | ||
| 550 | </div> | ||
| 551 | ) : null} | ||
| 552 | {saveError ? <p className="text-xs text-destructive">{saveError}</p> : null} | ||
| 553 | {createdHermesIdentity ? ( | ||
| 554 | <p className="text-xs text-muted-foreground"> | ||
| 555 | The Hermes instance was added. Retry enrollment for this exact instance, or | ||
| 556 | close this dialog and finish pairing from its Settings card. | ||
| 403 | </p> | 557 | </p> |
| 404 | </div> | 558 | ) : null} |
| 405 | ) : null} | 559 | </AnimatedHeight> |
| 406 | </AnimatedHeight> | 560 | )} |
| 407 | </div> | 561 | </div> |
| 408 | 562 | ||
| 409 | <DialogFooter variant="bare"> | 563 | <DialogFooter variant="bare"> |
| 410 | <Button | 564 | {hermesEnrollment ? ( |
| 411 | variant="outline" | 565 | <Button size="sm" onClick={() => onOpenChange(false)}> |
| 412 | size="sm" | 566 | Done |
| 413 | onClick={() => { | 567 | </Button> |
| 414 | if (wizardStep === 0) { | 568 | ) : ( |
| 415 | onOpenChange(false); | 569 | <Button |
| 416 | return; | 570 | variant="outline" |
| 417 | } | 571 | size="sm" |
| 418 | setWizardStep((step) => Math.max(0, step - 1)); | 572 | onClick={() => { |
| 419 | }} | 573 | if (createdHermesIdentity) { |
| 420 | > | 574 | onOpenChange(false); |
| 421 | {wizardStep === 0 ? "Cancel" : "Back"} | 575 | return; |
| 422 | </Button> | 576 | } |
| 423 | {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( | 577 | if (wizardStep === 0) { |
| 578 | onOpenChange(false); | ||
| 579 | return; | ||
| 580 | } | ||
| 581 | setWizardStep((step) => Math.max(0, step - 1)); | ||
| 582 | }} | ||
| 583 | disabled={isSaving} | ||
| 584 | > | ||
| 585 | {createdHermesIdentity ? "Close" : wizardStep === 0 ? "Cancel" : "Back"} | ||
| 586 | </Button> | ||
| 587 | )} | ||
| 588 | {hermesEnrollment ? null : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( | ||
| 424 | <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}> | 589 | <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}> |
| 425 | Next | 590 | Next |
| 426 | </Button> | 591 | </Button> |
| 427 | ) : ( | 592 | ) : ( |
| 428 | <Button size="sm" onClick={handleSave}> | 593 | <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> |
| 429 | Add instance | 594 | {isSaving ? <LoaderIcon className="animate-spin" /> : null} |
| 595 | {isSaving | ||
| 596 | ? createdHermesIdentity | ||
| 597 | ? "Retrying" | ||
| 598 | : "Adding" | ||
| 599 | : createdHermesIdentity | ||
| 600 | ? "Retry enrollment" | ||
| 601 | : "Add instance"} | ||
| 430 | </Button> | 602 | </Button> |
| 431 | )} | 603 | )} |
| 432 | </DialogFooter> | 604 | </DialogFooter> |
| @@ -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 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | 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 | describe("deriveProviderModelsForDisplay", () => { | 9 | describe("deriveProviderModelsForDisplay", () => { |
| 7 | it("uses current config custom models instead of stale live custom rows", () => { | 10 | it("uses current config custom models instead of stale live custom rows", () => { |
| @@ -34,3 +37,11 @@ describe("deriveProviderModelsForDisplay", () => { · 26 unmodified lines | |||
| 34 | ).toEqual(["server-model", "kept-custom"]); | 37 | ).toEqual(["server-model", "kept-custom"]); |
| 35 | }); | 38 | }); |
| 36 | }); | 39 | }); |
| 40 | |||
| 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 | import * as Arr from "effect/Array"; | 13 | import * as Arr from "effect/Array"; |
| 14 | import * as Result from "effect/Result"; | 14 | import * as Result from "effect/Result"; |
| 15 | import { useState, type ReactNode } from "react"; | 15 | import { useState, type ReactNode } from "react"; |
| 16 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 16 | import { | 17 | import { |
| 17 | isProviderDriverKind, | 18 | isProviderDriverKind, |
| 18 | type ProviderInstanceConfig, | 19 | type ProviderInstanceConfig, |
| @@ -26,6 +27,9 @@ import { · 7 unmodified lines | |||
| 26 | import { cn } from "../../lib/utils"; | 27 | import { cn } from "../../lib/utils"; |
| 27 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 28 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 28 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 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 | import { Badge } from "../ui/badge"; | 33 | import { Badge } from "../ui/badge"; |
| 30 | import { Button } from "../ui/button"; | 34 | import { Button } from "../ui/button"; |
| 31 | import { Checkbox } from "../ui/checkbox"; | 35 | import { Checkbox } from "../ui/checkbox"; |
| @@ -43,6 +47,8 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 11 unmodified lines | |||
| 43 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 47 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 44 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | 48 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 45 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 49 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 50 | import { HermesGatewayInstanceSection } from "./HermesGatewayInstanceSection"; | ||
| 51 | import { messageFromUnknownError } from "./HermesGatewayInstanceSection.logic"; | ||
| 46 | import { | 52 | import { |
| 47 | getProviderVersionAdvisoryPresentation, | 53 | getProviderVersionAdvisoryPresentation, |
| 48 | PROVIDER_STATUS_STYLES, | 54 | PROVIDER_STATUS_STYLES, |
| @@ -131,6 +137,10 @@ export function deriveProviderModelsForDisplay(input: { · 82 unmodified lines | |||
| 131 | return [...serverModels, ...customModels]; | 137 | return [...serverModels, ...customModels]; |
| 132 | } | 138 | } |
| 133 | 139 | ||
| 140 | export function isProviderInstanceDisplayNameEditable(driver: string): boolean { | ||
| 141 | return driver !== "hermes"; | ||
| 142 | } | ||
| 143 | |||
| 134 | function ProviderAuthEmail(props: { | 144 | function ProviderAuthEmail(props: { |
| 135 | readonly email: string | undefined; | 145 | readonly email: string | undefined; |
| 136 | readonly prefix?: string; | 146 | readonly prefix?: string; |
| @@ -394,6 +404,11 @@ export function ProviderInstanceCard({ · 257 unmodified lines | |||
| 394 | onRunUpdate, | 404 | onRunUpdate, |
| 395 | isUpdating = false, | 405 | isUpdating = false, |
| 396 | }: ProviderInstanceCardProps) { | 406 | }: ProviderInstanceCardProps) { |
| 407 | const environmentId = usePrimaryEnvironment()?.environmentId ?? null; | ||
| 408 | const revokeHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { | ||
| 409 | reportFailure: false, | ||
| 410 | }); | ||
| 411 | const [isDeleting, setIsDeleting] = useState(false); | ||
| 397 | const enabled = instance.enabled ?? true; | 412 | const enabled = instance.enabled ?? true; |
| 398 | // The server-reported status wins when present; otherwise fall back to | 413 | // The server-reported status wins when present; otherwise fall back to |
| 399 | // "disabled"/"warning" based on the local `enabled` flag so the dot | 414 | // "disabled"/"warning" based on the local `enabled` flag so the dot |
| @@ -413,8 +428,21 @@ export function ProviderInstanceCard({ · 13 unmodified lines | |||
| 413 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); | 428 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); |
| 414 | const updateCommand = versionAdvisory?.updateCommand ?? null; | 429 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 415 | const FallbackIconComponent = driverOption?.icon; | 430 | const FallbackIconComponent = driverOption?.icon; |
| 416 | const displayName = | 431 | const configuredDisplayName = instance.displayName?.trim(); |
| 417 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); | 432 | const isHermes = instance.driver === "hermes"; |
| 433 | const hermesNickname = | ||
| 434 | configuredDisplayName?.replace(/^Hermes\s*[·:–—-]?\s*/u, "").trim() || | ||
| 435 | (instanceId === "hermes" | ||
| 436 | ? "Hermes" | ||
| 437 | : String(instanceId) | ||
| 438 | .replace(/^hermes[-_]?/u, "") | ||
| 439 | .replace(/[-_]+/g, " ") | ||
| 440 | .trim()); | ||
| 441 | const displayName = isHermes | ||
| 442 | ? instanceId === "hermes" || hermesNickname === "Hermes" | ||
| 443 | ? "Hermes" | ||
| 444 | : `Hermes · ${hermesNickname}` | ||
| 445 | : configuredDisplayName || driverOption?.label || String(instance.driver); | ||
| 418 | const accentColor = normalizeProviderAccentColor(instance.accentColor); | 446 | const accentColor = normalizeProviderAccentColor(instance.accentColor); |
| 419 | const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ | 447 | const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ |
| 420 | onCopy: ({ providerName }) => { | 448 | onCopy: ({ providerName }) => { |
| @@ -501,6 +529,49 @@ export function ProviderInstanceCard({ · 80 unmodified lines | |||
| 501 | ); | 529 | ); |
| 502 | }; | 530 | }; |
| 503 | 531 | ||
| 532 | const handleDelete = async () => { | ||
| 533 | if (!onDelete || isDeleting) return; | ||
| 534 | if (!isHermes) { | ||
| 535 | onDelete(); | ||
| 536 | return; | ||
| 537 | } | ||
| 538 | if (environmentId === null) { | ||
| 539 | toastManager.add({ | ||
| 540 | type: "error", | ||
| 541 | title: "Could not delete Hermes instance", | ||
| 542 | description: "Reconnect this browser to the T3 server so its gateway can be revoked first.", | ||
| 543 | }); | ||
| 544 | return; | ||
| 545 | } | ||
| 546 | setIsDeleting(true); | ||
| 547 | const result = await revokeHermesInstance({ | ||
| 548 | environmentId, | ||
| 549 | input: { instanceId }, | ||
| 550 | }); | ||
| 551 | if (result._tag === "Success") { | ||
| 552 | onDelete(); | ||
| 553 | setIsDeleting(false); | ||
| 554 | return; | ||
| 555 | } | ||
| 556 | const failure = squashAtomCommandFailure(result); | ||
| 557 | const wasNeverEnrolled = | ||
| 558 | typeof failure === "object" && | ||
| 559 | failure !== null && | ||
| 560 | "code" in failure && | ||
| 561 | failure.code === "instance-not-found"; | ||
| 562 | if (wasNeverEnrolled) { | ||
| 563 | onDelete(); | ||
| 564 | setIsDeleting(false); | ||
| 565 | return; | ||
| 566 | } | ||
| 567 | toastManager.add({ | ||
| 568 | type: "error", | ||
| 569 | title: "Could not delete Hermes instance", | ||
| 570 | description: `${messageFromUnknownError(failure)} The instance was kept so a live gateway credential is not orphaned.`, | ||
| 571 | }); | ||
| 572 | setIsDeleting(false); | ||
| 573 | }; | ||
| 574 | |||
| 504 | const titleIconNode = driverKind ? ( | 575 | const titleIconNode = driverKind ? ( |
| 505 | <ProviderInstanceIcon | 576 | <ProviderInstanceIcon |
| 506 | driverKind={driverKind} | 577 | driverKind={driverKind} |
| @@ -563,10 +634,15 @@ export function ProviderInstanceCard({ · 56 unmodified lines | |||
| 563 | size="icon-xs" | 634 | size="icon-xs" |
| 564 | variant="ghost" | 635 | variant="ghost" |
| 565 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" | 636 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" |
| 566 | onClick={onDelete} | 637 | disabled={isDeleting} |
| 638 | onClick={() => void handleDelete()} | ||
| 567 | aria-label={`Delete provider instance ${instanceId}`} | 639 | aria-label={`Delete provider instance ${instanceId}`} |
| 568 | > | 640 | > |
| 569 | <Trash2Icon className="size-3" /> | 641 | {isDeleting ? ( |
| 642 | <LoaderIcon className="size-3 animate-spin" /> | ||
| 643 | ) : ( | ||
| 644 | <Trash2Icon className="size-3" /> | ||
| 645 | )} | ||
| 570 | </Button> | 646 | </Button> |
| 571 | } | 647 | } |
| 572 | /> | 648 | /> |
| @@ -730,22 +806,38 @@ export function ProviderInstanceCard({ · 157 unmodified lines | |||
| 730 | <Collapsible open={isExpanded} onOpenChange={onExpandedChange}> | 806 | <Collapsible open={isExpanded} onOpenChange={onExpandedChange}> |
| 731 | <CollapsibleContent> | 807 | <CollapsibleContent> |
| 732 | <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4"> | 808 | <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4"> |
| 733 | <div> | 809 | {isProviderInstanceDisplayNameEditable(String(instance.driver)) ? ( |
| 734 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | 810 | <div> |
| 735 | <span className="text-xs font-medium text-foreground">Display name</span> | 811 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> |
| 736 | <DraftInput | 812 | <span className="text-xs font-medium text-foreground">Display name</span> |
| 737 | id={`provider-instance-${instanceId}-display-name`} | 813 | <DraftInput |
| 738 | className="mt-1.5" | 814 | id={`provider-instance-${instanceId}-display-name`} |
| 739 | value={instance.displayName ?? ""} | 815 | className="mt-1.5" |
| 740 | onCommit={updateDisplayName} | 816 | value={instance.displayName ?? ""} |
| 741 | placeholder={driverOption?.label ?? "Instance label"} | 817 | onCommit={updateDisplayName} |
| 742 | spellCheck={false} | 818 | placeholder={driverOption?.label ?? "Instance label"} |
| 743 | /> | 819 | spellCheck={false} |
| 820 | /> | ||
| 821 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 822 | Optional label shown in the provider list. | ||
| 823 | </span> | ||
| 824 | </label> | ||
| 825 | </div> | ||
| 826 | ) : ( | ||
| 827 | <div> | ||
| 828 | <span className="text-xs font-medium text-foreground">Hermes nickname</span> | ||
| 829 | <div | ||
| 830 | className="mt-1.5 rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-sm text-foreground" | ||
| 831 | data-hermes-nickname-readonly="true" | ||
| 832 | > | ||
| 833 | {displayName} | ||
| 834 | </div> | ||
| 744 | <span className="mt-1 block text-xs text-muted-foreground"> | 835 | <span className="mt-1 block text-xs text-muted-foreground"> |
| 745 | Optional label shown in the provider list. | 836 | Fixed after creation because this nickname identifies the paired Hermes instance. |
| 837 | Delete and re-add the instance to choose a different nickname. | ||
| 746 | </span> | 838 | </span> |
| 747 | </label> | 839 | </div> |
| 748 | </div> | 840 | )} |
| 749 | 841 | ||
| 750 | <div> | 842 | <div> |
| 751 | <ProviderAccentColorPicker | 843 | <ProviderAccentColorPicker |
| @@ -757,14 +849,18 @@ export function ProviderInstanceCard({ · 5 unmodified lines | |||
| 757 | /> | 849 | /> |
| 758 | </div> | 850 | </div> |
| 759 | 851 | ||
| 760 | <div> | 852 | {isHermes ? ( |
| 761 | <ProviderEnvironmentSection | 853 | <HermesGatewayInstanceSection instanceId={instanceId} nickname={hermesNickname} /> |
| 762 | environment={instance.environment ?? []} | 854 | ) : ( |
| 763 | onChange={updateEnvironment} | 855 | <div> |
| 764 | /> | 856 | <ProviderEnvironmentSection |
| 765 | </div> | 857 | environment={instance.environment ?? []} |
| 858 | onChange={updateEnvironment} | ||
| 859 | /> | ||
| 860 | </div> | ||
| 861 | )} | ||
| 766 | 862 | ||
| 767 | {driverOption ? ( | 863 | {driverOption && !isHermes ? ( |
| 768 | <ProviderSettingsForm | 864 | <ProviderSettingsForm |
| 769 | definition={driverOption} | 865 | definition={driverOption} |
| 770 | value={instance.config} | 866 | value={instance.config} |
| @@ -774,7 +870,7 @@ export function ProviderInstanceCard({ · 3 unmodified lines | |||
| 774 | /> | 870 | /> |
| 775 | ) : null} | 871 | ) : null} |
| 776 | 872 | ||
| 777 | {driverOption !== undefined ? ( | 873 | {driverOption !== undefined && !isHermes ? ( |
| 778 | <ProviderModelsSection | 874 | <ProviderModelsSection |
| 779 | instanceId={instanceId} | 875 | instanceId={instanceId} |
| 780 | driverKind={driverKind} | 876 | driverKind={driverKind} |
| @@ -788,7 +884,7 @@ export function ProviderInstanceCard({ · 7 unmodified lines | |||
| 788 | onFavoriteModelsChange={onFavoriteModelsChange} | 884 | onFavoriteModelsChange={onFavoriteModelsChange} |
| 789 | onModelOrderChange={onModelOrderChange} | 885 | onModelOrderChange={onModelOrderChange} |
| 790 | /> | 886 | /> |
| 791 | ) : ( | 887 | ) : driverOption === undefined ? ( |
| 792 | <div> | 888 | <div> |
| 793 | <p className="text-xs text-muted-foreground"> | 889 | <p className="text-xs text-muted-foreground"> |
| 794 | This instance uses a driver ( | 890 | This instance uses a driver ( |
| @@ -797,7 +893,7 @@ export function ProviderInstanceCard({ · 2 unmodified lines | |||
| 797 | edited from this surface. | 893 | edited from this surface. |
| 798 | </p> | 894 | </p> |
| 799 | </div> | 895 | </div> |
| 800 | )} | 896 | ) : null} |
| 801 | </div> | 897 | </div> |
| 802 | </CollapsibleContent> | 898 | </CollapsibleContent> |
| 803 | </Collapsible> | 899 | </Collapsible> |
| @@ -3,11 +3,13 @@ import { · 2 unmodified lines | |||
| 3 | CodexSettings, | 3 | CodexSettings, |
| 4 | CursorSettings, | 4 | CursorSettings, |
| 5 | GrokSettings, | 5 | GrokSettings, |
| 6 | HermesSettings, | ||
| 6 | OpenCodeSettings, | 7 | OpenCodeSettings, |
| 7 | ProviderDriverKind, | 8 | ProviderDriverKind, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import type * as Schema from "effect/Schema"; | 10 | import type * as Schema from "effect/Schema"; |
| 10 | import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 11 | import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 12 | import { HermesIcon } from "../HermesIcon"; | ||
| 11 | 13 | ||
| 12 | type ProviderSettingsSchema = { | 14 | type ProviderSettingsSchema = { |
| 13 | readonly fields: Readonly<Record<string, Schema.Top>>; | 15 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| @@ -67,6 +69,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = · 53 unmodified lines | |||
| 67 | icon: OpenCodeIcon, | 69 | icon: OpenCodeIcon, |
| 68 | settingsSchema: OpenCodeSettings, | 70 | settingsSchema: OpenCodeSettings, |
| 69 | }, | 71 | }, |
| 72 | { | ||
| 73 | value: ProviderDriverKind.make("hermes"), | ||
| 74 | label: "Hermes", | ||
| 75 | icon: HermesIcon, | ||
| 76 | settingsSchema: HermesSettings, | ||
| 77 | }, | ||
| 70 | ]; | 78 | ]; |
| 71 | 79 | ||
| 72 | export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< | 80 | export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< |
| @@ -64,7 +64,7 @@ function readInstanceCustomModels( · 63 unmodified lines | |||
| 64 | } | 64 | } |
| 65 | const legacyProviders = settings.providers as Record< | 65 | const legacyProviders = settings.providers as Record< |
| 66 | string, | 66 | string, |
| 67 | { readonly customModels: ReadonlyArray<string> } | undefined | 67 | { readonly customModels?: ReadonlyArray<string> } | undefined |
| 68 | >; | 68 | >; |
| 69 | return legacyProviders[driverKind]?.customModels ?? []; | 69 | return legacyProviders[driverKind]?.customModels ?? []; |
| 70 | } | 70 | } |
| @@ -5,7 +5,10 @@ import { · 4 unmodified lines | |||
| 5 | deriveProviderInstanceEntries, | 5 | deriveProviderInstanceEntries, |
| 6 | getDefaultProviderInstanceModel, | 6 | getDefaultProviderInstanceModel, |
| 7 | isProviderInstancePickerReady, | 7 | isProviderInstancePickerReady, |
| 8 | isProviderInstanceComposerAvailable, | ||
| 9 | getProviderInstanceComposerUnavailableMessage, | ||
| 8 | isProviderInstancePickerVisible, | 10 | isProviderInstancePickerVisible, |
| 11 | resolveComposerProviderInstanceId, | ||
| 9 | resolveDefaultProviderModelSelection, | 12 | resolveDefaultProviderModelSelection, |
| 10 | resolveSelectableProviderInstance, | 13 | resolveSelectableProviderInstance, |
| 11 | resolveProviderDriverKindForInstanceSelection, | 14 | resolveProviderDriverKindForInstanceSelection, |
| @@ -68,6 +71,66 @@ describe("isProviderInstancePickerReady", () => { · 56 unmodified lines | |||
| 68 | }); | 71 | }); |
| 69 | }); | 72 | }); |
| 70 | 73 | ||
| 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 | describe("isProviderInstancePickerVisible", () => { | 134 | describe("isProviderInstancePickerVisible", () => { |
| 72 | it("keeps enabled instances in the rail and removes disabled instances", () => { | 135 | it("keeps enabled instances in the rail and removes disabled instances", () => { |
| 73 | const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ | 136 | const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ |
| @@ -130,6 +193,32 @@ describe("deriveProviderInstanceEntries", () => { · 56 unmodified lines | |||
| 130 | expect(entry?.driverKind).toBe("codex"); | 193 | expect(entry?.driverKind).toBe("codex"); |
| 131 | expect(entry?.isDefault).toBe(false); | 194 | expect(entry?.isDefault).toBe(false); |
| 132 | }); | 195 | }); |
| 196 | |||
| 197 | it("formats default and custom Hermes instance labels consistently", () => { | ||
| 198 | const entries = deriveProviderInstanceEntries([ | ||
| 199 | provider({ | ||
| 200 | provider: ProviderDriverKind.make("hermes"), | ||
| 201 | instanceId: "hermes", | ||
| 202 | displayName: "Hermes", | ||
| 203 | }), | ||
| 204 | provider({ | ||
| 205 | provider: ProviderDriverKind.make("hermes"), | ||
| 206 | instanceId: "hermes-research", | ||
| 207 | displayName: "Research", | ||
| 208 | }), | ||
| 209 | provider({ | ||
| 210 | provider: ProviderDriverKind.make("hermes"), | ||
| 211 | instanceId: "hermes-lab", | ||
| 212 | displayName: "Hermes · Lab", | ||
| 213 | }), | ||
| 214 | ]); | ||
| 215 | |||
| 216 | expect(entries.map((entry) => entry.displayName)).toEqual([ | ||
| 217 | "Hermes", | ||
| 218 | "Hermes · Research", | ||
| 219 | "Hermes · Lab", | ||
| 220 | ]); | ||
| 221 | }); | ||
| 133 | }); | 222 | }); |
| 134 | 223 | ||
| 135 | describe("resolveSelectableProviderInstance", () => { | 224 | describe("resolveSelectableProviderInstance", () => { |
| @@ -74,6 +74,31 @@ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boo · 73 unmodified lines | |||
| 74 | return entry.enabled && entry.isAvailable && entry.status === "ready"; | 74 | return entry.enabled && entry.isAvailable && entry.status === "ready"; |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | /** | ||
| 78 | * Existing providers retain their historical composer behavior while Hermes | ||
| 79 | * requires a live gateway connection. This keeps an offline Hermes thread | ||
| 80 | * selected and readable without allowing a turn to be routed elsewhere. | ||
| 81 | */ | ||
| 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 | /** Picker rails contain configured, enabled instances only. */ | 102 | /** Picker rails contain configured, enabled instances only. */ |
| 78 | export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { | 103 | export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { |
| 79 | return entry.enabled; | 104 | return entry.enabled; |
| @@ -139,6 +164,15 @@ function resolveInstanceDisplayName( · 59 unmodified lines | |||
| 139 | ): string { | 164 | ): string { |
| 140 | const trimmedSnapshotName = snapshot.displayName?.trim(); | 165 | const trimmedSnapshotName = snapshot.displayName?.trim(); |
| 141 | const kindLabel = driverKindLabel(driverKind); | 166 | const kindLabel = driverKindLabel(driverKind); |
| 167 | if (driverKind === "hermes") { | ||
| 168 | if (isDefault) return "Hermes"; | ||
| 169 | const candidate = | ||
| 170 | trimmedSnapshotName && trimmedSnapshotName !== kindLabel | ||
| 171 | ? trimmedSnapshotName | ||
| 172 | : humanizeInstanceId(instanceId).replace(/^Hermes(?:\s+|$)/u, ""); | ||
| 173 | const nickname = candidate.replace(/^Hermes\s*[·:–—-]\s*/u, "").trim(); | ||
| 174 | return nickname ? `Hermes · ${nickname}` : "Hermes"; | ||
| 175 | } | ||
| 142 | if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { | 176 | if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { |
| 143 | return trimmedSnapshotName; | 177 | return trimmedSnapshotName; |
| 144 | } | 178 | } |
| @@ -312,6 +346,66 @@ export function resolveSelectableProviderInstanceEntry( · 167 unmodified lines | |||
| 312 | ); | 346 | ); |
| 313 | } | 347 | } |
| 314 | 348 | ||
| 349 | /** | ||
| 350 | * Resolve the instance used by the composer while preserving an existing | ||
| 351 | * Hermes thread's immutable gateway binding. Hermes sessions cannot be moved | ||
| 352 | * to another instance, so a missing or disabled bound instance remains the | ||
| 353 | * exact selection and lets the composer surface its unavailable state instead | ||
| 354 | * of silently routing the next message to another connected Hermes process. | ||
| 355 | */ | ||
| 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 | * Resolve the routing key for a selection that may reference an instance | 410 | * Resolve the routing key for a selection that may reference an instance |
| 317 | * id that no longer exists (e.g. a persisted thread selection after the | 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 | key: ({ environmentId }) => environmentId, | 315 | key: ({ environmentId }) => environmentId, |
| 316 | }, | 316 | }, |
| 317 | }), | 317 | }), |
| 318 | hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, { | ||
| 319 | label: "environment-data:server:hermes-gateway-create-enrollment", | ||
| 320 | tag: WS_METHODS.hermesGatewayCreateEnrollment, | ||
| 321 | }), | ||
| 322 | hermesGatewayGetInstanceStatus: createEnvironmentRpcCommand(runtime, { | ||
| 323 | label: "environment-data:server:hermes-gateway-get-instance-status", | ||
| 324 | tag: WS_METHODS.hermesGatewayGetInstanceStatus, | ||
| 325 | }), | ||
| 326 | hermesGatewayListInstances: createEnvironmentRpcCommand(runtime, { | ||
| 327 | label: "environment-data:server:hermes-gateway-list-instances", | ||
| 328 | tag: WS_METHODS.hermesGatewayListInstances, | ||
| 329 | }), | ||
| 330 | hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, { | ||
| 331 | label: "environment-data:server:hermes-gateway-revoke-instance", | ||
| 332 | tag: WS_METHODS.hermesGatewayRevokeInstance, | ||
| 333 | }), | ||
| 318 | updateProvider: createEnvironmentRpcCommand(runtime, { | 334 | updateProvider: createEnvironmentRpcCommand(runtime, { |
| 319 | label: "environment-data:server:update-provider", | 335 | label: "environment-data:server:update-provider", |
| 320 | tag: WS_METHODS.serverUpdateProvider, | 336 | tag: WS_METHODS.serverUpdateProvider, |
| @@ -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 | export * from "./preview.ts"; | 27 | export * from "./preview.ts"; |
| 28 | export * from "./previewAutomation.ts"; | 28 | export * from "./previewAutomation.ts"; |
| 29 | export * from "./rpc.ts"; | 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 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); | 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); |
| 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); | 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 135 | export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); | ||
| 135 | 136 | ||
| 136 | export const DEFAULT_MODEL = "gpt-5.6-sol"; | 137 | export const DEFAULT_MODEL = "gpt-5.6-sol"; |
| 138 | export const DEFAULT_HERMES_MODEL = "hermes"; | ||
| 137 | 139 | ||
| 138 | /** | 140 | /** |
| 139 | * Codex default-model preference, most preferred first. The provider snapshot | 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 | [CURSOR_DRIVER_KIND]: "auto", | 154 | [CURSOR_DRIVER_KIND]: "auto", |
| 153 | [GROK_DRIVER_KIND]: "grok-build", | 155 | [GROK_DRIVER_KIND]: "grok-build", |
| 154 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", | 156 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", |
| 157 | [HERMES_DRIVER_KIND]: DEFAULT_HERMES_MODEL, | ||
| 155 | }; | 158 | }; |
| 156 | 159 | ||
| 157 | /** Per-provider text generation model defaults. */ | 160 | /** Per-provider text generation model defaults. */ |
| @@ -218,4 +221,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 60 unmodified lines | |||
| 218 | [CURSOR_DRIVER_KIND]: "Cursor", | 221 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 219 | [GROK_DRIVER_KIND]: "Grok", | 222 | [GROK_DRIVER_KIND]: "Grok", |
| 220 | [OPENCODE_DRIVER_KIND]: "OpenCode", | 223 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 224 | [HERMES_DRIVER_KIND]: "Hermes", | ||
| 221 | }; | 225 | }; |
| @@ -59,6 +59,16 @@ import { · 58 unmodified lines | |||
| 59 | OrchestrationRpcSchemas, | 59 | OrchestrationRpcSchemas, |
| 60 | } from "./orchestration.ts"; | 60 | } from "./orchestration.ts"; |
| 61 | import { ProviderInstanceId } from "./providerInstance.ts"; | 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 | import { | 72 | import { |
| 63 | RelayClientInstallFailedError, | 73 | RelayClientInstallFailedError, |
| 64 | RelayClientInstallProgressEventSchema, | 74 | RelayClientInstallProgressEventSchema, |
| @@ -219,6 +229,12 @@ export const WS_METHODS = { · 154 unmodified lines | |||
| 219 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", | 229 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 220 | serverSignalProcess: "server.signalProcess", | 230 | serverSignalProcess: "server.signalProcess", |
| 221 | 231 | ||
| 232 | // Hermes gateway instance management | ||
| 233 | hermesGatewayCreateEnrollment: "hermesGateway.createEnrollment", | ||
| 234 | hermesGatewayGetInstanceStatus: "hermesGateway.getInstanceStatus", | ||
| 235 | hermesGatewayListInstances: "hermesGateway.listInstances", | ||
| 236 | hermesGatewayRevokeInstance: "hermesGateway.revokeInstance", | ||
| 237 | |||
| 222 | // Cloud environment methods | 238 | // Cloud environment methods |
| 223 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", | 239 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", |
| 224 | cloudInstallRelayClient: "cloud.installRelayClient", | 240 | cloudInstallRelayClient: "cloud.installRelayClient", |
| @@ -334,6 +350,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, · 109 unmodified lines | |||
| 334 | error: EnvironmentAuthorizationError, | 350 | error: EnvironmentAuthorizationError, |
| 335 | }); | 351 | }); |
| 336 | 352 | ||
| 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 | export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { | 383 | export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { |
| 338 | payload: Schema.Struct({}), | 384 | payload: Schema.Struct({}), |
| 339 | success: RelayClientStatusSchema, | 385 | success: RelayClientStatusSchema, |
| @@ -713,6 +759,10 @@ export const WsRpcGroup = RpcGroup.make( · 373 unmodified lines | |||
| 713 | WsServerGetProcessDiagnosticsRpc, | 759 | WsServerGetProcessDiagnosticsRpc, |
| 714 | WsServerGetProcessResourceHistoryRpc, | 760 | WsServerGetProcessResourceHistoryRpc, |
| 715 | WsServerSignalProcessRpc, | 761 | WsServerSignalProcessRpc, |
| 762 | WsHermesGatewayCreateEnrollmentRpc, | ||
| 763 | WsHermesGatewayGetInstanceStatusRpc, | ||
| 764 | WsHermesGatewayListInstancesRpc, | ||
| 765 | WsHermesGatewayRevokeInstanceRpc, | ||
| 716 | WsCloudGetRelayClientStatusRpc, | 766 | WsCloudGetRelayClientStatusRpc, |
| 717 | WsCloudInstallRelayClientRpc, | 767 | WsCloudInstallRelayClientRpc, |
| 718 | WsSourceControlLookupRepositoryRpc, | 768 | WsSourceControlLookupRepositoryRpc, |
| @@ -389,6 +389,19 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 388 unmodified lines | |||
| 389 | ); | 389 | ); |
| 390 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; | 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 | export const ObservabilitySettings = Schema.Struct({ | 405 | export const ObservabilitySettings = Schema.Struct({ |
| 393 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 406 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 394 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 407 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| @@ -433,6 +446,7 @@ export const ServerSettings = Schema.Struct({ · 38 unmodified lines | |||
| 433 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 446 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 434 | grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 447 | grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 435 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 448 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 449 | hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | ||
| 436 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 450 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 437 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values | 451 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 438 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob | 452 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob |
| @@ -536,6 +550,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 97 unmodified lines | |||
| 536 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 550 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 537 | }); | 551 | }); |
| 538 | 552 | ||
| 553 | const HermesSettingsPatch = Schema.Struct({ | ||
| 554 | enabled: Schema.optionalKey(Schema.Boolean), | ||
| 555 | }); | ||
| 556 | |||
| 539 | export const ServerSettingsPatch = Schema.Struct({ | 557 | export const ServerSettingsPatch = Schema.Struct({ |
| 540 | // Server settings | 558 | // Server settings |
| 541 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | 559 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| @@ -558,6 +576,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 16 unmodified lines | |||
| 558 | cursor: Schema.optionalKey(CursorSettingsPatch), | 576 | cursor: Schema.optionalKey(CursorSettingsPatch), |
| 559 | grok: Schema.optionalKey(GrokSettingsPatch), | 577 | grok: Schema.optionalKey(GrokSettingsPatch), |
| 560 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), | 578 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), |
| 579 | hermes: Schema.optionalKey(HermesSettingsPatch), | ||
| 561 | }), | 580 | }), |
| 562 | ), | 581 | ), |
| 563 | // Whole-map replacement for the new instance config. Patching individual | 582 | // Whole-map replacement for the new instance config. Patching individual |