feat(gateway): generate shared TypeScript contracts

Define gateway payloads as Python TypedDict contracts and generate the shared
TypeScript surface through ts-type during shared, TUI, and desktop workflows.
This commit is contained in:
ethernet 2026-07-23 01:09:34 -04:00
parent 75afaf46da
commit 2cdfe35429
12 changed files with 166 additions and 49 deletions

View file

@ -44,6 +44,17 @@ jobs:
with:
node-version: 22
cache: npm
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install Python development dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --extra dev
- uses: ./.github/actions/retry
with:
command: npm ci

1
.gitignore vendored
View file

@ -90,6 +90,7 @@ apps/desktop/src/**/*.d.ts
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/shared/src/generated/
apps/desktop/release/
*.tsbuildinfo

View file

@ -11,6 +11,7 @@
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"predev": "npm run generate:gateway-types --prefix ../shared",
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
"clean:renderer":"tsc --build tsconfig.json --clean ",
@ -23,7 +24,7 @@
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"prebuild": "npm run clean",
"prebuild": "npm run generate:gateway-types --prefix ../shared && npm run clean",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
@ -45,7 +46,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"typecheck": "npm run generate:gateway-types --prefix ../shared && tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",

View file

@ -1,3 +1,5 @@
import type { GatewaySessionRuntimeInfo } from '@hermes/shared/gateway-contracts'
export interface ConfigFieldSchema {
category?: string
description?: string
@ -470,27 +472,7 @@ export interface SessionResumeResponse {
status?: string
}
export interface SessionRuntimeInfo {
approval_mode?: 'manual' | 'off' | 'smart'
branch?: string
config_warning?: string
credential_warning?: string
cwd?: string
desktop_contract?: number
fast?: boolean
install_warning?: string
model?: string
personality?: string
provider?: string
reasoning_effort?: string
running?: boolean
service_tier?: string
skills?: Record<string, string[]> | string[]
tools?: Record<string, string[]>
usage?: Partial<UsageStats>
version?: string
yolo?: boolean
}
export type SessionRuntimeInfo = GatewaySessionRuntimeInfo
export interface UsageStats {
calls: number

View file

@ -8,14 +8,16 @@
"./billing": "./src/billing-types.ts",
"./billing-policy": "./src/billing-policy.ts",
"./charge-settlement": "./src/charge-settlement.ts",
"./gateway-contracts": "./src/generated/gateway.ts",
"./skin": "./src/skin.ts"
},
"types": "./src/index.ts",
"scripts": {
"generate:gateway-types": "uv run --locked --extra dev python ../../scripts/generate_gateway_types.py",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix",
"typecheck": "tsc -p . --noEmit",
"typecheck": "npm run generate:gateway-types && tsc -p . --noEmit",
"check": "npm run typecheck"
},
"devDependencies": {

View file

@ -156,7 +156,7 @@ edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82)
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ts-type==0.3.4", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82)
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"]

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Generate TypeScript gateway contracts from ``tui_gateway.contracts``."""
from __future__ import annotations
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
import ts_type as ts
from tui_gateway.contracts import (
GatewayMcpServerStatus,
GatewayProjectInfo,
GatewaySessionRuntimeInfo,
GatewayUsage,
)
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "apps/shared/src/generated/gateway.ts"
def main() -> None:
for contract in (
GatewayMcpServerStatus,
GatewayProjectInfo,
GatewayUsage,
GatewaySessionRuntimeInfo,
):
ts.generator.add(contract, "gateway", contract.__name__)
source = ts.generator.render()["gateway"]
output = "// Generated by scripts/generate_gateway_types.py. Do not edit.\n\n" + source + "\n"
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
# Workspace checks may run concurrently. Replacing an already-complete
# temporary file means a typecheck always observes either version, never a
# partially-written module.
with NamedTemporaryFile("w", encoding="utf-8", dir=OUTPUT.parent, delete=False) as file:
file.write(output)
temp_path = Path(file.name)
os.replace(temp_path, OUTPUT)
if __name__ == "__main__":
main()

72
tui_gateway/contracts.py Normal file
View file

@ -0,0 +1,72 @@
"""Type-only JSON contracts shared by the TUI gateway and TypeScript clients.
These ``TypedDict`` definitions describe the wire payloads without changing the
runtime's deliberately lightweight plain-dict serialization. ``ts-type``
generates the matching TypeScript definitions during frontend checks.
"""
from typing import Literal, TypedDict
class GatewayMcpServerStatusBase(TypedDict):
connected: bool
name: str
tools: int
transport: str
class GatewayMcpServerStatus(GatewayMcpServerStatusBase, total=False):
disabled: bool
status: str
class GatewayProjectInfoBase(TypedDict):
id: str
name: str
slug: str
class GatewayProjectInfo(GatewayProjectInfoBase, total=False):
primary_path: str | None
class GatewayUsage(TypedDict, total=False):
calls: int
context_max: int
context_percent: int
context_used: int
cost_usd: float
input: int
output: int
total: int
class GatewaySessionRuntimeInfo(TypedDict, total=False):
approval_mode: Literal["manual", "off", "smart"]
branch: str
config_warning: str
credential_warning: str
cwd: str
desktop_contract: int
fast: bool
install_warning: str
mcp_servers: list[GatewayMcpServerStatus]
model: str
personality: str
profile_name: str
project: GatewayProjectInfo | None
provider: str
reasoning_effort: str
release_date: str
running: bool
service_tier: str
skills: dict[str, list[str]] | list[str]
stored_session_id: str
system_prompt: str
title: str
tools: dict[str, list[str]]
update_behind: int | None
update_command: str
usage: GatewayUsage
version: str
yolo: bool

View file

@ -14,9 +14,10 @@ import sys
import threading
import time
import uuid
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any, NamedTuple, Optional
from typing import Any, NamedTuple, Optional, cast
from hermes_constants import (
get_hermes_home,
@ -29,6 +30,7 @@ from utils import is_truthy_value
from tools.environments.local import hermes_subprocess_env
from agent.replay_cleanup import sanitize_replay_history
from tui_gateway import git_probe
from tui_gateway.contracts import GatewaySessionRuntimeInfo
from tui_gateway.transport import (
StdioTransport,
Transport,
@ -1212,14 +1214,14 @@ def write_json(obj: dict) -> bool:
return (current_transport() or _stdio_transport).write(obj)
def _event_frame(event: str, sid: str, payload: dict | None = None) -> dict:
def _event_frame(event: str, sid: str, payload: Mapping[str, Any] | None = None) -> dict:
params: dict = {"type": event, "session_id": sid}
if payload is not None:
params["payload"] = payload
return {"jsonrpc": "2.0", "method": "event", "params": params}
def _emit(event: str, sid: str, payload: dict | None = None):
def _emit(event: str, sid: str, payload: Mapping[str, Any] | None = None):
write_json(_event_frame(event, sid, payload))
@ -1245,7 +1247,7 @@ def unregister_live_transport(transport: Transport | None) -> None:
_live_transports.discard(transport)
def _broadcast_global_event(event: str, payload: dict | None = None) -> None:
def _broadcast_global_event(event: str, payload: Mapping[str, Any] | None = None) -> None:
"""Fan a session-less, surface-global event (``skin.changed``) to every
connected client. Emitters like the skin watcher run on background threads
where ``write_json``'s ladder bottoms out at stdio and WS peers never see
@ -4042,7 +4044,7 @@ def _project_info_for_cwd(cwd: str) -> dict | None:
return None
def _session_info(agent, session: dict | None = None) -> dict:
def _session_info(agent, session: dict | None = None) -> GatewaySessionRuntimeInfo:
if session is None:
for candidate in _sessions.values():
if candidate.get("agent") is agent:
@ -4161,7 +4163,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
warn = _probe_credentials(agent)
if warn:
info["credential_warning"] = warn
return info
return cast(GatewaySessionRuntimeInfo, info)
def _tool_ctx(name: str, args: dict) -> str:

View file

@ -4,12 +4,13 @@
"private": true,
"type": "module",
"scripts": {
"predev": "npm run generate:gateway-types --prefix ../apps/shared",
"dev": "npm run build:ink && tsx --watch src/entry.tsx",
"start": "tsx src/entry.tsx",
"build": "node scripts/build.mjs",
"start": "npm run generate:gateway-types --prefix ../apps/shared && tsx src/entry.tsx",
"build": "npm run generate:gateway-types --prefix ../apps/shared && node scripts/build.mjs",
"build:ink": "npm run build --prefix packages/hermes-ink",
"visual": "node scripts/visual/run.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck": "npm run generate:gateway-types --prefix ../apps/shared && tsc --noEmit -p tsconfig.json",
"lint": "eslint src/ packages/",
"lint:fix": "eslint src/ packages/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",

View file

@ -1,3 +1,5 @@
import type { GatewayMcpServerStatus, GatewayProjectInfo } from '@hermes/shared/gateway-contracts'
export interface ActiveTool {
context?: string
id: string
@ -140,21 +142,8 @@ export type ThinkingMode = 'collapsed' | 'truncated' | 'full'
export type SectionName = 'thinking' | 'tools' | 'subagents' | 'activity'
export type SectionVisibility = Partial<Record<SectionName, DetailsMode>>
export interface McpServerStatus {
connected: boolean
disabled?: boolean
status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed'
name: string
tools: number
transport: string
}
export interface ProjectInfo {
id: string
name: string
primary_path?: null | string
slug: string
}
export type McpServerStatus = GatewayMcpServerStatus
export type ProjectInfo = GatewayProjectInfo
export interface SessionInfo {
cwd?: string

11
uv.lock generated
View file

@ -1597,6 +1597,7 @@ dev = [
{ name = "ruff" },
{ name = "setuptools" },
{ name = "starlette" },
{ name = "ts-type" },
{ name = "ty" },
]
dingtalk = [
@ -1847,6 +1848,7 @@ requires-dist = [
{ name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" },
{ name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" },
{ name = "tenacity", specifier = "==9.1.4" },
{ name = "ts-type", marker = "extra == 'dev'", specifier = "==0.3.4" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
{ name = "urllib3", specifier = ">=2.7.0,<3" },
@ -4136,6 +4138,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "ts-type"
version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/9a/37a2cc86f00818ba15dc2cace832a8bdde4b22fa48c23b2480a10eae8ca6/ts_type-0.3.4.tar.gz", hash = "sha256:5f2e2a4ba4e5f7f63eb7cc5a0208b868dc066da99c34da891bc3cbfe0f27b19d", size = 18003, upload-time = "2025-05-18T05:44:19.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/be/61270497b881389a61797ea9a288d4560de80d97cfe9679499e2796e5dbb/ts_type-0.3.4-py3-none-any.whl", hash = "sha256:dc16d431d5f8e3e09b923b38f95037c07d95dbed3116964771e65f4cddca7038", size = 14426, upload-time = "2025-05-18T05:44:17.15Z" },
]
[[package]]
name = "ty"
version = "0.0.21"