diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 4e4622f72d0..50852041b3e 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 29489633104..ad0e5515d81 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64312654650..0cd57030a77 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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'", diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index a80d6ba8137..707152e0110 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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[] - tools?: Record - usage?: Partial - version?: string - yolo?: boolean -} +export type SessionRuntimeInfo = GatewaySessionRuntimeInfo export interface UsageStats { calls: number diff --git a/apps/shared/package.json b/apps/shared/package.json index beb2340f4f7..52a32a77f86 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -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": { diff --git a/pyproject.toml b/pyproject.toml index a2a1d6c0ee2..c097751cc87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/generate_gateway_types.py b/scripts/generate_gateway_types.py new file mode 100644 index 00000000000..96cd4920896 --- /dev/null +++ b/scripts/generate_gateway_types.py @@ -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() diff --git a/tui_gateway/contracts.py b/tui_gateway/contracts.py new file mode 100644 index 00000000000..b171505e66a --- /dev/null +++ b/tui_gateway/contracts.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index eb9f5cda0e6..3a4564c34a1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/package.json b/ui-tui/package.json index 3dbcb758d2c..d2c2eda682d 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -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}'", diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index f97506df7c9..0b77bf43d41 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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> -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 diff --git a/uv.lock b/uv.lock index 3b035b7e277..a33e20236c5 100644 --- a/uv.lock +++ b/uv.lock @@ -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"