Files
hermes-agent/tui_gateway/contracts/projects_pets.py
T
teknium1 0250c8bcae feat(contracts): declare every gateway method, server request and event; commit the generated TS + OpenRPC (#110522, part 2)
215 methods, 13 server→client requests and 67 notifications now have Pydantic
contracts under tui_gateway/contracts/<topic>.py, rendered to
apps/shared/src/gateway-contract.generated.ts (616 types) and
gateway-contract.openrpc.json. tests/contracts/test_generated.py pins both
files to an in-memory regeneration and asserts catalog completeness from the
CODE side (every registered handler / emitted event / sent request has a
contract, nothing orphaned). scripts/ci/classify_changes.py runs the Python
lane when either generated file changes.

Phantom fields the hand-typed TS carried and no emitter ever set:
tool.start.todos, error.reason, voice.transcript.voice_stopped.
2026-09-14 06:12:19 -07:00

473 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Projects (``projects.*`` — per-profile multi-folder workspaces, repo discovery, sidebar tree)
and the pet mascot surface (``pet.*`` — gallery, sprite payloads, adopt/remove/rename).
Every method here is profile-scoped: the desktop's ``projectParams`` / ``petRpc`` wrappers add
``profile`` so app-global remote mode reads the focused profile's ``projects.db`` / ``config.yaml``.
The pet wire predates the snake_case rule and travels camelCase (``displayName``,
``spritesheetBase64``); the field names below are those wire keys verbatim.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result
from .common import OkResult, OpenModel, ProfileParams, StoredSessionRow
from .registry import method
# ── projects: stored rows ─────────────────────────────────────────────────────────────────────
class ProjectFolder(Result):
"""``hermes_cli/projects_db.py::ProjectFolder.to_dict``."""
path: str
label: str | None = None
is_primary: bool = False
added_at: int | None = None
class ProjectInfo(Result):
"""``hermes_cli/projects_db.py::Project.to_dict`` — one stored project with its folders."""
id: str
slug: str
name: str
description: str | None = None
icon: str | None = None
color: str | None = None
board_slug: str | None = None
primary_path: str | None = None
archived: bool = False
created_at: int
folders: list[ProjectFolder] = Field(default_factory=list)
class ProjectsPayload(Result):
"""``methods_projects._projects_payload``: every project (archived included) + the active id."""
projects: list[ProjectInfo]
active_id: str | None = None
class ProjectResult(Result):
project: ProjectInfo
class OptionalProjectResult(Result):
project: ProjectInfo | None = None
class ProjectIdParams(ProfileParams):
"""Any method addressed at one stored project (``5062`` when the id resolves to nothing)."""
id: str
method("projects.list", params=ProfileParams, result=ProjectsPayload,
doc="Every project of the profile (archived included) plus which one is active.")
method("projects.get", params=ProjectIdParams, result=ProjectResult,
doc="One stored project with its folders.")
class ProjectsCreateParams(ProfileParams):
"""``use`` also activates the new project."""
name: str
folders: list[str] | None = None
slug: str | None = None
primary_path: str | None = None
description: str | None = None
icon: str | None = None
color: str | None = None
board_slug: str | None = None
use: bool = False
method("projects.create", params=ProjectsCreateParams, result=OptionalProjectResult,
doc="Create a project from a name + folders; duplicate primary paths are refused (5063).")
class ProjectsUpdateParams(ProjectIdParams):
"""Absent keys are left untouched; ``''`` clears ``color`` / ``icon``."""
name: str | None = None
description: str | None = None
icon: str | None = None
color: str | None = None
board_slug: str | None = None
method("projects.update", params=ProjectsUpdateParams, result=ProjectResult,
doc="Patch a project's display fields; answers the refreshed project.")
class ProjectsAddFolderParams(ProjectIdParams):
path: str
label: str | None = None
is_primary: bool = False
method("projects.add_folder", params=ProjectsAddFolderParams, result=ProjectResult,
doc="Attach a folder to a project (optionally as its primary path).")
class ProjectFolderParams(ProjectIdParams):
path: str
method("projects.remove_folder", params=ProjectFolderParams, result=ProjectResult,
doc="Detach a folder from a project.")
method("projects.set_primary", params=ProjectFolderParams, result=ProjectResult,
doc="Make one attached folder the project's primary path.")
class ProjectsArchiveParams(ProjectIdParams):
restore: bool = False
method("projects.archive", params=ProjectsArchiveParams, result=ProjectsPayload,
doc="Archive (or with ``restore`` un-archive) a project; answers the full listing.")
method("projects.delete", params=ProjectIdParams, result=ProjectsPayload,
doc="Delete a project and its folders; answers the full listing.")
class ProjectsSetActiveParams(ProfileParams):
"""No ``id`` (or null) clears the active project."""
id: str | None = None
class ActiveIdResult(Result):
active_id: str | None = None
method("projects.set_active", params=ProjectsSetActiveParams, result=ActiveIdResult,
doc="Switch (or clear) the active project for the profile.")
class ProjectsForCwdParams(ProfileParams):
"""Absent ``cwd`` resolves the gateway's default completion cwd."""
cwd: str | None = None
class ProjectsForCwdResult(Result):
project: ProjectInfo | None = None
cwd: str
branch: str = ""
method("projects.for_cwd", params=ProjectsForCwdParams, result=ProjectsForCwdResult,
doc="Which project (if any) owns a directory, plus the resolved cwd and its git branch.")
# ── projects: repo discovery ──────────────────────────────────────────────────────────────────
class RepoDiscoveryPolicy(Result):
"""``methods_projects._repo_discovery_policy`` — the effective ``desktop.repo_scan_*`` config."""
enabled: bool
roots: list[str]
exclude_paths: list[str]
class RepoDiscoveryPolicyParams(Params):
"""The policy the desktop scanned under (short or ``repo_scan_*`` long keys both accepted)."""
enabled: bool | None = None
roots: list[str] | None = None
exclude_paths: list[str] | None = None
repo_scan_enabled: bool | None = None
repo_scan_roots: list[str] | None = None
repo_scan_exclude_paths: list[str] | None = None
class DiscoveredRepo(Result):
"""``methods_projects._discover_repos_payload`` row: a git root with session totals."""
root: str
label: str = ""
sessions: int = 0
last_active: float = 0.0
class ProjectsDiscoverReposParams(ProfileParams):
"""``scan`` asks the host to walk the policy roots itself (remote-gateway desktop)."""
scan: bool = False
class ProjectsDiscoverReposResult(Result):
repos: list[DiscoveredRepo]
discovery_policy: RepoDiscoveryPolicy | None = None
method("projects.discover_repos", params=ProjectsDiscoverReposParams, result=ProjectsDiscoverReposResult,
doc="Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived.")
class RecordRepoItem(Params):
root: str
label: str | None = None
class ProjectsRecordReposParams(ProfileParams):
"""Repos as ``{root, label}`` objects or bare root strings; entries without a root are skipped."""
repos: list[RecordRepoItem | str] | None = None
discovery_policy: RepoDiscoveryPolicyParams | None = None
class ProjectsRecordReposResult(ProjectsDiscoverReposResult):
accepted: bool
method("projects.record_repos", params=ProjectsRecordReposParams, result=ProjectsRecordReposResult,
doc="Persist repo roots found by the client's (desktop-side) scan; return the merged list.")
# ── projects: sidebar tree ────────────────────────────────────────────────────────────────────
class ProjectTreeSession(StoredSessionRow):
"""``methods_projects._project_tree_row`` + ``project_tree.stamp_profile``: the minimal row the
sidebar renders, stamped with the profile it belongs to."""
profile: str | None = None
class ProjectTreeLane(Result):
"""One branch / worktree / kanban lane inside a repo; ``sessions`` is empty unless hydrated."""
id: str
label: str
path: str | None = None
isMain: bool = False
isKanban: bool = False
sessions: list[ProjectTreeSession] = Field(default_factory=list)
class ProjectTreeRepo(Result):
id: str
label: str
path: str | None = None
groups: list[ProjectTreeLane] = Field(default_factory=list)
sessionCount: int = 0
class ProjectTreeNode(Result):
"""``project_tree._project_node`` — explicit, auto (git root) or the synthetic Home bucket."""
id: str
label: str
path: str | None = None
color: str | None = None
icon: str | None = None
isAuto: bool = False
isNoProject: bool = False
sessionCount: int = 0
lastActive: float = 0.0
totalTokens: int = 0
totalCostUsd: float = 0.0
repos: list[ProjectTreeRepo] = Field(default_factory=list)
previewSessions: list[ProjectTreeSession] = Field(default_factory=list)
class ProjectsTreeParams(ProfileParams):
preview_limit: int | None = None
session_limit: int | None = None
class ProjectsTreeResult(Result):
projects: list[ProjectTreeNode]
active_id: str | None = None
scoped_session_ids: list[str] = Field(default_factory=list)
method("projects.tree", params=ProjectsTreeParams, result=ProjectsTreeResult,
doc="Project → repo → lane overview with counts and a few preview sessions per project.")
class ProjectsProjectSessionsParams(ProfileParams):
project_id: str
session_limit: int | None = None
class ProjectsProjectSessionsResult(Result):
project: ProjectTreeNode | None = None
method("projects.project_sessions", params=ProjectsProjectSessionsParams, result=ProjectsProjectSessionsResult,
doc="Fully hydrated lanes for one project, from the same grouping as projects.tree.")
# ── pet: active mascot ────────────────────────────────────────────────────────────────────────
class PetInfoParams(ProfileParams):
"""``knownRevision``: the spritesheet revision the caller already holds (send-once bytes)."""
knownRevision: str | None = None
class PetInfoResult(OpenModel):
"""``server._pet_sprite_payload`` behind ``enabled``; every sprite field is absent when the pet
display is off, ``spritesheetBase64`` is elided when ``spritesheetUnchanged``."""
enabled: bool
slug: str | None = None
displayName: str | None = None
mime: str | None = None
spritesheetBase64: str | None = None
spritesheetRevision: str | None = None
spritesheetUnchanged: bool | None = None
frameW: int | None = None
frameH: int | None = None
framesPerState: int | None = None
framesByState: dict[str, int] | None = None
framesByRow: dict[str, int] | None = None
loopMs: int | None = None
scale: float | None = None
stateRows: list[str] | None = None
method("pet.info", params=PetInfoParams, result=PetInfoResult,
doc="Active pet for sprite renderers: spritesheet (base64) + frame geometry + state-row taxonomy.")
class PetInfoMetaResult(Result):
enabled: bool
slug: str | None = None
displayName: str | None = None
scale: float | None = None
spritesheetRevision: str | None = None
method("pet.info.meta", params=ProfileParams, result=PetInfoMetaResult,
doc="Cheap active-pet metadata used to avoid full payload refreshes.")
class PetCellsParams(ProfileParams):
"""``graphics`` opts into the kitty payload when the TTY speaks it; ``cols`` overrides the width."""
state: str | None = None
cols: int | None = None
graphics: bool = False
class PetCellsResult(Result):
"""Unicode: ``frames`` is frame → row → cell ``[tr,tg,tb,ta, br,bg,bb,ba]``; kitty (``graphics``
set): ``frames`` are transmit escapes and ``placeholder`` the text grid."""
enabled: bool
slug: str | None = None
displayName: str | None = None
state: str | None = None
cols: int | None = None
frameMs: float | None = None
frames: list[list[list[list[int]]]] | list[str] | None = None
scale: float | None = None
graphics: str | None = None
imageId: int | None = None
color: str | None = None
rows: int | None = None
placeholder: list[str] | None = None
method("pet.cells", params=PetCellsParams, result=PetCellsResult,
doc="Half-block cell frames (or a kitty placement) for one pet state.")
# ── pet: gallery / picker ─────────────────────────────────────────────────────────────────────
class PetGalleryParams(ProfileParams):
localOnly: bool = False
class PetGalleryEntry(Result):
slug: str
displayName: str
installed: bool
spritesheetUrl: str = ""
curated: bool | None = None
generated: bool = False
class PetGalleryResult(Result):
enabled: bool
active: str = ""
pets: list[PetGalleryEntry] = Field(default_factory=list)
method("pet.gallery", params=PetGalleryParams, result=PetGalleryResult,
doc="Petdex gallery + local install state (installed-only offline); localOnly skips the remote manifest.")
class PetSlugParams(ProfileParams):
slug: str
class PetSlugResult(Result):
ok: bool
slug: str
displayName: str | None = None
method("pet.select", params=PetSlugParams, result=PetSlugResult,
doc="Adopt a pet: install (if needed) + activate; writes display.pet.* to config.")
method("pet.remove", params=PetSlugParams, result=PetSlugResult,
doc="Uninstall a pet (delete its directory); if it was active, turn the display off.")
class PetRenameParams(PetSlugParams):
name: str
method("pet.rename", params=PetRenameParams, result=PetSlugResult,
doc="Rename a pet's display name + realign its slug/dir; follows the active slug in config.")
class PetExportResult(Result):
ok: bool
filename: str
zipBase64: str
method("pet.export", params=PetSlugParams, result=PetExportResult,
doc="Export an installed pet as a re-importable .zip.")
class PetThumbParams(PetSlugParams):
"""``url``: spritesheet source for a not-yet-installed pet."""
url: str | None = None
class PetThumbResult(Result):
ok: bool
slug: str
dataUri: str | None = None
method("pet.thumb", params=PetThumbParams, result=PetThumbResult,
doc="Idle-frame PNG data URI for the picker (desktop CSP breaks CDN <img>).")
method("pet.disable", params=ProfileParams, result=OkResult,
doc="Turn the pet display off from the desktop picker.")
class PetScaleParams(ProfileParams):
scale: JsonValue = None # number or numeric string; a non-number answers 4004
class PetScaleResult(Result):
ok: bool
scale: float
method("pet.scale", params=PetScaleParams, result=PetScaleResult,
doc="Persist display.pet.scale (clamped to engine bounds) from the desktop slider.")