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.
This commit is contained in:
teknium1
2026-09-14 01:29:49 -07:00
committed by Teknium
parent 0cec9299fa
commit 0250c8bcae
17 changed files with 45385 additions and 22 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -96,8 +96,9 @@ _PY_RELEVANT_SITE = (
# Editing only the JSON in an apps/-only PR would otherwise skip the one test
# that can catch the drift, so these force the Python lane too.
_PY_RELEVANT_CONTRACT_FILES = {
# tests/tui_gateway/test_gateway_event_contract.py
"apps/shared/src/gateway-events.json",
# tests/contracts/test_generated.py (rendered from tui_gateway/contracts)
"apps/shared/src/gateway-contract.generated.ts",
"apps/shared/src/gateway-contract.openrpc.json",
# tests/hermes_cli/test_desktop_slash_registry.py
"apps/desktop/src/lib/desktop-slash-registry.json",
}
+11 -8
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import re
import subprocess
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Any
@@ -20,10 +21,13 @@ from typing import Any
from pydantic import TypeAdapter
from pydantic.json_schema import GenerateJsonSchema
from tui_gateway import contracts # noqa: F401 (imports every topic module → fills the tables)
from tui_gateway.contracts.registry import EVENTS, METHODS, SERVER_REQUESTS
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tui_gateway import contracts # noqa: E402,F401 (imports every topic module → fills the tables)
from tui_gateway.contracts.registry import EVENTS, METHODS, SERVER_REQUESTS # noqa: E402
TS_OUT = ROOT / "apps" / "shared" / "src" / "gateway-contract.generated.ts"
OPENRPC_OUT = ROOT / "apps" / "shared" / "src" / "gateway-contract.openrpc.json"
@@ -73,8 +77,9 @@ class Renderer:
if "enum" in schema:
return " | ".join(json.dumps(v) for v in schema["enum"])
if "anyOf" in schema or "oneOf" in schema:
variants = schema.get("anyOf") or schema.get("oneOf")
return " | ".join(self.type_of(v, inline_depth=inline_depth) for v in variants)
variants = schema.get("anyOf") or schema.get("oneOf") or []
rendered = list(dict.fromkeys(self.type_of(v, inline_depth=inline_depth) for v in variants))
return " | ".join(rendered)
t = schema.get("type")
if isinstance(t, list):
return " | ".join(self.type_of({**schema, "type": x}, inline_depth=inline_depth) for x in t)
@@ -127,7 +132,7 @@ class Renderer:
doc = _doc(schema.get("description"))
if "enum" in schema:
body = f"export type {name} = {self.type_of({'enum': schema['enum']})}\n"
elif "properties" in schema or schema.get("type") == "object":
elif schema.get("properties"):
body = f"export interface {name} {self.object_literal(schema, 0)}\n"
else:
body = f"export type {name} = {self.type_of(schema)}\n"
@@ -289,8 +294,6 @@ def render_all() -> dict[Path, str]:
def main(argv: list[str] | None = None) -> int:
import sys
args = argv if argv is not None else sys.argv[1:]
check = "--check" in args
stale = []
+6 -2
View File
@@ -101,8 +101,12 @@ CASES = {
"frontend → no uv_lock": (["apps/desktop/src/store/profile.ts"], _lanes(frontend=True)),
# Cross-language contract JSON under apps/: the pytest that pins it against
# the Python side must run even when nothing else in the PR is Python.
"gateway-events contract JSON → python + frontend": (
["apps/shared/src/gateway-events.json"],
"generated gateway contract → python + frontend": (
["apps/shared/src/gateway-contract.generated.ts"],
_lanes(python=True, frontend=True),
),
"gateway OpenRPC document → python + frontend": (
["apps/shared/src/gateway-contract.openrpc.json"],
_lanes(python=True, frontend=True),
),
"desktop slash-registry JSON → python + frontend": (
View File
+89
View File
@@ -0,0 +1,89 @@
"""The committed TypeScript + OpenRPC contract files are exactly what ``tui_gateway/contracts``
renders, and the contract catalog covers the whole wire.
Regenerate with ``.venv/bin/python scripts/gen_gateway_contracts.py`` when a model changes. The
two files are listed in ``scripts/ci/classify_changes.py::_PY_RELEVANT_CONTRACT_FILES`` so a
TS-only PR that edits them still runs this test.
"""
from __future__ import annotations
import importlib.util
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
GEN = REPO / "scripts" / "gen_gateway_contracts.py"
@pytest.fixture(scope="module")
def gen():
spec = importlib.util.spec_from_file_location("gen_gateway_contracts", GEN)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_generated_files_are_current(gen):
"""Both committed artefacts equal an in-memory regeneration (byte-for-byte)."""
stale = [path.relative_to(REPO) for path, text in gen.render_all().items()
if (path.read_text(encoding="utf-8") if path.exists() else None) != text]
assert not stale, f"stale generated contract files {stale}: run scripts/gen_gateway_contracts.py"
# The emitter inventory the old gateway-events.json scan used, kept as the completeness oracle:
# names must come from CODE the gateway runs, never from the contract tables themselves.
_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle")
_LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS))
_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block")
_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS))
_LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S)
_SIDE_AGENT = re.compile(r"_spawn_side_agent\((?:[^()]|\([^()]*\))*?\"([a-z_][a-z0-9_.]*\.complete)\"", re.S)
_SUBAGENT_RELAY = re.compile(r"\"(subagent\.[a-z_]+)\"")
_DESKTOP_UI_EMIT = re.compile(r"desktop_ui\.(?:emit|emit_or_error)\(\s*\"([a-z_][a-z0-9_.]*)\"")
_BROKER_FRAME = re.compile(r"^FRAME_[A-Z_]+ = \"(browser\.controller\.[a-z_]+)\"", re.M)
_SETUP_READY = re.compile(r"^SETUP_READY_EVENT = \"([a-z_.]+)\"", re.M)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def emitted_event_names() -> set[str]:
names: set[str] = set()
for src in (REPO / "tui_gateway").glob("*.py"):
text = _read(src)
names.update(_LITERAL_EMIT.findall(text))
names.update(_LITERAL_FRAME.findall(text))
names.update(_SIDE_AGENT.findall(text))
from tui_gateway.agent_callbacks import _CHILD_DELTA_EVENTS
from tui_gateway.change_watcher import _CHANGE_WATCHES
names.update(_CHANGE_WATCHES)
names.update(_CHILD_DELTA_EVENTS.values())
for src in (REPO / "tools").glob("delegate_tool*.py"):
names.update(_SUBAGENT_RELAY.findall(_read(src)))
names.discard("subagent.text") # mirrored into the watch window as message.delta, never emitted
for src in (REPO / "tools").glob("*.py"):
names.update(_DESKTOP_UI_EMIT.findall(_read(src)))
names.update(_BROKER_FRAME.findall(_read(REPO / "gateway" / "browser_control_broker.py")))
names.update(_SETUP_READY.findall(_read(REPO / "hermes_cli" / "free_tier_bootstrap.py")))
return names
def sent_server_requests() -> set[str]:
names: set[str] = set()
for src in (REPO / "tui_gateway").glob("*.py"):
names.update(_LITERAL_REQUEST.findall(_read(src)))
return names
def test_catalog_covers_the_whole_wire():
"""Every registered method, every emitted event and every sent server request has a contract,
and no contract is orphaned (a deleted handler must take its contract with it)."""
from tui_gateway import server
from tui_gateway.contracts import registry
registry.assert_complete(server._methods, emitted_event_names(), sent_server_requests())
@@ -1 +1,628 @@
"""Contracts: billing_delegation_pets (authored by the contract worker)."""
"""Contracts: billing / subscription / usage envelopes, delegation controls, handoff, message
reactions, pet generation and ``project.facts`` (handlers in ``tui_gateway/methods_session.py``).
Billing routes are FAIL-OPEN: a logged-out / unreachable portal answers an ``ok`` result whose
``error`` carries the typed code (``_serialize_billing_error``) — never a JSON-RPC error — so the
client always resolves and branches on the envelope. ``BillingEnvelope`` is that shared shape.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionParams
from .registry import method
# ── billing envelope ──────────────────────────────────────────────────────────────────────────
class BillingEnvelope(Result):
"""``tui_gateway/billing_view.py::_serialize_billing_error`` on failure (``ok`` false + typed
``error``); success routes add their own fields. ``payload`` is the raw portal body (error
extras such as ``remainingUsd``, or the NAS success body on pending-change routes)."""
ok: bool
error: str | None = None
message: str | None = None
portal_url: str | None = None
retry_after: int | float | None = None
payload: dict[str, JsonValue] | None = None
actor: str | None = None
code: str | None = None
recovery: str | None = None
# ── usage.bars (shared two-bar dollar model) ──────────────────────────────────────────────────
class UsageBarKind(WireEnum):
plan = "plan"
topup = "topup"
class UsageBar(Result):
"""``_serialize_usage_bar``: one bar, magnitudes pre-formatted; ``pct_used`` only for ``plan``."""
kind: UsageBarKind
remaining_display: str
total_display: str
spent_display: str
pct_used: int | None = None
fill_fraction: float
class UsageModel(Result):
"""``_serialize_usage_model`` — also embedded as ``usage`` in the billing / subscription states,
where the fail-open form is a bare ``{available: false}`` (no ``ok``)."""
ok: bool | None = None
available: bool
status: str | None = None
plan_name: str | None = None
renews_at: str | None = None
renews_display: str | None = None
subscription_remaining_display: str | None = None
topup_remaining_display: str | None = None
total_spendable_display: str | None = None
has_topup: bool | None = None
plan_bar: UsageBar | None = None
topup_bar: UsageBar | None = None
method("usage.bars", params=ProfileParams, result=UsageModel,
doc="Two-bar dollar usage view shared by /usage, /topup and /subscription; fail-open to unavailable.")
# ── billing.state ─────────────────────────────────────────────────────────────────────────────
class BillingCardInfo(Result):
brand: str
last4: str
masked: str
display: str | None = None
resolved_via: str | None = None
class PaymentMethodKind(WireEnum):
card = "card"
link = "link"
unknown = "unknown"
class BillingPaymentMethod(Result):
"""``_serialize_payment_method``: each kind emits only its own fields (a ``card`` never carries
``email``; ``unknown`` carries what the server called it in ``raw_kind``)."""
kind: PaymentMethodKind
brand: str | None = None
last4: str | None = None
wallet: str | None = None
email: str | None = None
raw_kind: str | None = None
resolved_via: str | None = None
class BillingMonthlyCap(Result):
limit_usd: str | None = None
limit_display: str
spent_this_month_usd: str | None = None
spent_display: str
is_default_ceiling: bool
class AutoReloadCardKind(WireEnum):
canonical = "canonical"
distinct = "distinct"
none = "none"
class BillingAutoReloadCard(Result):
"""Only ``distinct`` carries the payment-method identity."""
kind: AutoReloadCardKind
payment_method_id: str | None = None
brand: str | None = None
last4: str | None = None
class BillingAutoReload(Result):
enabled: bool
threshold_usd: str | None = None
threshold_display: str
reload_to_usd: str | None = None
reload_to_display: str
card: BillingAutoReloadCard | None = None
class BillingStateResult(Result):
"""``_serialize_billing_state`` (money as strings); the ``except`` fallback emits only
``ok / logged_in / free_tier / error``, so everything else is optional."""
ok: bool
logged_in: bool
free_tier: bool = False
free_tier_model: str | None = None
org_name: str | None = None
org_slug: str | None = None
role: str | None = None
is_admin: bool | None = None
can_change_plan: bool | None = None
can_charge: bool | None = None
balance_usd: str | None = None
balance_display: str | None = None
cli_billing_enabled: bool | None = None
charge_presets: list[str] | None = None
charge_presets_display: list[str] | None = None
min_usd: str | None = None
max_usd: str | None = None
card: BillingCardInfo | None = None
payment_method: BillingPaymentMethod | None = None
monthly_cap: BillingMonthlyCap | None = None
auto_reload: BillingAutoReload | None = None
portal_url: str | None = None
error: str | None = None
usage: UsageModel | None = None
method("billing.state", params=ProfileParams, result=BillingStateResult,
doc="Read-only billing view (no scope); the Nous free tier is answered locally without a portal call.")
# ── subscription.state / preview / change / resume / upgrade ──────────────────────────────────
class SubscriptionContext(WireEnum):
personal = "personal"
team = "team"
class CurrentSubscription(Result):
tier_id: str | None = None
tier_name: str | None = None
monthly_credits: str | None = None
credits_remaining: str | None = None
cycle_ends_at: str | None = None
pending_downgrade_tier_name: str | None = None
pending_downgrade_at: str | None = None
pending_downgrade_display: str | None = None
cancel_at_period_end: bool
cancellation_effective_at: str | None = None
cancellation_effective_display: str | None = None
class SubscriptionTierOption(Result):
tier_id: str
name: str
tier_order: int
dollars_per_month_display: str
monthly_credits: str | None = None
is_current: bool
is_enabled: bool
class SubscriptionStateResult(Result):
"""``_serialize_subscription_state``; the view's fallback emits only ``ok / logged_in / error``."""
ok: bool
logged_in: bool
is_admin: bool | None = None
can_change_plan: bool | None = None
org_name: str | None = None
org_id: str | None = None
role: str | None = None
context: SubscriptionContext | None = None
current: CurrentSubscription | None = None
tiers: list[SubscriptionTierOption] | None = None
portal_url: str | None = None
error: str | None = None
usage: UsageModel | None = None
method("subscription.state", params=ProfileParams, result=SubscriptionStateResult,
doc="Current plan, tier catalog and usage for the picker; fail-open when logged out.")
class SubscriptionPreviewParams(ProfileParams):
subscription_type_id: str | None = None
class SubscriptionChangeEffect(WireEnum):
charge_now = "charge_now"
scheduled = "scheduled"
no_op = "no_op"
blocked = "blocked"
class SubscriptionPreviewResult(BillingEnvelope):
"""``_serialize_subscription_preview`` on success; ``effect`` drives the confirm copy."""
effect: SubscriptionChangeEffect | None = None
reason: str | None = None
current_tier_id: str | None = None
current_tier_name: str | None = None
target_tier_id: str | None = None
target_tier_name: str | None = None
monthly_credits_delta: str | None = None
amount_due_now_cents: int | None = None
effective_at: str | None = None
method("subscription.preview", params=SubscriptionPreviewParams, result=SubscriptionPreviewResult,
doc="Chargeless quote of what a plan change would do (billing:manage).")
class SubscriptionChangeParams(ProfileParams):
"""Either a target tier (downgrade / same-price change) or ``cancel`` (period-end cancellation)."""
subscription_type_id: str | None = None
cancel: bool = False
class BillingPendingChangeResult(BillingEnvelope):
"""``_billing_pending_change``: ``message`` + the raw NAS body in ``payload`` on success."""
method("subscription.change", params=SubscriptionChangeParams, result=BillingPendingChangeResult,
doc="Schedule a downgrade / same-price change or a period-end cancellation.")
method("subscription.resume", params=ProfileParams, result=BillingPendingChangeResult,
doc="Clear a scheduled downgrade / cancellation (re-enables recurring spend).")
class SubscriptionUpgradeParams(ProfileParams):
subscription_type_id: str | None = None
idempotency_key: str | None = None
class SubscriptionUpgradeResult(BillingEnvelope):
"""The money route: ``status`` separates a completed upgrade from an SCA / decline that must
finish in the portal at ``recovery_url``; ``idempotency_key`` is echoed (also on error) so a
retry reuses it."""
status: str | None = None
target_tier_name: str | None = None
recovery_url: str | None = None
reason: str | None = None
idempotency_key: str | None = None
method("subscription.upgrade", params=SubscriptionUpgradeParams, result=SubscriptionUpgradeResult,
doc="Prorate, charge and flip the plan (billing:manage, idempotent).")
# ── billing.charge / charge_status / auto_reload / step_up ───────────────────────────────────
class BillingChargeParams(ProfileParams):
amount_usd: float | str | None = None
idempotency_key: str | None = None
class BillingChargeResult(BillingEnvelope):
"""``202 {chargeId}`` — money is not confirmed yet; poll ``billing.charge_status``."""
charge_id: str | None = None
idempotency_key: str | None = None
method("billing.charge", params=BillingChargeParams, result=BillingChargeResult,
doc="Start a one-off top-up charge (billing:manage, idempotent).")
class BillingChargeStatusParams(ProfileParams):
charge_id: str | None = None
class BillingChargeStatusResult(BillingEnvelope):
"""Single status read (pending | settled | failed); the caller drives the poll cadence."""
status: str | None = None
amount_usd: str | float | None = None
settled_at: str | None = None
reason: str | None = None
method("billing.charge_status", params=BillingChargeStatusParams, result=BillingChargeStatusResult,
doc="Poll one charge by id.")
class BillingAutoReloadParams(ProfileParams):
enabled: bool = False
threshold: float | str | None = None
top_up_amount: float | str | None = None
class BillingMutationResult(BillingEnvelope):
"""A write with no success payload beyond ``ok``."""
method("billing.auto_reload", params=BillingAutoReloadParams, result=BillingMutationResult,
doc="Enable/disable auto top-up with its threshold and reload amount (billing:manage).")
class BillingStepUpParams(ProfileParams):
session_id: str | None = None
class BillingStepUpResult(BillingEnvelope):
"""``granted`` false when the server downscopes (also on every error envelope)."""
granted: bool | None = None
method("billing.step_up", params=BillingStepUpParams, result=BillingStepUpResult,
doc="Run the billing:manage device flow; the URL/code arrive via billing.step_up.verification.")
# ── delegation / subagent.steer ───────────────────────────────────────────────────────────────
class ActiveSubagent(OpenModel):
"""One live child from ``tools/delegate_tool_registry.py::list_active_subagents`` (the record
is extended by the child runner — ``missed_steer`` etc. — so it stays open)."""
subagent_id: str
parent_id: str | None = None
depth: int | None = None
goal: str | None = None
delegation_id: str | None = None
model: str | None = None
started_at: float | None = None
status: str | None = None
tool_count: int | None = None
owner_agent_session_id: str | None = None
class DelegationStatusResult(Result):
active: list[ActiveSubagent]
paused: bool
max_spawn_depth: int
max_concurrent_children: int
method("delegation.status", params=ProfileParams, result=DelegationStatusResult,
doc="Running subagent tree plus the spawn pause flag and limits.")
class DelegationPauseParams(ProfileParams):
paused: bool = True
class DelegationPauseResult(Result):
paused: bool
method("delegation.pause", params=DelegationPauseParams, result=DelegationPauseResult,
doc="Block/unblock NEW spawns globally (active children keep running); returns the new state.")
class SubagentSteerParams(SessionParams):
subagent_id: str
text: str
class SteerStatus(WireEnum):
queued = "queued"
rejected = "rejected"
class SubagentSteerResult(Result):
"""``queued`` is not ``delivered``: a child past its final tool batch surfaces ``missed_steer``."""
status: SteerStatus
subagent_id: str
text: str
method("subagent.steer", params=SubagentSteerParams, result=SubagentSteerResult,
doc="Queue steering text into a live delegated child owned by this session.")
# ── handoff ───────────────────────────────────────────────────────────────────────────────────
class HandoffRequestParams(SessionParams):
platform: str
class HandoffRequestResult(Result):
queued: bool
session_key: str
platform: str
home_name: str
method("handoff.request", params=HandoffRequestParams, result=HandoffRequestResult,
doc="Queue a handoff to a messaging platform's home channel; the gateway watcher claims it.")
class HandoffStateResult(Result):
"""``state`` is pending | running | completed | failed, or '' when nothing was requested."""
state: str
platform: str
error: str
method("handoff.state", params=SessionParams, result=HandoffStateResult,
doc="Poll the handoff row for this session.")
class HandoffFailParams(SessionParams):
error: str | None = None
class HandoffFailResult(Result):
"""``failed`` false when the watcher already claimed the row; ``state`` is what it is now."""
failed: bool
state: str
method("handoff.fail", params=HandoffFailParams, result=HandoffFailResult,
doc="Fail a not-yet-claimed handoff (client poll timeout); CAS against the watcher.")
# ── message.react ─────────────────────────────────────────────────────────────────────────────
class ReactionAuthor(WireEnum):
user = "user"
agent = "agent"
class MessageReactParams(SessionParams):
"""``row_id`` is ``messages.id``; a not-yet-persisted live message names ``newest_role`` instead.
``emoji`` null clears; the same emoji again retracts."""
row_id: int | None = None
newest_role: str | None = None
emoji: str | None = None
author: ReactionAuthor | None = None
class MessageReaction(OpenModel):
"""One persisted reaction (``hermes_state_messages.py``); ``seen`` is stamped once announced."""
emoji: str
author: str
at: float | None = None
seen: bool | None = None
class MessageReactResult(Result):
row_id: int
reactions: list[MessageReaction]
method("message.react", params=MessageReactParams, result=MessageReactResult,
doc="Set/clear one author's emoji reaction on a message; returns the row's full reaction list.")
# ── pets: generate / hatch / cancel / status ──────────────────────────────────────────────────
class PetCancelParams(ProfileParams):
token: str | None = None
class PetCancelResult(Result):
ok: bool
method("pet.cancel", params=PetCancelParams, result=PetCancelResult,
doc="Stop an in-flight pet generate/hatch by token (idempotent).")
class PetGenProvider(Result):
"""``agent/pet/generate/imagegen.py::list_sprite_providers`` row."""
name: str
label: str
default: bool
class PetGenerateStatusResult(Result):
available: bool
providers: list[PetGenProvider]
method("pet.generate.status", params=ProfileParams, result=PetGenerateStatusResult,
doc="Whether pet generation is possible (a reference-capable image backend) and which providers.")
class PetGenerateParams(ProfileParams):
"""``prompt`` or a ``referenceImage`` data URL is required (the handler answers 4004 without one)."""
prompt: str | None = None
referenceImage: str | None = None # noqa: N815 - wire key
count: int | None = None
style: str | None = None
provider: str | None = None
class PetDraft(Result):
index: int
dataUri: str # noqa: N815 - wire key
class PetGenerateResult(Result):
ok: bool
token: str
drafts: list[PetDraft]
method("pet.generate", params=PetGenerateParams, result=PetGenerateResult,
doc="Candidate base looks for a new pet (draft step); drafts also stream via pet.generate.progress.")
class PetHatchParams(ProfileParams):
token: str
name: str
cancelToken: str | None = None # noqa: N815 - wire key
index: int | None = None
description: str | None = None
prompt: str | None = None
style: str | None = None
provider: str | None = None
# TODO(common): ``tui_gateway/server.py::_pet_sprite_payload`` is one shape for ``pet.info`` and
# ``pet.hatch``; consolidate with the pets contract module. Every field optional: ``pet.hatch``
# emits ``{}`` when the installed pet cannot be reloaded.
class PetSpritePayload(Result):
slug: str | None = None
displayName: str | None = None # noqa: N815 - wire key
mime: str | None = None
spritesheetBase64: str | None = None # noqa: N815 - wire key
spritesheetRevision: str | None = None # noqa: N815 - wire key
frameW: int | None = None # noqa: N815 - wire key
frameH: int | None = None # noqa: N815 - wire key
framesPerState: int | None = None # noqa: N815 - wire key
framesByState: dict[str, int] | None = None # noqa: N815 - wire key
framesByRow: dict[str, int] | None = None # noqa: N815 - wire key
loopMs: int | None = None # noqa: N815 - wire key
scale: float | None = None
stateRows: list[str] | None = None # noqa: N815 - wire key
class PetHatchResult(Result):
"""The hatched pet is installed but NOT active (``pet.select`` adopts, ``pet.remove`` discards)."""
ok: bool
slug: str
displayName: str # noqa: N815 - wire key
warnings: list[JsonValue] = Field(default_factory=list)
pet: PetSpritePayload
method("pet.hatch", params=PetHatchParams, result=PetHatchResult,
doc="Turn a base draft into a full spritesheet pet; progress streams via pet.hatch.progress.")
# ── project.facts ─────────────────────────────────────────────────────────────────────────────
class ProjectFactsParams(ProfileParams):
cwd: str | None = None
class ProjectFacts(Result):
"""``agent/coding_context.py::project_facts_for`` — the system prompt's coding-context detection."""
root: str
manifests: list[str]
packageManagers: list[str] # noqa: N815 - wire key
verifyCommands: list[str] # noqa: N815 - wire key
contextFiles: list[str] # noqa: N815 - wire key
class ProjectFactsResult(Result):
"""``facts`` null outside a workspace (or when detection failed)."""
facts: ProjectFacts | None = None
method("project.facts", params=ProjectFactsParams, result=ProjectFactsResult,
doc="Structured project facts for a cwd so UIs don't re-sniff the workspace.")
@@ -1 +1,558 @@
"""Contracts: config_free_tier_control (authored by the contract worker)."""
"""Contracts: config, setup readiness, free tier, model inventory, connectors, diagnostics,
image generation and structured session control.
Handlers: ``tui_gateway/methods_config.py`` (``config.get``, ``setup.*``, ``diagnostics.share_nous``),
``methods_config_set.py`` (``config.set``), ``methods_free_tier.py``, ``methods_complete.py``
(``model.options``), ``methods_connectors.py``, ``methods_images.py``, ``methods_session_control.py``
and ``methods_session.py`` (``verification.status``).
"""
from __future__ import annotations
from typing import Literal
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionLiveInfo
from .registry import method
# ── config.get ────────────────────────────────────────────────────────────────────────────────
class ConfigGetParams(ProfileParams):
"""``key`` selects one getter from ``_CONFIG_GETTERS``; ``cwd`` feeds the ``project`` getter,
``session_id`` lets ``reasoning`` / ``fast`` answer with the session's live pin."""
key: str
cwd: str | None = None
session_id: str | None = None
class ConfigProviderRef(OpenModel):
"""``hermes_cli/models.py::list_available_providers`` row."""
id: str
label: str
aliases: list[str] = Field(default_factory=list)
authenticated: bool = False
class ConfigGetResult(Result):
"""Union of every getter's payload: ``value`` for the simple words, ``config`` for ``full``,
``mtime`` / ``mcp_rev`` for the poller, ``model`` / ``provider`` / ``providers`` for ``provider``,
``home`` / ``display`` for ``profile``, ``cwd`` / ``branch`` for ``project``, ``prompt``."""
value: str | None = None
display: str | None = None
tool_progress: str | None = None
model: str | None = None
provider: str | None = None
providers: list[ConfigProviderRef] | None = None
home: str | None = None
cwd: str | None = None
branch: str | None = None
config: dict[str, JsonValue] | None = None
prompt: str | None = None
mtime: float | None = None
mcp_rev: str | None = None
method("config.get", params=ConfigGetParams, result=ConfigGetResult,
doc="Read one normalised config value (or the whole effective config) the way the UIs render it.")
# ── config.set ────────────────────────────────────────────────────────────────────────────────
class ConfigSetScope(WireEnum):
session = "session"
global_ = "global"
once = "once"
class ConfigSetParams(ProfileParams):
"""``key`` picks the setter (``_CONFIG_SETTERS``, ``details_mode.<section>``, display toggles);
``value`` is the raw word/string the setter normalises (falsy non-strings are reported back in
the error). ``scope`` applies to ``yolo`` / ``reasoning``; ``confirm_expensive_model`` to ``model``."""
key: str
value: JsonValue = ""
session_id: str | None = None
scope: str | None = None
confirm_expensive_model: bool = False
class ConfigSetResult(Result):
"""``{key, value}`` plus the setter's extras: model switches add ``warning`` /
``confirm_required`` / ``confirm_message`` / ``scope`` / ``deferred``; ``focus`` adds
``tool_progress``; ``cwd`` adds ``cwd`` / ``branch``; ``personality`` adds ``history_reset`` /
``info``; ``yolo`` reports its ``scope``. ``value`` is a bool only for the display toggles."""
key: str
value: str | bool | None = None
warning: str | None = None
confirm_required: bool | None = None
confirm_message: str | None = None
scope: str | None = None
deferred: bool | None = None
tool_progress: str | None = None
cwd: str | None = None
branch: str | None = None
history_reset: bool | None = None
info: SessionLiveInfo | None = None
method("config.set", params=ConfigSetParams, result=ConfigSetResult,
doc="Change one config key (persisted or session-scoped) and read back the normalised value.")
# ── setup readiness ───────────────────────────────────────────────────────────────────────────
class SetupStatusResult(Result):
"""``provider_configured`` is the loose answer; the boot record's fields (``ready``,
``free_tier``, ``other_providers``, ``inference_provider``) ride along on the launch profile.
An unknown ``profile`` answers ``ok=False`` + ``error``."""
provider_configured: bool | None = None
ready: bool | None = None
free_tier: bool | None = None
other_providers: bool | None = None
inference_provider: str | None = None
profile: str | None = None
ok: bool | None = None
error: str | None = None
method("setup.status", params=ProfileParams, result=SetupStatusResult,
doc="Loose provider check: is ANY provider auth state discoverable for the (launch or named) profile.")
class SetupRuntimeCheckParams(ProfileParams):
provider: str | None = None
class SetupRuntimeCheckResult(Result):
"""``ok=False`` + ``error`` when the resolved model can't be served; ``free_tier`` says the
selected route is the welcome host."""
ok: bool
provider: str | None = None
model: str | None = None
source: str | None = None
error: str | None = None
free_tier: bool | None = None
profile: str | None = None
method("setup.runtime_check", params=SetupRuntimeCheckParams, result=SetupRuntimeCheckResult,
doc="Strict provider check through the same runtime resolution the agent uses on session creation.")
# ── diagnostics.share_nous ────────────────────────────────────────────────────────────────────
class DiagnosticsShareNousParams(Params):
error_context: str | None = None
extra_files: dict[str, str] | None = None
log_lines: int | None = None
class DiagnosticsShareNousResult(Result):
"""Structured envelope: ``ok=False`` + ``error`` renders inline instead of failing the RPC."""
ok: bool
view_url: str | None = None
upload_id: str | None = None
expires_at: str | None = None
error: str | None = None
method("diagnostics.share_nous", params=DiagnosticsShareNousParams, result=DiagnosticsShareNousResult,
doc="Upload a force-redacted debug bundle to Nous-internal diagnostics storage.")
# ── free tier ─────────────────────────────────────────────────────────────────────────────────
class FreeTierStatusResult(Result):
"""``available`` = an identity exists AND the tier is on; whether inference runs on it is
``setup.runtime_check.free_tier``'s question."""
has_guest: bool
enabled: bool
available: bool
notice_pending: bool
model: str
label: str
method("free_tier.status", params=ProfileParams, result=FreeTierStatusResult,
doc="Pure read of the focused profile's free-tier identity state (no network, no side effects).")
class FreeTierProvisionResult(Result):
has_guest: bool
enabled: bool
error: str | None = None
method("free_tier.provision", params=ProfileParams, result=FreeTierProvisionResult,
doc="Explicit retry of the free-tier identity mint when the boot bootstrap could not create it.")
class FreeTierAckNoticeResult(Result):
acked: bool
method("free_tier.ack_notice", params=ProfileParams, result=FreeTierAckNoticeResult,
doc="Mark the one-time availability notice as shown on the free-tier identity.")
# ── model.options ─────────────────────────────────────────────────────────────────────────────
class ModelOptionsParams(ProfileParams):
session_id: str | None = None
explicit_only: bool = False
include_unconfigured: bool = False
refresh: bool = False
# TODO(common): ModelPricing / ModelCapabilities / ModelOptionProvider are also the row shape of
# ``model.save_key``'s ``provider`` — the parent consolidates into contracts/common.py.
class ModelPricing(Result):
"""``hermes_cli/inventory.py::_apply_pricing`` — formatted $/Mtok strings (``""`` unknown,
``"free"``); the sale fields are Nous Portal-only."""
input: str
output: str
cache: str | None = None
free: bool
discount_percent: int | None = None
was_input: str | None = None
was_output: str | None = None
class ModelCapabilities(Result):
"""``hermes_cli/inventory.py::_apply_capabilities``."""
fast: bool
reasoning: bool
can_disable_reasoning: bool | None = None
class ModelOptionProvider(OpenModel):
"""One ``hermes_cli/inventory.py::build_models_payload`` provider row (the union of every field
the builder sets; ``pricing_pending`` / ``free_tier_pending`` mark the cached-only path)."""
slug: str
name: str
models: list[str] = Field(default_factory=list)
total_models: int | None = None
is_current: bool | None = None
is_user_defined: bool | None = None
source: str | None = None
aliases: list[str] | None = None
api_url: str | None = None
auth_type: str | None = None
authenticated: bool | None = None
key_env: str | None = None
warning: str | None = None
featured_models: list[str] | None = None
capabilities: dict[str, ModelCapabilities] | None = None
pricing: dict[str, ModelPricing] | None = None
pricing_pending: bool | None = None
free_tier: bool | None = None
free_tier_pending: bool | None = None
free_tier_row: bool | None = None
unavailable_models: list[str] | None = None
class ModelOptionsResult(Result):
providers: list[ModelOptionProvider]
model: str = ""
provider: str = ""
method("model.options", params=ModelOptionsParams, result=ModelOptionsResult,
doc="Provider/model inventory for the picker, layered over the session's live provider when given.")
# ── connectors ────────────────────────────────────────────────────────────────────────────────
class ConnectorsListParams(ProfileParams):
session_id: str
class ConnectorRow(OpenModel):
"""One ``manage_connections`` status entry after ``connector_ui_payload`` redaction; the
connector service owns the closed key set, so unknown metadata passes through."""
connector: str = ""
connected: bool | None = None
enabled: bool | None = None
connectionStatus: str | None = None
name: str | None = None
description: str | None = None
class ConnectorsListResult(Result):
available: bool
connectors: list[ConnectorRow]
method("connectors.list", params=ConnectorsListParams, result=ConnectorsListResult,
doc="Connector catalog + connection state for one owned session (``available=False`` when the toolset is off).")
class ConnectorsConnectParams(ProfileParams):
session_id: str
connectors: list[str]
reconnect: bool = False
class ConnectorConnectEntry(OpenModel):
"""``tools/connections_tool.py`` per-connector authorization outcome."""
connector: str = ""
status: str | None = None
connect_url: str | None = None
note: str | None = None
instruction: str | None = None
class ConnectorsConnectResult(Result):
results: list[ConnectorConnectEntry]
summary: dict[str, JsonValue]
method("connectors.connect", params=ConnectorsConnectParams, result=ConnectorsConnectResult,
doc="Start (or re-initiate) authorization for named connectors; returns per-connector links/status.")
# ── image.generate ────────────────────────────────────────────────────────────────────────────
class ImageGenerateParams(Params):
prompt: str | None = None
aspect_ratio: str | None = None
probe: JsonValue | None = None # truthy word/flag: availability check only
max_bytes: int | None = None
class ImageGenerateResult(Result):
"""``probe`` answers ``{available}`` alone; ``image_data`` (data URL) is omitted when the
download failed or exceeded ``max_bytes`` so callers fall back to ``image``."""
available: bool
success: bool | None = None
image: str | None = None
image_data: str | None = None
error: str | None = None
method("image.generate", params=ImageGenerateParams, result=ImageGenerateResult,
doc="Generate an image through the tool's provider dispatcher and hand the renderer a data URL.")
# ── session.control ───────────────────────────────────────────────────────────────────────────
class GoalContractSnapshot(Result):
"""``hermes_cli/goals.py::GoalContract.to_dict``."""
outcome: str = ""
verification: str = ""
constraints: str = ""
boundaries: str = ""
stop_when: str = ""
class GoalGateSnapshot(Result):
command: str
timeout_seconds: int
max_retries: int
attempts: int
last_exit_code: int | None = None
class WaitBarrierUntil(Result):
type: Literal["until"]
until_at: float
reason: str = ""
class WaitBarrierTarget(Result):
type: Literal["session", "pid"]
target: str | int
reason: str = ""
class GoalSnapshot(Result):
"""``methods_session_control.py::_safe_goal_snapshot`` — the frontend-safe GoalState subset."""
title: str
status: str
turns_used: int
max_turns: int
contract: GoalContractSnapshot
subgoals: list[str]
gates: list[GoalGateSnapshot]
created_at: float | None = None
updated_at: float | None = None
paused_reason: str | None = None
last_verdict: str | None = None
last_reason: str | None = None
wait_barrier: WaitBarrierUntil | WaitBarrierTarget | None = Field(default=None, discriminator="type")
class LoopSnapshot(Result):
"""``_safe_loop_snapshot`` — persisted LoopState fields, never its route."""
prompt: str
status: str
mode: str
interval_seconds: float
current_delay: float
times: int
until: str
max_ticks: int
ticks_fired: int
created_at: float
last_fired_at: float
next_due_at: float
awaiting_response: bool
deferred_by_goal: bool
paused_reason: str | None = None
last_stop_reason: str | None = None
class HeartbeatSnapshot(Result):
prompt: str
status: str
interval_seconds: int
created_at: float
last_fired_at: float
fire_count: int
class SessionControlSnapshot(Result):
"""``_snapshot_control`` — ``revision`` is a hash of the visible state (``""`` when empty);
``updated_at`` is the newest persisted timestamp (``0`` when none)."""
goal: GoalSnapshot | None
loop: LoopSnapshot | None
heartbeat: HeartbeatSnapshot | None
revision: str
updated_at: float
class SessionControlReadParams(ProfileParams):
session_id: str
class SessionControlReadResult(Result):
control: SessionControlSnapshot
method("session.control.read", params=SessionControlReadParams, result=SessionControlReadResult,
doc="Stable, allowlisted snapshot of one live session's goal / loop / heartbeat state.")
class SessionControlAction(WireEnum):
goal_pause = "goal.pause"
goal_resume = "goal.resume"
goal_clear = "goal.clear"
goal_unwait = "goal.unwait"
loop_pause = "loop.pause"
loop_resume = "loop.resume"
loop_stop = "loop.stop"
subgoal_add = "subgoal.add"
subgoal_remove = "subgoal.remove"
subgoal_clear = "subgoal.clear"
heartbeat_pause = "heartbeat.pause"
heartbeat_resume = "heartbeat.resume"
heartbeat_clear = "heartbeat.clear"
class SessionControlArgs(Params):
"""``subgoal.add`` reads ``text``; ``subgoal.remove`` reads the 1-based ``index``."""
text: str | None = None
index: int | None = None
class SessionControlParams(ProfileParams):
"""``action`` is validated by the handler (unknown / gate actions answer ``4004``), so it stays a
string on the wire; ``SessionControlAction`` lists the accepted set."""
session_id: str
action: str
args: SessionControlArgs | None = None
class SessionControlDispatch(Result):
"""``_dispatch_envelope`` — the command result's user-visible envelope, every key always present."""
type: str | None
output: str | None
notice: str | None
message: str | None
display: str | None
class SessionControlResult(Result):
control: SessionControlSnapshot
dispatch: SessionControlDispatch
method("session.control", params=SessionControlParams, result=SessionControlResult,
doc="Run one allowlisted goal / loop / subgoal / heartbeat action and return the exact resulting snapshot.")
# ── verification.status ───────────────────────────────────────────────────────────────────────
class VerificationStatusParams(ProfileParams):
session_id: str | None = None
session_key: str | None = None
cwd: str | None = None
class VerificationEvidenceRow(OpenModel):
"""One ``verification_events`` row (``agent/verification_evidence.py``)."""
id: int | None = None
created_at: str | None = None
session_id: str | None = None
cwd: str | None = None
root: str | None = None
command: str | None = None
canonical_command: str | None = None
kind: str | None = None
scope: str | None = None
status: str | None = None
exit_code: int | None = None
output_summary: str | None = None
class VerificationStatusInfo(Result):
"""``verification_status()``: ``disabled`` / ``not_applicable`` / ``unverified`` / ``stale`` or the
latest event's own status; ``root`` and friends only once a workspace was identified."""
status: str
evidence: VerificationEvidenceRow | None = None
root: str | None = None
session_id: str | None = None
changed_paths: list[str] | None = None
class VerificationStatusResult(Result):
verification: VerificationStatusInfo
method("verification.status", params=VerificationStatusParams, result=VerificationStatusResult,
doc="Best known verification evidence for a cwd/session; read-only, never runs checks.")
+724 -1
View File
@@ -1 +1,724 @@
"""Contracts: events (authored by the contract worker)."""
"""Notification payloads: every ``event`` frame the gateway emits (``params.payload``).
One ``Payload`` model per event name, registered with ``event(...)``; ``request.cancel`` lives in
``server_requests.py`` next to the requests it withdraws. Each model's docstring names the Python
emitter it was typed from — that emitter is the source of truth, the hand-written TS in
``apps/shared/src/gateway-events.ts`` was only the map. Fields the emitter always sets are required;
anything conditional is ``X | None = None``.
A handful of payloads stay ``extra="allow"`` on purpose: their closed shape is owned by another
module (the skin engine, the pet store, the goal/loop/heartbeat state files, the free-tier bootstrap
record) or they are watcher signals whose payload is ``{}`` today and may grow. Everything else is
closed, so a drifted emitter fails the suite (``registry.check_payload`` raises under
``HERMES_TEST_ISOLATION``).
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Payload, WireEnum
from .common import SessionLiveInfo, SubagentStatus, Usage
from .registry import event
class OpenPayload(Payload):
"""A payload whose known keys are typed but whose closed set is owned elsewhere."""
model_config = Payload.model_config | {"extra": "allow"}
# ── gateway lifecycle ─────────────────────────────────────────────────────────────────────────
class SkinPayload(OpenPayload):
"""``tui_gateway/change_watcher.py::resolve_skin`` — the resolved active skin (``HermesSkin``).
``{}`` when the skin engine failed to load. Colour maps are token → colour string."""
name: str | None = None
colors: dict[str, str] | None = None
light_colors: dict[str, str] | None = None
dark_colors: dict[str, str] | None = None
branding: dict[str, str] | None = None
banner_logo: str | None = None
banner_hero: str | None = None
tool_prefix: str | None = None
help_header: str | None = None
class GatewayReadyPayload(Payload):
"""``tui_gateway/entry.py`` (stdio) / ``tui_gateway/ws.py`` (WebSocket) first frame."""
skin: SkinPayload
change_events: bool
replay_epoch: str
heartbeat: bool | None = None # WebSocket transport only
event("gateway.ready", GatewayReadyPayload,
doc="First frame of a connection: the resolved skin, the change-event capability and the replay epoch.")
event("skin.changed", SkinPayload,
doc="The active skin moved (name switch or live colour edit); repaint from this palette.")
class SetupReadyPayload(OpenPayload):
"""``hermes_cli/free_tier_bootstrap.py::SetupRecord.as_payload``."""
provider_configured: bool
inference_provider: str
free_tier: bool
has_identity: bool
other_providers: bool
error: str = ""
finished_at: float
event("setup.ready", SetupReadyPayload,
doc="The free-tier bootstrap finished (broadcast); the desktop's setup gate reads the record.")
class ErrorPayload(Payload):
"""Every ``_emit("error", …)`` site sets exactly ``message``."""
message: str
event("error", ErrorPayload, doc="A session-level failure outside a turn (agent init, model switch, compression, resume).")
class NoticePayload(Payload):
"""``tui_gateway/model_switch.py`` capability-refresh notice."""
message: str
event("notice", NoticePayload, doc="Informational one-liner for the session (capabilities refreshed).")
# ── turn stream ───────────────────────────────────────────────────────────────────────────────
event("message.start", None, doc="A turn began streaming; no payload.")
class StreamDeltaPayload(Payload):
"""``prompt_turn._invoke_agent._stream`` (message.delta: ``text`` + optional ``rendered``),
``agent_callbacks._agent_cbs`` (reasoning.delta / thinking.delta), ``tool_progress._progress_reasoning``
(reasoning.available). ``verbose`` rides only when the session's verbose reasoning mode is on."""
text: str
rendered: str | None = None
verbose: bool | None = None
event("message.delta", StreamDeltaPayload, doc="One streamed chunk of the assistant reply.")
event("reasoning.delta", StreamDeltaPayload, doc="One streamed chunk of the model's reasoning.")
event("reasoning.available", StreamDeltaPayload, doc="A completed reasoning block (non-streaming providers).")
event("thinking.delta", StreamDeltaPayload, doc="Legacy thinking-text chunk (thinking_callback).")
class MessageInterimPayload(Payload):
"""``prompt_turn._interim_assistant_cb`` / ``agent_callbacks`` interim_assistant_callback."""
text: str
already_streamed: bool
event("message.interim", MessageInterimPayload,
doc="Interim assistant commentary (text beside tool calls) sealed as its own segment.")
class TurnStatus(WireEnum):
"""``prompt_turn._result_status``."""
complete = "complete"
error = "error"
interrupted = "interrupted"
class ErrorSurface(Payload):
"""``agent/error_surface.py::_surface`` — advisory {layer, code, retryable} (+ identity, + auth hint)."""
layer: str
code: str
retryable: bool
provider: str | None = None
model: str | None = None
model_config = Payload.model_config | {"extra": "allow"}
class BillingBlock(Payload):
"""``agent/billing_links.py::BillingBlock.to_dict`` (+ ``unverified`` from conversation_loop)."""
provider: str
provider_label: str
model: str
billing_url: str | None = None
is_nous: bool
message: str
unverified: bool | None = None
class MessageCompletePayload(Payload):
"""``prompt_turn._complete_turn_payload`` / ``session_auto_continue._emit_terminal_turn_error`` /
``agent_callbacks._mirror_subagent_to_child`` (child watch mirror: ``text`` only) /
``compute_host_bridge`` (``text`` + ``status``)."""
text: str | JsonValue = ""
usage: Usage | None = None
status: TurnStatus | None = None
reasoning: str | None = None
warning: str | None = None
response_previewed: bool | None = None
billing: BillingBlock | None = None
failure_reason: str | None = None
rendered: str | None = None
error: str | None = None
recoverable: bool | None = None
error_surface: ErrorSurface | None = None
partial: bool | None = None
event("message.complete", MessageCompletePayload, doc="The turn ended: final text, usage and outcome.")
class StatusUpdatePayload(Payload):
"""``server._status_update`` and the direct emitters (goal / loop / heartbeat / process)."""
kind: str
text: str
event("status.update", StatusUpdatePayload, doc="Transient status line (kind: status, lifecycle, compacting, goal, loop, heartbeat, process, …).")
class SessionUsagePayload(Payload):
"""``server._start_usage_ticker``."""
usage: Usage
event("session.usage", SessionUsagePayload, doc="Mid-turn usage tick; message.complete carries the authoritative final usage.")
class SessionTitlePayload(Payload):
"""``prompt_turn._invoke_agent`` ``_on_session_title`` hook."""
session_id: str
title: str
event("session.title", SessionTitlePayload, doc="Auto-titling renamed the session (``session_id`` is the stored key).")
class ReactionPayload(Payload):
"""``agent_callbacks`` reaction_callback."""
kind: str
event("reaction", ReactionPayload, doc="Affection reaction detected in the user's message (hearts etc.).")
class ReviewSummaryPayload(Payload):
"""``server`` background_review_callback."""
text: str
event("review.summary", ReviewSummaryPayload, doc="Background review of the last turn finished.")
# ── tools ─────────────────────────────────────────────────────────────────────────────────────
class ToolStartPayload(Payload):
"""``tool_progress._on_tool_start`` (+ ``agent_callbacks._mirror_subagent_to_child`` rows with
``preview`` and empty ``args``). ``todos``/``revision`` are NOT set by the emitter; kept optional
because tool.start rows may pass through connector redaction unchanged."""
tool_id: str
name: str
context: str | None = None
args: dict[str, JsonValue] | None = None
args_text: str | None = None
preview: str | None = None
event("tool.start", ToolStartPayload, doc="A tool call began (stable id + full args).")
class ToolCompletePayload(Payload):
"""``tool_progress._on_tool_complete``; ``todos``/``revision`` merged in for the todo tools."""
tool_id: str
name: str
args: dict[str, JsonValue]
duration_s: float | None = None
result: JsonValue = None
summary: str | None = None
result_text: str | None = None
inline_diff: str | None = None
todos: list[JsonValue] | None = None
revision: int | None = None
event("tool.complete", ToolCompletePayload, doc="A tool call finished: parsed result, summary, optional diff / todo snapshot.")
class ToolGeneratingPayload(Payload):
"""``agent_callbacks`` tool_gen_callback."""
name: str
event("tool.generating", ToolGeneratingPayload, doc="The model is emitting a tool call's arguments.")
class ToolOutputRiskPayload(Payload):
"""``tool_progress._progress_output_risk``."""
tool_id: str
name: str
risk: str
findings: list[str]
redacted: bool
event("tool.output_risk", ToolOutputRiskPayload, doc="Tool output was classified as risky (prompt-injection / secret findings).")
class TodoUpdatedPayload(Payload):
"""``tool_progress._normalize_todo_state`` — full task snapshot."""
todos: list[JsonValue]
revision: int
event("todo.updated", TodoUpdatedPayload, doc="Full todo snapshot after a todo tool ran.")
# ── notifications ─────────────────────────────────────────────────────────────────────────────
class NotificationShowPayload(Payload):
"""``agent/credits_tracker.py::AgentNotice`` via notice_callback, and ``server._await_agent_ready``'s
slow-build notice. ``level``: info | warn | error | success; ``kind``: sticky | ttl | agent."""
text: str
level: str
kind: str
ttl_ms: int | None = None
key: str | None = None
id: str | None = None
class NotificationClearPayload(Payload):
key: str
event("notification.show", NotificationShowPayload, doc="Show / replace a keyed out-of-band notice (toast or status bar).")
event("notification.clear", NotificationClearPayload, doc="Withdraw the notice with this key.")
class TipShowPayload(Payload):
"""``tools/tip_tool.py``."""
selector: str
text: str
title: str | None = None
side: str | None = None
event("tip.show", TipShowPayload, doc="Point at a desktop element with a one-line tip bubble.")
# ── session lifecycle ─────────────────────────────────────────────────────────────────────────
# SessionLiveInfo is a Result (it is also the ``info`` of create/resume/activate); the registry only
# needs ``model_validate`` and the generator renders one TS type either way.
event("session.info", SessionLiveInfo, # type: ignore[arg-type]
doc="Live session settings snapshot (``server._session_info``); also the ``info`` of create/resume/activate.")
class ResumePhaseStatus(WireEnum):
loading = "loading"
complete = "complete"
failed = "failed"
class SessionResumeProgressPayload(Payload):
"""``server._hydrate_resume_history``."""
phase: str
status: ResumePhaseStatus
message_count: int | None = None
message: str | None = None
event("session.resume_progress", SessionResumeProgressPayload, doc="Deferred resume hydration progress.")
class SessionReclaimedPayload(Payload):
"""``session_lifecycle._announce_session_reclaimed`` (broadcast)."""
session_id: str
stored_session_id: str
reason: str # idle_timeout | lru_evict | ws_orphan_reap
event("session.reclaimed", SessionReclaimedPayload, doc="The backend reclaimed a live session out from under its clients.")
class SessionControlSnapshot(OpenPayload):
"""``methods_session_control._snapshot_control``; goal / loop / heartbeat sub-objects are the
allow-listed state-file projections (owned by hermes_cli.goals / loops / heartbeat)."""
goal: dict[str, JsonValue] | None = None
loop: dict[str, JsonValue] | None = None
heartbeat: dict[str, JsonValue] | None = None
revision: str = ""
updated_at: float = 0
class SessionControlUpdatePayload(Payload):
control: SessionControlSnapshot
event("session.control.update", SessionControlUpdatePayload, doc="Persisted goal / loop / heartbeat state changed.")
class BillingStepUpVerificationPayload(Payload):
"""``methods_session`` billing.step_up on_verification."""
verification_url: str
user_code: str
event("billing.step_up.verification", BillingStepUpVerificationPayload,
doc="Device-flow URL + code for the billing scope step-up; the client opens the browser.")
# ── side agents (methods_prompt._spawn_side_agent) ────────────────────────────────────────────
class SideAgentCompletePayload(Payload):
"""``methods_prompt._spawn_side_agent``: ``{task_id, **extra, text}``; btw adds ``question``."""
task_id: str
text: str
question: str | None = None
event("background.complete", SideAgentCompletePayload, doc="A /background side agent finished.")
event("btw.complete", SideAgentCompletePayload, doc="A /btw side question was answered.")
event("preview.restart.complete", SideAgentCompletePayload, doc="The hidden preview-restart agent finished.")
class PreviewRestartProgressPayload(Payload):
"""``methods_prompt`` restart body + ``agent_callbacks._preview_restart_callbacks``."""
task_id: str
text: str
level: str | None = None
event("preview.restart.progress", PreviewRestartProgressPayload, doc="Progress line from the preview-restart agent.")
# ── subagents (tool_progress._progress_subagent) ──────────────────────────────────────────────
class SubagentOutputTailEntry(Payload):
"""``tools/delegate_tool_results.py::_extract_output_tail`` row."""
tool: str
preview: str
is_error: bool
class SubagentEventPayload(Payload):
"""``tool_progress._progress_subagent`` — every ``subagent.*`` frame; identity fields are optional
because older emitters omit them and the TUI spawn tree falls back to flat rendering."""
goal: str
task_count: int
task_index: int
subagent_id: str | None = None
parent_id: str | None = None
child_session_id: str | None = None
delegation_id: str | None = None
depth: int | None = None
model: str | None = None
tool_count: int | None = None
toolsets: list[str] | None = None
input_tokens: int | None = None
output_tokens: int | None = None
reasoning_tokens: int | None = None
api_calls: int | None = None
files_read: list[str] | None = None
files_written: list[str] | None = None
output_tail: list[SubagentOutputTailEntry] | None = None
tool_name: str | None = None
text: str | None = None
status: SubagentStatus | None = None
summary: str | None = None
duration_seconds: float | None = None
tool_preview: str | None = None
for _name, _doc in (
("subagent.spawn_requested", "delegate_task accepted a child goal (before the child starts)."),
("subagent.start", "A delegated child started running."),
("subagent.progress", "Batched tool-name progress from a child."),
("subagent.thinking", "A child's reasoning chunk."),
("subagent.tool", "A child called a tool."),
("subagent.complete", "A child finished (status + observability rollup)."),
):
event(_name, SubagentEventPayload, doc=_doc)
# ── MoA ───────────────────────────────────────────────────────────────────────────────────────
class MoaReferencePayload(Payload):
"""``tool_progress._progress_moa_reference``."""
label: str
text: str
index: int | None = None
count: int | None = None
class MoaAggregatingPayload(Payload):
aggregator: str
class MoaProgressPayload(Payload):
label: str
refs_done: int
refs_total: int
class MoaPhasePayload(Payload):
phase: str
refs_done: int | None = None
refs_total: int | None = None
aggregator: str | None = None
event("moa.reference", MoaReferencePayload, doc="One MoA reference model's output.")
event("moa.aggregating", MoaAggregatingPayload, doc="The MoA aggregator started.")
event("moa.progress", MoaProgressPayload, doc="MoA reference fan-out progress (n/total).")
event("moa.phase", MoaPhasePayload, doc="MoA phase transition (currently only ``aggregator``).")
# ── desktop GUI (tools/desktop_ui emitters) ──────────────────────────────────────────────────
class PreviewOpenPayload(Payload):
"""``tools/open_preview_tool.py``."""
url: str
label: str = ""
class PreviewClosePayload(Payload):
"""``tools/preview_tool.py`` / ``tools/close_preview_tool.py``; ``url`` '' closes every tab."""
url: str = ""
event("preview.open", PreviewOpenPayload, doc="Open a URL / file in the desktop preview pane.")
event("preview.close", PreviewClosePayload, doc="Close the preview pane or one tab.")
class LayoutApplyPayload(OpenPayload):
"""``tools/apply_layout_tool.py``."""
preset: str
class PaneRevealPayload(OpenPayload):
"""``tools/focus_pane_tool.py``."""
pane: str
event("layout.apply", LayoutApplyPayload, doc="Apply a named desktop layout preset.")
event("pane.reveal", PaneRevealPayload, doc="Focus / reveal a named desktop pane.")
class MessageReaction(Payload):
"""``hermes_state_messages.set_message_reaction`` row."""
emoji: str
author: str
at: float | None = None
model_config = Payload.model_config | {"extra": "allow"}
class MessageReactionPayload(Payload):
"""``tools/react_to_message_tool.py``."""
row_id: int
reactions: list[MessageReaction]
role: str
event("message.reaction", MessageReactionPayload, doc="The agent reacted to a message; paint it live.")
class TerminalOutputPayload(Payload):
"""``session_notifications`` process_registry.on_output."""
process_id: str
chunk: str
class TerminalClosePayload(Payload):
process_id: str
event("agent.terminal.output", TerminalOutputPayload, doc="Output chunk from an agent-owned background process.")
event("terminal.close", TerminalClosePayload, doc="An agent-owned background process closed.")
class BrowserProgressPayload(Payload):
"""``methods_browser`` announce(); ``level``: info | warn | error."""
message: str
level: str
event("browser.progress", BrowserProgressPayload, doc="Browser (CDP) connect / install progress line.")
# ── browser controller (gateway/browser_control_broker frames re-enveloped as events) ─────────
class BrowserControllerCommandPayload(Payload):
"""``gateway/browser_control_broker.py`` FRAME_COMMAND params."""
command_id: str
action: str
arguments: dict[str, JsonValue]
controller_id: str | None = None
browser_profile_id: str | None = None
tool_call_id: str | None = None
class BrowserControllerCancelPayload(Payload):
"""``gateway/browser_control_broker.py::_cancel_frame``."""
command_id: str
tool_call_id: str | None = None
event("browser.controller.command", BrowserControllerCommandPayload, doc="Dispatch one browser action to the attached controller.")
event("browser.controller.cancel", BrowserControllerCancelPayload, doc="Withdraw a pending controller command.")
# ── voice ─────────────────────────────────────────────────────────────────────────────────────
event("voice.interrupted", None, doc="Barge-in: the spoken interjection interrupted the turn; no payload.")
class VoiceStatusPayload(Payload):
"""``methods_voice._vr_on_status``; states come from the recorder (idle / listening / transcribing …)."""
state: str
class VoiceTranscriptPayload(Payload):
"""``methods_voice._vr_transcript`` / ``_deliver_fd_transcript`` / typed stop phrase in methods_prompt."""
text: str | None = None
stop_phrase: bool | None = None
typed: bool | None = None
no_speech_limit: bool | None = None
class WakeDetectedPayload(Payload):
"""``methods_voice`` wake detector ``_on_detect``."""
phrase: str
profile: str | None = None
start_new_session: bool
event("voice.status", VoiceStatusPayload, doc="Voice recorder state changed.")
event("voice.transcript", VoiceTranscriptPayload, doc="A voice capture produced text (or a stop phrase / silence limit).")
event("wake.detected", WakeDetectedPayload, doc="A wake phrase fired.")
# ── pets ──────────────────────────────────────────────────────────────────────────────────────
class PetChangedPayload(OpenPayload):
"""``change_watcher._pet_changed_payload`` — ``pet.info.meta``-shaped; ``{enabled: false}`` when off."""
enabled: bool
slug: str | None = None
displayName: str | None = None # noqa: N815 - wire key
scale: float | None = None
spritesheetRevision: str | None = None # noqa: N815 - wire key
class PetGenerateProgressPayload(OpenPayload):
"""``methods_session`` pet.generate: token-only init frame, then one per draft."""
token: str
count: int
index: int | None = None
dataUri: str | None = None # noqa: N815 - wire key
class PetHatchProgressPayload(OpenPayload):
"""``methods_session`` pet.hatch ``_on_progress``: ``{event, detail}`` or the parsed row form."""
event: str
detail: str | None = None
state: str | None = None
done: str | None = None
total: str | None = None
event("pet.changed", PetChangedPayload, doc="The active pet / its spritesheet changed (watcher).")
event("pet.generate.progress", PetGenerateProgressPayload, doc="Pet base-draft generation progress.")
event("pet.hatch.progress", PetHatchProgressPayload, doc="Pet hatch (row drawing) progress.")
# ── change watcher signals (payload is {} today) ─────────────────────────────────────────────
class ChangeSignalPayload(OpenPayload):
"""``change_watcher._CHANGE_WATCHES`` payload fn — ``{}`` for every watch except pet.changed."""
event("cron.changed", ChangeSignalPayload, doc="cron/jobs.json moved; refetch the cron list.")
event("sessions.changed", ChangeSignalPayload, doc="state.db moved; refetch the session list.")
event("platforms.changed", ChangeSignalPayload, doc="gateway_state.json moved; refetch platform status.")
event("pairing.changed", ChangeSignalPayload, doc="Pairing state moved; refetch pairing.")
event("bot_relay.outbox.pending", ChangeSignalPayload, doc="A bot-relay outbox envelope is queued; drain it.")
__all__ = [
"BillingBlock", "BillingStepUpVerificationPayload", "BrowserControllerCancelPayload",
"BrowserControllerCommandPayload", "BrowserProgressPayload", "ChangeSignalPayload", "ErrorPayload",
"ErrorSurface", "GatewayReadyPayload", "LayoutApplyPayload", "MessageCompletePayload",
"MessageInterimPayload", "MessageReaction", "MessageReactionPayload", "MoaAggregatingPayload",
"MoaPhasePayload", "MoaProgressPayload", "MoaReferencePayload", "NoticePayload",
"NotificationClearPayload", "NotificationShowPayload", "OpenPayload", "PaneRevealPayload",
"PetChangedPayload", "PetGenerateProgressPayload", "PetHatchProgressPayload", "PreviewClosePayload",
"PreviewOpenPayload", "PreviewRestartProgressPayload", "ReactionPayload", "ResumePhaseStatus",
"ReviewSummaryPayload", "SessionControlSnapshot", "SessionControlUpdatePayload",
"SessionReclaimedPayload", "SessionResumeProgressPayload", "SessionTitlePayload", "SessionUsagePayload",
"SetupReadyPayload", "SideAgentCompletePayload", "SkinPayload", "StatusUpdatePayload",
"StreamDeltaPayload", "SubagentEventPayload", "SubagentOutputTailEntry", "TerminalClosePayload",
"TerminalOutputPayload", "TipShowPayload", "TodoUpdatedPayload", "ToolCompletePayload",
"ToolGeneratingPayload", "ToolOutputRiskPayload", "ToolStartPayload", "TurnStatus", "VoiceStatusPayload",
"VoiceTranscriptPayload", "WakeDetectedPayload",
]
+646 -1
View File
@@ -1 +1,646 @@
"""Contracts: groups_bot_relay (authored by the contract worker)."""
"""Hosted Group Chat rooms (``groups.*``), cross-connection bot relay (``bot_relay.*``) and the
dashboard browser controller (``browser.controller.*``).
Handlers: ``tui_gateway/methods_groups.py``, ``tui_gateway/methods_bot_relay.py``,
``tui_gateway/methods_browser_control.py``. Room / event / page shapes are produced by
``gateway/hosted_rooms.py`` (``_room_from_row`` / ``_event_from_row`` / ``read_events``) and
``gateway/hosted_room_replicas.py``; the RoomLink catalog by ``gateway/hosted_room_peer.py``.
"""
from __future__ import annotations
from .base import JsonValue, Params, Result, WireEnum
from .common import OkResult, OpenModel, ProfileParams
from .registry import method
# ── shared room shapes ────────────────────────────────────────────────────────────────────────
class RoomMember(OpenModel):
"""One roster row (``hosted_room_discussion.validate_roster``); legacy rooms may carry
pre-normalisation rows, so the set stays open."""
member_id: str | None = None
profile: str | None = None
handle: str | None = None
display_name: str | None = None
target: dict[str, JsonValue] | None = None
class RoomActor(Result):
kind: str
id: str
class RoomEvent(Result):
"""``gateway/hosted_rooms.py::_event_from_row``."""
room_id: str
seq: int
event_id: str
kind: str
actor: RoomActor
authority_epoch: int | None = None
payload: dict[str, JsonValue]
created_at: float
idempotent: bool = False
class Room(Result):
"""``gateway/hosted_rooms.py::_room_from_row`` plus the branch-only keys ``create`` (legacy
adoption), ``state`` (``authority_claim``) and ``rename`` (``event``) add."""
room_id: str
name: str
members: list[RoomMember]
authority_gateway_id: str
authority_epoch: int
revision: int
created_at: float
updated_at: float
idempotent: bool = False
disbanded_at: float | None = None
latest_seq: int | None = None
adopted: bool | None = None
claim_event: RoomEvent | None = None
authority_claim: RoomEvent | None = None
event: RoomEvent | None = None
class RoomAuthority(Result):
gateway_id: str
epoch: int
class RoomMemberInput(Params):
"""A roster row as the client proposes it; ``validate_roster`` owns the exact rules."""
member_id: str | None = None
profile: str | None = None
handle: str | None = None
display_name: str | None = None
target: dict[str, JsonValue] | None = None
model_config = Params.model_config | {"extra": "allow"}
class RoomParams(ProfileParams):
"""Any method addressed at one hosted room."""
room_id: str
# ── RoomLink catalog ──────────────────────────────────────────────────────────────────────────
class RoomExecutionPolicy(Result):
"""``gateway/hosted_room_execution_policy.py::execution_policy_mapping``."""
version: int
target_profile: str
enabled_toolsets: list[str]
approval_mode: str
max_iterations: int
policy_digest: str
class RoomLinkEndpoint(Result):
"""``GatewayRoomCatalog.endpoint_mapping``: ``url``/``transport_security`` when available,
``reason`` when not."""
available: bool
url: str | None = None
transport_security: str | None = None
reason: str | None = None
class RoomLinkCatalog(Result):
"""``gateway/hosted_room_peer.py::GatewayRoomCatalog.as_mapping``."""
installation_id: str
protocol_versions: list[int]
link_modes: list[str]
persistent_process: bool
text: bool
attachments: bool
execution_policy: RoomExecutionPolicy
catalog_digest: str
endpoint: RoomLinkEndpoint | None = None
class RoomLinkStatus(Result):
"""``enabled`` with ``profile``/``catalog``/``endpoint``, or disabled with a ``reason``."""
enabled: bool
profile: str | None = None
catalog: RoomLinkCatalog | None = None
endpoint: RoomLinkEndpoint | None = None
reason: str | None = None
# ── groups.capabilities ───────────────────────────────────────────────────────────────────────
class GroupsCapabilitiesParams(ProfileParams):
pass
class GroupsCapabilitiesResult(Result):
protocol_version: int
driver: bool
persistent_process: bool
authority_gateway_id: str
room_link: RoomLinkStatus
features: list[str]
methods: list[str]
max_log_limit: int
method("groups.capabilities", params=GroupsCapabilitiesParams, result=GroupsCapabilitiesResult,
doc="Describe the hosted-room protocol implemented by this gateway.")
# ── groups.list / create / state ──────────────────────────────────────────────────────────────
class GroupsListParams(ProfileParams):
include_disbanded: bool | None = None
limit: int | None = None
offset: int | None = None
class GroupsListResult(Result):
rooms: list[Room]
next_offset: int | None = None
method("groups.list", params=GroupsListParams, result=GroupsListResult,
doc="List rooms hosted by this gateway, most recently changed first.")
class GroupsCreateParams(ProfileParams):
room_id: str
name: str
members: list[RoomMemberInput]
# Ignored: authority is always this gateway's install identity (a client cannot spoof it).
authority_gateway_id: str | None = None
class GroupsCreateResult(Result):
room: Room
method("groups.create", params=GroupsCreateParams, result=GroupsCreateResult,
doc="Create a hosted room idempotently; authority is this gateway's stable install identity.")
class GroupsStateParams(RoomParams):
include_disbanded: bool | None = None
class PeerRouteStatus(Result):
room_id: str
member_id: str
status: str
class RoomDriverStatus(Result):
"""``HostedRoomService.status(room_id)``; ``pending_actions`` rows are ``{kind: retry, task_id}``
or the driver's approval action (``kind: approval`` + run/session/approval context)."""
running: bool
working: bool
blocked: bool
counts: dict[str, int]
pending_actions: list[dict[str, JsonValue]]
peer_routes: list[PeerRouteStatus]
class GroupsStateResult(Result):
room: Room
driver_status: RoomDriverStatus | None = None
method("groups.state", params=GroupsStateParams, result=GroupsStateResult,
doc="One hosted room's replay cursor and fenced authority state, plus live driver status.")
# ── groups.send / rename / log ────────────────────────────────────────────────────────────────
class GroupsSendParams(RoomParams):
event_id: str | None = None
payload: dict[str, JsonValue]
class GroupsSendResult(Result):
event: RoomEvent
client_event_id: str | None = None
accepted: bool = True
driver_started: bool = True
method("groups.send", params=GroupsSendParams, result=GroupsSendResult,
doc="Append one inert message.user event idempotently; the actor is server-owned.")
class GroupsRenameParams(RoomParams):
event_id: str
name: str
class GroupsRenameResult(Result):
room: Room
method("groups.rename", params=GroupsRenameParams, result=GroupsRenameResult,
doc="Rename one hosted room atomically with its replay event.")
class GroupsLogParams(RoomParams):
since_seq: int | None = None
limit: int | None = None
include_disbanded: bool | None = None
class GroupsLogResult(Result):
"""``gateway/hosted_rooms.py::read_events`` page — also the ``page`` ``groups.replicate`` ingests."""
events: list[RoomEvent]
cursor: int
latest_seq: int
has_more: bool
authority: RoomAuthority
method("groups.log", params=GroupsLogParams, result=GroupsLogResult,
doc="A monotonic room-log delta after since_seq, bounded by count and page bytes.")
# ── groups.disband / stop / approve / retry ───────────────────────────────────────────────────
class GroupsDisbandParams(RoomParams):
cancel_id: str | None = None
class RoomTombstone(Result):
room_id: str
disbanded_at: float
idempotent: bool
history_expired: bool | None = None
event: RoomEvent | None = None
class GroupsDisbandResult(Result):
tombstone: RoomTombstone
method("groups.disband", params=GroupsDisbandParams, result=GroupsDisbandResult,
doc="Permanently tombstone a hosted room id after stopping its work and revoking peer routes.")
class GroupsStopParams(RoomParams):
cancel_id: str | None = None
class GroupsStopResult(Result):
cancelled: int
method("groups.stop", params=GroupsStopParams, result=GroupsStopResult,
doc="Durably cancel queued or running work for one hosted room.")
class ApprovalChoice(WireEnum):
once = "once"
deny = "deny"
class GroupsApproveParams(RoomParams):
member_id: str
task_id: str
execution_generation: int
choice: ApprovalChoice
request_id: str
class GroupsApproveResult(Result):
"""``result`` is the local ``approval.respond`` answer or the peer's run-action receipt."""
approved: bool = True
result: dict[str, JsonValue]
method("groups.approve", params=GroupsApproveParams, result=GroupsApproveResult,
doc="Resolve one exact pending approval raised by a local or peer room member.")
class GroupsRetryParams(RoomParams):
task_id: str
class RoomTaskReceipt(Result):
room_id: str
task_id: str
thread_id: str
turn_id: str
status: str
execution_generation: int
cancel_generation: int
class GroupsRetryResult(Result):
retried: bool = True
task: RoomTaskReceipt
method("groups.retry", params=GroupsRetryParams, result=GroupsRetryResult,
doc="Retry one indeterminate room task after explicit user confirmation.")
# ── replication / authority takeover ──────────────────────────────────────────────────────────
class GroupsReplicateParams(RoomParams):
room_name: str
members: list[RoomMemberInput]
page: dict[str, JsonValue] # a verbatim ``groups.log`` result
class GroupsReplicateResult(Result):
room_id: str
stored_seq: int
ingested: int
authority: RoomAuthority
caught_up: bool
method("groups.replicate", params=GroupsReplicateParams, result=GroupsReplicateResult,
doc="Persist one authority-stamped replay page into the local replica store; idempotent.")
class GroupsReplicaStateParams(RoomParams):
pass
class GroupsReplicaStateResult(Result):
room_id: str
name: str
members: list[RoomMember]
authority: RoomAuthority
last_seq: int
latest_seq: int
event_bytes: int
created_at: float
updated_at: float
method("groups.replica_state", params=GroupsReplicaStateParams, result=GroupsReplicaStateResult,
doc="The local replica's coverage and authority lineage for one room.")
class GroupsPromoteParams(RoomParams):
confirm: bool | None = None
reason: str | None = None
class GroupsPromoteResult(Result):
room_id: str
authority_gateway_id: str
authority_epoch: int
previous_gateway_id: str
previous_epoch: int
claim_seq: int
latest_seq: int
method("groups.promote", params=GroupsPromoteParams, result=GroupsPromoteResult,
doc="Continue a replicated room on this gateway at epoch + 1; requires confirm=true.")
class GroupsDemoteParams(RoomParams):
observed_gateway_id: str
observed_epoch: int
class GroupsDemoteResult(Result):
room_id: str
authority_gateway_id: str
authority_epoch: int
idempotent: bool
method("groups.demote", params=GroupsDemoteParams, result=GroupsDemoteResult,
doc="Fence this gateway's stale room authority against a proven newer epoch.")
# ── peer routes (RoomLink) ────────────────────────────────────────────────────────────────────
class GroupsPeerInviteParams(ProfileParams):
room_id: str | None = None
home_install_id: str | None = None
authority_gateway_id: str | None = None
authority_epoch: int | None = None
member_id: str | None = None
grant_id: str | None = None
ttl_seconds: float | None = None
class GroupsPeerInviteResult(Result):
grant: str
target_profile: str
catalog: RoomLinkCatalog
endpoint: RoomLinkEndpoint
method("groups.peer.invite", params=GroupsPeerInviteParams, result=GroupsPeerInviteResult,
doc="Mint one target-issued room/profile grant for a prospective room home.")
class GroupsPeerRevokeParams(ProfileParams):
grant: str
class GroupsPeerRevokeResult(Result):
revoked: bool = True
method("groups.peer.revoke", params=GroupsPeerRevokeParams, result=GroupsPeerRevokeResult,
doc="Revoke one target-issued grant using its exact profile scope.")
class GroupsPeerRegisterParams(RoomParams):
member_id: str
target_url: str
target_profile: str
grant: str
catalog: dict[str, JsonValue] # a RoomLinkCatalog mapping; ``GatewayRoomCatalog.from_mapping`` is exact
cancellation_scope_id: str | None = None
trace_id: str | None = None
class GroupsPeerRegisterResult(Result):
registered: bool = True
mode: str
transport_security: str
target_install_id: str
target_profile: str
method("groups.peer.register", params=GroupsPeerRegisterParams, result=GroupsPeerRegisterResult,
doc="Register and probe one scoped peer route on the room home.")
# ── bot relay ─────────────────────────────────────────────────────────────────────────────────
class RelayAgentRow(Params):
"""A roster row the Desktop pushes (``tools/bot_relay.py::_normalize_roster_row``); invalid
rows are dropped server-side, so the shape stays open."""
profile: str | None = None
handle: str | None = None
connection_id: str | None = None
connection_label: str | None = None
title: str | None = None
description: str | None = None
online: bool | None = None
model_config = Params.model_config | {"extra": "allow"}
class BotRelayRosterSyncParams(ProfileParams):
agents: list[RelayAgentRow] | None = None
class BotRelayRosterSyncResult(Result):
count: int
method("bot_relay.roster.sync", params=BotRelayRosterSyncParams, result=BotRelayRosterSyncResult,
doc="Replace this gateway's view of agents on other connections; answers the accepted row count.")
class BotRelayOutboxDrainParams(ProfileParams):
pass
class RelayEnvelope(OpenModel):
"""``tools/bot_relay.py::enqueue_envelope``."""
id: str
created_at: int | float
from_profile: str
from_handle: str
target_connection: str
target_profile: str
target_handle: str
message: str
class BotRelayOutboxDrainResult(Result):
envelopes: list[RelayEnvelope]
method("bot_relay.outbox.drain", params=BotRelayOutboxDrainParams, result=BotRelayOutboxDrainResult,
doc="Atomically claim every pending cross-connection envelope queued on this gateway.")
class BotRelayDeliverParams(Params):
"""``profile`` here is the TARGET profile on this gateway (also what the desktop route wrapper adds)."""
profile: str
message: str
from_profile: str | None = None
from_handle: str | None = None
from_connection: str | None = None
class BotRelayDeliverResult(Result):
reply: str
method("bot_relay.deliver", params=BotRelayDeliverParams, result=BotRelayDeliverResult,
doc="Deliver a relayed DM into a Bot Chat on this gateway and return the one-turn reply (blocking).")
class BotRelayReplyParams(ProfileParams):
id: str
reply: str | None = None
error: str | None = None
reason: str | None = None
method("bot_relay.reply", params=BotRelayReplyParams, result=OkResult,
doc="Write a relayed reply and/or typed error for an envelope so the sender-side waiter resolves.")
# ── browser controller ────────────────────────────────────────────────────────────────────────
class BrowserControllerParams(Params):
"""Every controller call names the session the controller is attached to."""
session_id: str
class BrowserControllerRegisterParams(BrowserControllerParams):
controller_id: str
browser_profile_id: str
capabilities: list[str] | None = None
protocol_version: JsonValue | None = None # checked exactly by the handler (an int today)
# Ignored: the principal is derived from the server-minted identity, never client-supplied.
principal_id: str | None = None
class ControllerScope(Result):
principal_id: str
profile_id: str
session_id: str
controller_id: str
browser_profile_id: str
transport_family: str
capabilities: list[str]
class BrowserControllerRegisterResult(Result):
scope: ControllerScope
method("browser.controller.register", params=BrowserControllerRegisterParams,
result=BrowserControllerRegisterResult,
doc="Attach this connection as the browser controller for one session; fails closed (4403).")
class BrowserControllerResultParams(BrowserControllerParams):
command_id: str
ok: JsonValue | None = None # only the exact ``true`` counts as success
result: JsonValue | None = None
error: JsonValue | None = None
class BrowserControllerResultResult(Result):
accepted: bool
method("browser.controller.result", params=BrowserControllerResultParams,
result=BrowserControllerResultResult,
doc="Deliver one command result to the broker; accepted is false for unknown or settled command ids.")
method("browser.controller.heartbeat", params=BrowserControllerParams, result=OkResult,
doc="Acknowledge a heartbeat only for this transport's own attached controller.")
class BrowserControllerDetachResult(Result):
detached: bool = True
method("browser.controller.detach", params=BrowserControllerParams, result=BrowserControllerDetachResult,
doc="Hard-detach only the controller owned by this authenticated transport.")
__all__ = [
"GroupsLogResult", "RelayEnvelope", "Room", "RoomAuthority", "RoomEvent", "RoomLinkCatalog",
"RoomMember", "RoomMemberInput",
]
@@ -1 +1,635 @@
"""Contracts: profiles_vault_complete_foreign_subagents (authored by the contract worker)."""
"""Contracts for ``methods_profiles``, ``methods_vault``, ``methods_complete``,
``methods_session_foreign`` and ``methods_subagents``.
Profiles are the ws twin of the dashboard's ``/api/profiles``; the vault handlers are the
Desktop's Settings → Credential Vault door (metadata only — a secret never appears in a result);
completions feed the composer popovers; ``session.foreign.*`` browses Claude Code / Codex
histories on the serving backend; ``subagent.*`` is the session-scoped roster of live children.
"""
from __future__ import annotations
from typing import Literal
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionParams, SubagentStatus
from .registry import method
# ── completions / paste / model keys (methods_complete) ───────────────────────────────────────
class CompletionItem(Result):
"""One popover row; ``kind`` rides only on slash completions (command vs skill)."""
text: str
display: str = ""
meta: str = ""
kind: str | None = None
class CompletionItemsResult(Result):
items: list[CompletionItem] = Field(default_factory=list)
class CompletePathParams(ProfileParams):
"""``word`` is the token under the cursor (``@`` prefix = context reference); ``cwd`` /
``session_id`` pick the directory the listing resolves against."""
word: str | None = None
cwd: str | None = None
session_id: str | None = None
method("complete.path", params=CompletePathParams, result=CompletionItemsResult,
doc="Path / @-reference completions for the composer (files, folders, profiles, plugin providers).")
class CompleteSlashParams(Params):
text: str | None = None
class CompleteSlashResult(Result):
"""``replace_from`` is the column the accepted item replaces from."""
items: list[CompletionItem] = Field(default_factory=list)
replace_from: int | None = None
method("complete.slash", params=CompleteSlashParams, result=CompleteSlashResult,
doc="Ranked slash-command / skill completions for a ``/`` token.")
class PasteCollapseParams(Params):
text: str | None = None
class PasteCollapseResult(Result):
placeholder: str
path: str
lines: int
method("paste.collapse", params=PasteCollapseParams, result=PasteCollapseResult,
doc="Spill a large paste to a file and hand back the inline placeholder.")
class ModelProviderRow(OpenModel):
"""One provider row of the shared inventory builder (``hermes_cli.inventory``); the closed
set of keys is owned there."""
# TODO(common): same shape as the ``model.options`` provider rows — consolidate.
slug: str
name: str = ""
is_current: bool = False
is_user_defined: bool | None = None
models: list[JsonValue] = Field(default_factory=list)
total_models: int | None = None
authenticated: bool | None = None
auth_type: str | None = None
key_env: str | None = None
warning: str | None = None
class ModelSaveKeyParams(Params):
slug: str
api_key: str
session_id: str | None = None
class ModelSaveKeyResult(Result):
provider: ModelProviderRow
method("model.save_key", params=ModelSaveKeyParams, result=ModelSaveKeyResult,
doc="Save an API key for a provider and return its refreshed inventory row.")
class ModelDisconnectParams(Params):
slug: str
session_id: str | None = None
class ModelDisconnectResult(Result):
slug: str
name: str
disconnected: bool
method("model.disconnect", params=ModelDisconnectParams, result=ModelDisconnectResult,
doc="Remove every credential (env keys and OAuth state) for a provider.")
# ── profiles (methods_profiles) ───────────────────────────────────────────────────────────────
class ProfileSessionPreview(Result):
"""Newest human-facing session of a profile (``_latest_profile_session_rows``)."""
id: str
title: str = ""
preview: str = ""
started_at: float | int = 0
last_active: float | int = 0
message_count: int = 0
class ProfileWorkerSession(Result):
"""Newest kanban/tool worker row, so rosters can show a profile as working."""
id: str
source: str = ""
title: str = ""
last_active: float | int = 0
class ProfileCanonicalSession(Result):
"""The profile's "Bot Chat" registry row; ``resolved_id`` is the live compression tip."""
id: str
resolved_id: str
root_title: str = ""
title: str = ""
preview: str = ""
started_at: float | int = 0
last_active: float | int = 0
message_count: int = 0
class ProfileRow(Result):
"""One roster row; the session fields are present only with ``include_sessions``."""
name: str
path: str
is_default: bool = False
model: str | None = None
provider: str | None = None
description: str = ""
display_name: str = ""
skill_count: int = 0
last_session: ProfileSessionPreview | None = None
worker_session: ProfileWorkerSession | None = None
canonical_session: ProfileCanonicalSession | None = None
ui_meta_revisions: dict[str, int] = Field(default_factory=dict)
ui_meta: dict[str, JsonValue] | None = None
has_avatar: bool = False
class ProfilesListParams(ProfileParams):
include_sessions: bool | str | None = None
class ProfilesListResult(Result):
"""``bot_mode_protocol`` tells clients this backend injects the teammate protocol itself."""
profiles: list[ProfileRow] = Field(default_factory=list)
bot_mode_protocol: bool = True
method("profiles.list", params=ProfilesListParams, result=ProfilesListResult,
doc="Roster of profiles with previews so a client paints without N follow-up calls.")
class ProfilesCreateParams(ProfileParams):
"""``clone_from`` omitted = fresh profile + bundled skills; ``mirror_credentials`` defaults on
so a headless bot has a provider."""
name: str
description: str | None = None
clone_from: str | None = None
clone_all: bool | str | None = None
clone_channels: bool | str | None = None
no_skills: bool | str | None = None
no_alias: bool | str | None = None
soul: str | None = None
model: str | None = None
provider: str | None = None
share_auth: bool | str | None = None
mirror_credentials: bool | str | None = None
class ProfileMirrored(Result):
"""What was copied from the launch profile; ``auth`` is ``"shared"`` under ``share_auth``."""
env: bool = False
auth: bool | Literal["shared"] = False
model_inherited: bool = False
voice: bool = False
class ProfilesCreateResult(Result):
ok: bool = True
name: str
path: str
soul_written: bool = False
model_set: bool = False
mirrored: ProfileMirrored
method("profiles.create", params=ProfilesCreateParams, result=ProfilesCreateResult,
doc="Create a profile (ws twin of POST /api/profiles), mirroring launch credentials by default.")
class ProfileNameParams(ProfileParams):
name: str | None = None
class CapabilityEntry(Result):
name: str
enabled: bool = True
class ToolsetEntry(CapabilityEntry):
label: str = ""
description: str = ""
tool_count: int = 0
class McpServerEntry(CapabilityEntry):
transport: str = "stdio"
class ProfileModelPin(Result):
provider: str = ""
default: str = ""
class ProfilesDescribeResult(Result):
"""Editor snapshot; ``toolsets_pinned`` says whether ``tools.enabled_toolsets`` is explicit."""
name: str
description: str = ""
soul: str = ""
model: ProfileModelPin
skills: list[CapabilityEntry] = Field(default_factory=list)
toolsets: list[ToolsetEntry] = Field(default_factory=list)
toolsets_pinned: bool = False
mcp_servers: list[McpServerEntry] = Field(default_factory=list)
method("profiles.describe", params=ProfileNameParams, result=ProfilesDescribeResult,
doc="Everything the profile editor shows: soul, model pin, skills, toolsets, MCP servers.")
class ProfilesConfigureParams(ProfileParams):
"""Sections are independent; ``ui_meta_expected_revisions`` is a per-key compare-and-swap."""
name: str | None = None
ui_meta: dict[str, JsonValue] | None = None
ui_meta_expected_revisions: dict[str, int] | None = None
soul: str | None = None
description: str | None = None
model: str | None = None
provider: str | None = None
confirm_expensive_model: bool | str | None = None
disabled_skills: list[str] | None = None
enabled_toolsets: list[str] | None = None
enabled_mcp_servers: list[str] | None = None
class UiMetaConflict(Result):
expected: JsonValue = None
actual: int = 0
class ProfilesConfigureApplied(Result):
"""Per-section outcome; only the sections the request carried are present."""
ui_meta: bool | None = None
ui_meta_revisions: dict[str, int] | None = None
ui_meta_conflicts: dict[str, UiMetaConflict] | None = None
soul: bool | None = None
description: bool | None = None
model: bool | None = None
skills: bool | None = None
toolsets: bool | None = None
mcp_servers: bool | None = None
class ProfilesConfigureResult(Result):
"""``confirm_required`` mirrors ``config.set``: a guarded model pick wrote nothing yet."""
ok: bool
applied: ProfilesConfigureApplied
confirm_required: bool | None = None
confirm_message: str | None = None
method("profiles.configure", params=ProfilesConfigureParams, result=ProfilesConfigureResult,
doc="Editor Save: apply any subset of a profile's sections and report each one.")
class ProfilesSetAssetParams(ProfileParams):
"""``data`` is a data URL or bare base64 (PNG/JPEG/WebP, sniffed); ``clear`` deletes instead."""
name: str | None = None
asset: str | None = None
data: str | None = None
clear: bool | str | None = None
class ProfilesSetAssetResult(Result):
ok: bool = True
asset: str
size: int = 0
removed: int | None = None
method("profiles.set_asset", params=ProfilesSetAssetParams, result=ProfilesSetAssetResult,
doc="Store or clear a profile asset (avatar) atomically.")
class ProfilesGetAssetParams(ProfileParams):
name: str | None = None
asset: str | None = None
class ProfilesGetAssetResult(Result):
"""Absent is ``found: false``, not an error."""
found: bool
mime: str | None = None
size: int | None = None
data: str | None = None
method("profiles.get_asset", params=ProfilesGetAssetParams, result=ProfilesGetAssetResult,
doc="A profile asset as a data URL.")
class OnboardingAnswers(Params):
"""``tui_gateway/onboarding_personalization.py`` — the facts agreed during onboarding."""
name: str | None = None
context: str | None = None
theme: str | None = None
accent: str | None = None
layout: str | None = None
focus: list[str] | None = None
connectors: list[str] | None = None
# The onboarding store may carry extra UI-only keys; the writer ignores unknown ones.
model_config = Params.model_config | {"extra": "allow"}
class ProfilesRememberOnboardingParams(ProfileParams):
answers: OnboardingAnswers | None = None
class ProfilesRememberOnboardingResult(Result):
saved: bool = True
profile: str = "default"
target: str = "user"
method("profiles.remember_onboarding", params=ProfilesRememberOnboardingParams,
result=ProfilesRememberOnboardingResult,
doc="Write the onboarding facts into the default profile's user memory and confirm they landed.")
# ── vault (methods_vault) ─────────────────────────────────────────────────────────────────────
class VaultKind(WireEnum):
login = "login"
payment = "payment"
address = "address"
class VaultItem(Result):
"""Metadata-only view (``VaultItemMeta.to_dict`` + ``backend``); never a secret."""
id: str
kind: str
label: str
origin: str | None = None
created_at: str = ""
identifier: str | None = None
identifier_type: str | None = None
has_otp: bool | None = None
backend: str
class VaultListResult(Result):
items: list[VaultItem] = Field(default_factory=list)
method("vault.list", params=ProfileParams, result=VaultListResult,
doc="Metadata-only listing across the local vault and every unlocked password manager.")
class VaultSource(Result):
name: str
display_name: str
enabled: bool
needs_unlock: bool
unlocked: bool
installed: bool
class VaultSourcesResult(Result):
sources: list[VaultSource] = Field(default_factory=list)
method("vault.sources", params=ProfileParams, result=VaultSourcesResult,
doc="Status of every login source (local vault + detected password managers).")
class VaultSourceSetParams(ProfileParams):
name: str | None = None
enabled: bool | None = None
class VaultSourceSetResult(Result):
name: str
enabled: bool
method("vault.source.set", params=VaultSourceSetParams, result=VaultSourceSetResult,
doc="Enable or disable an external password manager (disabling also locks it).")
class VaultUnlockParams(ProfileParams):
"""The master password is consumed by the manager CLI and never stored or logged."""
name: str | None = None
password: str | None = None
class VaultUnlockResult(Result):
name: str
unlocked: bool = True
method("vault.unlock", params=VaultUnlockParams, result=VaultUnlockResult,
doc="Unlock a password manager for this session with its master password.")
class VaultLockParams(ProfileParams):
name: str | None = None
class VaultLockResult(Result):
locked: bool = True
method("vault.lock", params=VaultLockParams, result=VaultLockResult,
doc="Forget a manager's session token (every manager when no name is given).")
class VaultAddParams(ProfileParams):
"""``secret`` goes straight into the encrypted store; the result carries only the new id."""
kind: VaultKind | None = None
label: str | None = None
origin: str | None = None
secret: dict[str, JsonValue] | None = None
class VaultAddResult(Result):
id: str
method("vault.add", params=VaultAddParams, result=VaultAddResult,
doc="Add a login / payment / address item to the local vault.")
class VaultRemoveParams(ProfileParams):
id: str | None = None
class VaultRemoveResult(Result):
removed: bool
method("vault.remove", params=VaultRemoveParams, result=VaultRemoveResult,
doc="Remove a local vault item by id.")
# ── foreign histories (methods_session_foreign) ───────────────────────────────────────────────
class ForeignSource(WireEnum):
claude = "claude"
codex = "codex"
class ForeignSessionRow(Result):
"""``hermes_cli/foreign_sessions_browser.py::list_foreign_sessions`` — ``id`` is an opaque
handle, never a path."""
id: str
source: ForeignSource
label: str
title: str = ""
cwd: str | None = None
mtime: float
turn_count: int = 0
excerpt: str = ""
class SessionForeignListParams(ProfileParams):
source: ForeignSource | None = None
offset: int | None = None
limit: int | None = None
class SessionForeignListResult(Result):
"""``unreadable`` counts logs on this page that failed to parse."""
sessions: list[ForeignSessionRow] = Field(default_factory=list)
next_offset: int | None = None
host: str
unreadable: int = 0
method("session.foreign.list", params=SessionForeignListParams, result=SessionForeignListResult,
doc="One page of Claude Code / Codex sessions found on the serving backend.")
class ForeignTurn(Result):
role: str
content: str
class SessionForeignIdParams(ProfileParams):
id: str | None = None
class SessionForeignPreviewResult(Result):
"""Bounded to the last 40 turns / 8000 chars each; ``already_imported`` is the local id."""
messages: list[ForeignTurn] = Field(default_factory=list)
total: int = 0
truncated: bool = False
already_imported: str | None = None
cwd: str | None = None
method("session.foreign.preview", params=SessionForeignIdParams, result=SessionForeignPreviewResult,
doc="Preview a foreign session's tail before importing it.")
class SessionForeignImportResult(Result):
session_id: str
already_imported: bool = False
method("session.foreign.import", params=SessionForeignIdParams, result=SessionForeignImportResult,
doc="Import a foreign session into this profile's history (idempotent per origin).")
# ── subagents (methods_subagents) ─────────────────────────────────────────────────────────────
class SubagentSnapshot(Result):
"""``methods_subagents._SUBAGENT_SNAPSHOT_FIELDS`` projection of one live child record."""
subagent_id: str
parent_id: str | None = None
depth: int | None = None
goal: str | None = None
delegation_id: str | None = None
model: str | None = None
started_at: float | None = None
status: SubagentStatus | None = None
tool_count: int | None = None
last_tool: str | None = None
accepting_steer: bool | None = None
class SubagentListResult(Result):
"""``delegations`` is reserved for async delegation records and is currently always empty."""
subagents: list[SubagentSnapshot] = Field(default_factory=list)
delegations: list[dict[str, JsonValue]] = Field(default_factory=list)
method("subagent.list", params=SessionParams, result=SubagentListResult,
doc="Live children owned by this session (other sessions' children never leak).")
class SubagentIdParams(SessionParams):
subagent_id: str
class SubagentInterruptResult(Result):
found: bool
subagent_id: str
method("subagent.interrupt", params=SubagentIdParams, result=SubagentInterruptResult,
doc="Hard-interrupt one owned child; ``found`` is false when it already finished.")
class SubagentTailResult(Result):
"""``available`` is false while the child has no live transcript yet (or it was cleaned up)."""
subagent_id: str
available: bool = False
text: str = ""
truncated: bool = False
method("subagent.tail", params=SubagentIdParams, result=SubagentTailResult,
doc="Last 16KB of an owned child's live transcript.")
+472 -1
View File
@@ -1 +1,472 @@
"""Contracts: projects_pets (authored by the contract worker)."""
"""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.")
+548 -1
View File
@@ -1 +1,548 @@
"""Contracts: prompt_voice (authored by the contract worker)."""
"""Prompt submission, attachments, side agents, approvals, batch-clarify locks (``methods_prompt.py``)
and voice / wake-word control (``methods_voice.py``).
``profile`` rides on every method here: the desktop routes session-scoped calls through
``requestForOwnedSession`` / ``requestForBot`` / ``requestGatewayForProfile``, which stamp it.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, SessionParams
from .registry import method
# ── prompt.submit ─────────────────────────────────────────────────────────────────────────────
class ClientSurface(WireEnum):
"""Where the user typed this turn; anything else is treated as the plain app window."""
hud = "hud"
voice_live = "voice-live"
class PromptSubmitParams(SessionParams):
"""``text`` is normally a string; the relay / hosted paths may hand a structured (parts list)
payload, and the busy path renders it. Truncation (rewind / edit / regenerate) needs explicit
consent: ``confirm_truncate`` plus one durable target (``truncate_before_row_id`` preferred,
``truncate_before_message_id``, or the legacy ``truncate_before_user_ordinal``)."""
text: JsonValue = ""
display_kind: str | None = None # only "hidden" is honoured; anything else renders as a user row
interrupted: bool | None = None # client-side barge-in: the turn's model message carries the note
queued: bool | None = None # client queue drain — the busy path must hold it, never redirect/steer
surface: str | None = None # a ClientSurface value; unknown values clear the surface
voice_context: str | None = None # recent spoken transcript, model input only (voice-live)
truncate_before_user_ordinal: int | None = None
truncate_before_row_id: int | None = None
truncate_before_message_id: str | None = None
confirm_truncate: bool | None = None
confirm_empty_truncate: bool | None = None
rebind_survivor_row_ids: list[int] | None = None
# In-process only: injected by the hosted-room / bot-relay handlers, never accepted from a
# client (a client dict for ``_turn_author`` answers 4124). Excluded from the rendered wire.
hosted_task: JsonValue | None = Field(default=None, exclude=True, alias="_hosted_task")
turn_author: JsonValue | None = Field(default=None, exclude=True, alias="_turn_author")
hosted_terminal_callback: JsonValue | None = Field(
default=None, exclude=True, alias="_hosted_terminal_callback")
class PromptSubmitStatus(WireEnum):
streaming = "streaming"
queued = "queued"
steered = "steered"
redirected = "redirected"
class PromptSubmitResult(Result):
"""``status`` is absent only on the typed-stop-phrase reply (``voice_stopped``). After a
truncation the survivor row ids let the client rebind its cached ``rowId``s (``None`` map
entries: drop the cached id). ``turn_isolation`` marks a compute-host dispatch."""
status: PromptSubmitStatus | None = None
voice_stopped: bool | None = None
survivor_user_row_ids: list[int | None] | None = None
survivor_row_id_map: dict[str, int | None] | None = None
turn_isolation: bool | None = None
method("prompt.submit", params=PromptSubmitParams, result=PromptSubmitResult,
doc="Send a user turn to a live session; busy sessions queue / steer / redirect instead of refusing.")
# ── attachments ───────────────────────────────────────────────────────────────────────────────
class ImageMeta(Result):
"""``tui_gateway/server.py::_image_meta`` — dimensions when PIL can open the file."""
name: str | None = None
width: int | None = None
height: int | None = None
token_estimate: int | None = None
class AttachedImageResult(ImageMeta):
"""``methods_prompt.py::_attached_image_result``: the image is queued for the next turn."""
attached: bool
path: str | None = None
count: int | None = None
remainder: str | None = None
text: str | None = None
bytes: int | None = None
message: str | None = None # clipboard.paste: why nothing was attached
class ClipboardPasteParams(SessionParams):
pass
method("clipboard.paste", params=ClipboardPasteParams, result=AttachedImageResult,
doc="Save the host clipboard image into the session and queue it for the next turn.")
class ImageAttachParams(SessionParams):
path: str # a host path, optionally followed by remainder text (drop syntax accepted)
method("image.attach", params=ImageAttachParams, result=AttachedImageResult,
doc="Queue a gateway-visible image file for the next turn.")
class ImageAttachBytesParams(SessionParams):
"""``content_base64`` (or the ``data`` alias) carries the bytes; ``filename`` / ``ext`` only hint
the extension — magic bytes decide."""
content_base64: str | None = None
data: str | None = None
filename: str | None = None
ext: str | None = None
method("image.attach_bytes", params=ImageAttachBytesParams, result=AttachedImageResult,
doc="Queue an image uploaded as base64 (remote client); reply mirrors image.attach.")
class PdfAttachParams(SessionParams):
"""Host ``path`` or base64 ``content_base64`` / ``data``; ``first_page`` / ``last_page`` bound the
render (per-call page cap enforced server-side)."""
path: str | None = None
content_base64: str | None = None
data: str | None = None
filename: str | None = None
first_page: int | None = None
last_page: int | None = None
class PdfPage(ImageMeta):
path: str
page: int
class PdfAttachResult(Result):
attached: bool
filename: str
pages_attached: int
pages: list[PdfPage]
count: int
text: str
method("pdf.attach", params=PdfAttachParams, result=PdfAttachResult,
doc="Render a PDF's pages to PNG and queue them as images for the next turn.")
class FileAttachParams(SessionParams):
"""``path`` when the file is gateway-visible, else ``data_url`` carries the bytes; ``name`` labels
an uploaded file."""
path: str | None = None
data_url: str | None = None
name: str | None = None
class FileAttachResult(Result):
attached: bool
name: str
path: str
ref_path: str
ref_text: str
uploaded: bool
method("file.attach", params=FileAttachParams, result=FileAttachResult,
doc="Stage a non-image file into the session workspace and hand back its @file: ref.")
class ImageDetachParams(SessionParams):
path: str
class ImageDetachResult(Result):
detached: bool
count: int
method("image.detach", params=ImageDetachParams, result=ImageDetachResult,
doc="Drop a queued image before the turn is sent.")
class InputDetectDropParams(SessionParams):
text: str | None = None
class InputDetectDropResult(ImageMeta):
"""``matched: false`` alone when the text is not a drop; an image drop is queued immediately
(``is_image`` + ``count``), a file drop only yields the ``text`` to insert."""
matched: bool
is_image: bool | None = None
path: str | None = None
count: int | None = None
text: str | None = None
method("input.detect_drop", params=InputDetectDropParams, result=InputDetectDropResult,
doc="Recognise a terminal file drop pasted into the composer and turn it into an attachment.")
# ── side agents ───────────────────────────────────────────────────────────────────────────────
class SideAgentParams(SessionParams):
text: str
class TaskIdResult(Result):
"""The side agent runs detached; its answer lands on the parent session as an event carrying
this ``task_id``."""
task_id: str
method("prompt.background", params=SideAgentParams, result=TaskIdResult,
doc="Run a task on a fresh agent in the background; the answer arrives as background.complete.")
method("prompt.btw", params=SideAgentParams, result=TaskIdResult,
doc="Side question over a snapshot of the live conversation; the answer arrives as btw.complete.")
class PreviewRestartParams(SessionParams):
url: str
cwd: str | None = None
context: str | None = None # the preview pane's console output
method("preview.restart", params=PreviewRestartParams, result=TaskIdResult,
doc="Spawn a hidden agent that brings the desktop preview's dev server back up.")
# ── batch clarify locks / proxied request answers ─────────────────────────────────────────────
class ClarifyLockParams(Params):
request_id: str
question_id: str
answer: JsonValue = "" # non-string answers are JSON-encoded server-side
profile: str | None = None
class ClarifyLockStatus(WireEnum):
ok = "ok"
expired = "expired"
class ClarifyLockResult(Result):
"""``remaining`` lists the qids still unanswered; the lock that empties it resolves the request.
``expired``: the wait already ended (timeout / cancel) — not an error."""
status: ClarifyLockStatus
remaining: list[str] | None = None
method("clarify.lock", params=ClarifyLockParams, result=ClarifyLockResult,
doc="Lock one answer of a batch clarify request (editable until every question is locked).")
class RequestAnswerParams(Params):
id: str # the open server→client request id
result: dict[str, JsonValue]
profile: str | None = None
class RequestAnswerResult(Result):
status: ClarifyLockStatus
method("request.answer", params=RequestAnswerParams, result=RequestAnswerResult,
doc="Answer an open server→client request from a client that never received the frame.")
# ── approvals ─────────────────────────────────────────────────────────────────────────────────
class PendingApproval(OpenModel):
"""One unresolved ``tools/approval.py`` gateway queue entry (snapshot of its ``data``); the key
set is owned by the approval tool, so it stays open."""
request_id: str | None = None
command: str | None = None
description: str | None = None
pattern_key: str | None = None
pattern_keys: list[str] | None = None
allow_permanent: bool | None = None
allow_session: bool | None = None
smart_denied: bool | None = None
choices: list[str] | None = None
tool_name: str | None = None
class ApprovalPendingParams(SessionParams):
pass
class ApprovalPendingResult(Result):
approvals: list[PendingApproval]
method("approval.pending", params=ApprovalPendingParams, result=ApprovalPendingResult,
doc="Replay the approvals still waiting on this session (reconnect / polling).")
class ApprovalReceivedParams(SessionParams):
request_id: str
class ApprovalReceivedResult(Result):
acknowledged: bool
method("approval.received", params=ApprovalReceivedParams, result=ApprovalReceivedResult,
doc="Tell the backend the card is on screen, so its timeout clock starts.")
class ApprovalRespondParams(SessionParams):
"""``choice`` is one of the offered ``approval`` choices (once / session / always / deny); ``all``
resolves every pending approval, ``request_id`` a specific one, neither the oldest."""
choice: str | None = None # default "deny"
all: bool | None = None
request_id: str | None = None
class ApprovalRespondResult(Result):
resolved: int # how many pending approvals this decision unblocked
method("approval.respond", params=ApprovalRespondParams, result=ApprovalRespondResult,
doc="Deliver the user's decision on a dangerous command (falls back to durable identity on a stale sid).")
# ── voice ─────────────────────────────────────────────────────────────────────────────────────
class VoiceToggleAction(WireEnum):
status = "status"
on = "on"
off = "off"
tts = "tts"
class VoiceToggleParams(Params):
action: VoiceToggleAction = VoiceToggleAction.status
profile: str | None = None
class VoiceToggleResult(Result):
"""``methods_voice.py::_voice_status_payload`` (+ the requirements probe on ``status``, the spoken
stop hint on ``on``)."""
enabled: bool
record_key: str
tts: bool
stop_hint: str | None = None
available: bool | None = None
audio_available: bool | None = None
stt_available: bool | None = None
details: str | None = None
method("voice.toggle", params=VoiceToggleParams, result=VoiceToggleResult,
doc="/voice parity: report, flip voice mode on/off, or toggle speech output.")
class VoiceRecordAction(WireEnum):
start = "start"
stop = "stop"
class VoiceRecordParams(Params):
action: VoiceRecordAction = VoiceRecordAction.start
session_id: str | None = None # where voice.transcript / voice.status events are addressed
profile: str | None = None
class VoiceRecordStatus(WireEnum):
recording = "recording"
stopped = "stopped"
busy = "busy"
class VoiceRecordResult(Result):
status: VoiceRecordStatus
reason: str | None = None # "wake_owned" when another surface holds the mic
method("voice.record", params=VoiceRecordParams, result=VoiceRecordResult,
doc="VAD-bounded push-to-talk; the transcript arrives as a voice.transcript event.")
class VoiceTtsParams(Params):
text: str
profile: str | None = None
class VoiceTtsResult(Result):
status: str # "speaking"
method("voice.tts", params=VoiceTtsParams, result=VoiceTtsResult,
doc="Speak text through the backend TTS engine (barge-in aware).")
# ── wake word ─────────────────────────────────────────────────────────────────────────────────
class WakeStartParams(Params):
"""``surface`` names the caller ("tui" | "gui"); ``persist`` is the explicit gesture that also
flips ``wake_word.enabled`` on; ``client_capture`` asks for PCM streamed via wake.feed."""
surface: str | None = None
persist: bool | None = None
client_capture: bool | None = None
session_id: str | None = None # session the wake.detected event is addressed to
profile: str | None = None
class WakeStartResult(Result):
"""``started: false`` carries ``reason`` (unavailable / disabled / disabled_for_surface / owned);
``sample_rate`` / ``frame_length`` describe the PCM frames the armed detector expects."""
started: bool
reason: str | None = None
hint: str | None = None
phrase: str | None = None
provider: str | None = None
owner_surface: str | None = None
enabled_persisted: bool | None = None
capture: str | None = None
sample_rate: int | None = None
frame_length: int | None = None
method("wake.start", params=WakeStartParams, result=WakeStartResult,
doc="Arm the wake-word listener for the calling surface; refusals explain why.")
class WakeStopParams(Params):
persist: bool | None = None
profile: str | None = None
class WakeOwnerResult(Result):
"""``methods_voice.py::_owner_result`` — ``reason: "not_owner"`` when the caller does not hold
the listener."""
reason: str | None = None
class WakeStopResult(WakeOwnerResult):
stopped: bool
disabled_persisted: bool
method("wake.stop", params=WakeStopParams, result=WakeStopResult,
doc="Stop this surface's listener; persist also writes wake_word.enabled: false.")
class WakeControlParams(Params):
profile: str | None = None
class WakePauseResult(WakeOwnerResult):
paused: bool
method("wake.pause", params=WakeControlParams, result=WakePauseResult,
doc="Release the mic (e.g. while the desktop's browser captures audio).")
class WakeResumeResult(WakeOwnerResult):
resumed: bool
method("wake.resume", params=WakeControlParams, result=WakeResumeResult,
doc="Reclaim the mic after a pause; no-op if the listener isn't armed.")
class WakeStatusParams(Params):
surface: str | None = None
client_capture: bool | None = None
profile: str | None = None
class WakeInputDevice(OpenModel):
"""``tools/wake_word.py::_describe_input_device`` — PortAudio diagnostics for the configured mic."""
selector: int | str | None = None
name: str | None = None
error: str | None = None
max_input_channels: int | None = None
default_samplerate: float | None = None
hostapi_index: int | None = None
hostapi: str | None = None
class WakeStatusResult(Result):
"""``enabled`` is config truth; ``listening`` is this caller's armed detector; ``audio_silent``
means armed but deaf (see ``hint``)."""
listening: bool
owned_by_caller: bool
owner_surface: str | None = None
phrase: str
provider: str
configured_surface: str
input_device: WakeInputDevice
available: bool
hint: str
enabled: bool
audio_silent: bool
capture: str
local_input_available: bool
sample_rate: int
frame_length: int
method("wake.status", params=WakeStatusParams, result=WakeStatusResult,
doc="Everything a client needs to draw the wake-word state and decide whether to (re)arm.")
class WakeFeedParams(Params):
"""``pcm`` (or the ``pcm_b64`` alias): base64 int16 mono little-endian, 16 kHz only."""
pcm: str | None = None
pcm_b64: str | None = None
sample_rate: int | None = None
profile: str | None = None
class WakeFeedResult(WakeOwnerResult):
fed: bool # false with reason "empty" / "not_owner"
method("wake.feed", params=WakeFeedParams, result=WakeFeedResult,
doc="Push client-captured PCM into the armed detector (mic-less remote backends).")
+680 -1
View File
@@ -1 +1,680 @@
"""Contracts: sessions (authored by the contract worker)."""
"""Session lifecycle contracts (``tui_gateway/methods_session.py``): create / resume / activate /
close, the live-session snapshot those share, history + compression + undo, mid-turn corrections,
listing/browsing stored rows, spawn-tree snapshots, event replay and the stateless one-shot LLM call.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionLiveInfo, SessionParams, TranscriptMessage, Usage
from .registry import method
# ── shared live-session snapshot ──────────────────────────────────────────────────────────────
class OpenRequestEntry(Result):
"""One unanswered server→client request (``server_requests.Request.snapshot``); the reconnecting
client re-delivers it to its request handlers."""
id: str
method: str
params: dict[str, JsonValue]
class InflightTurn(Result):
"""``session_auto_continue._inflight_snapshot``: the live (or retained failed) turn a reconnecting
client rebuilds its bubbles from."""
assistant: str = ""
streaming: bool = False
user: str = ""
corrections: list[str] | None = None
correction_offsets: list[int] | None = None
error: str | None = None
status: str | None = None
recoverable: bool | None = None
error_surface: dict[str, JsonValue] | None = None
class QueuedPrompt(Result):
user: str
class TodoState(Result):
"""``tool_progress._normalize_todo_state``: the authoritative todo snapshot."""
todos: list[dict[str, JsonValue]]
revision: int
class AutoContinue(Result):
"""A crash-interrupted turn was scheduled to continue right after this resume."""
attempt: int
interrupted_at: float
class PendingApproval(OpenModel): # TODO(common): twin of server_requests.ApprovalRequestParams minus session_id
"""``server._approval_request_payload`` for the oldest unresolved approval (command redacted)."""
request_id: str | None = None
command: str | None = None
description: str | None = None
choices: list[str] | None = None
allow_permanent: bool | None = None
allow_session: bool | None = None
smart_denied: bool | None = None
tool_name: str | None = None
class LiveSessionStatus(WireEnum):
idle = "idle"
starting = "starting"
waiting = "waiting"
working = "working"
streaming = "streaming" # lazy watch window whose child run is active
resuming = "resuming" # deferred hydration in flight
class LiveSessionSnapshot(Result):
"""Union of ``server._live_session_payload``, ``methods_session._resume_response`` and the
live-unpersisted resume path; keys only some paths produce are optional."""
session_id: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
stored_session_id: str | None = None
resumed: str | None = None
session_key: str | None = None
messages_omitted: bool | None = None
hydrating: bool | None = None
running: bool | None = None
turn_started_at: float | None = None
started_at: float | None = None
status: str | None = None # a LiveSessionStatus value
inflight: InflightTurn | None = None
queued: QueuedPrompt | None = None
pending_approval: PendingApproval | None = None
open_requests: list[OpenRequestEntry] | None = None
todo_state: TodoState | None = None
auto_continue: AutoContinue | None = None
# ── session.create ────────────────────────────────────────────────────────────────────────────
class SeedMessage(Params):
"""One create-time transcript row (``session_history._coerce_seed_history``); ``text`` is the
legacy alias of ``content``; only ``display_kind: "hidden"`` is accepted from the wire."""
role: str
content: str | None = None
text: str | None = None
display_kind: str | None = None
class SessionCreateParams(ProfileParams):
cols: int | None = None
source: str | None = None
cwd: str | None = None
messages: list[SeedMessage] | None = None
parent_session_id: str | None = None
title: str | None = None
model: str | None = None
provider: str | None = None
reasoning_effort: str | None = None
fast: bool | None = None # presence is the contract: omitted inherits, true pins priority, false pins normal
close_on_disconnect: bool = False
hidden: bool = False
room_plumbing: bool = False
follow_profile_config: bool = False
class SessionCreateResult(Result):
session_id: str
stored_session_id: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
method("session.create", params=SessionCreateParams, result=SessionCreateResult,
doc="Mint a live session (agent builds after the reply); a DB row appears on the first prompt unless seeded.")
# ── session.resume / activate ─────────────────────────────────────────────────────────────────
class SessionResumeParams(SessionParams):
"""``session_id`` is the STORED id (or an exact title); the reply's ``session_id`` is the runtime id."""
cols: int | None = None
source: str | None = None
lazy: bool = False
defer_history: bool = False
omit_messages: bool = False
eager_build: bool = False
close_on_disconnect: bool = False
class SessionResumeResult(LiveSessionSnapshot):
pass
method("session.resume", params=SessionResumeParams, result=SessionResumeResult,
doc="Attach to a stored session: reuse it if live here, else lazy / deferred / cold / eager rebuild.")
class SessionActivateParams(SessionParams):
cols: int | None = None # sent by the desktop; the handler keeps the session's current width
omit_messages: bool = False
class SessionActivateResult(LiveSessionSnapshot):
pass
method("session.activate", params=SessionActivateParams, result=SessionActivateResult,
doc="Attach the frontend to a live session without closing the previously focused one.")
# ── listing ───────────────────────────────────────────────────────────────────────────────────
class SessionListParams(ProfileParams):
title: str | None = None # exact-title lookup (title as identity); windowless
limit: int | None = None
include_hidden: bool = False
class SessionListRow(Result):
"""``methods_session._session_row_summary``; ``resolved_id`` only on a title lookup that followed a
compression lineage to its tip."""
id: str
resolved_id: str | None = None
title: str = ""
preview: str = ""
started_at: float = 0
message_count: int = 0
source: str = ""
class SessionListResult(Result):
sessions: list[SessionListRow]
method("session.list", params=SessionListParams, result=SessionListResult,
doc="Human-facing stored sessions, most recent first (sub-agent / kanban sources denied).")
class SessionMostRecentParams(ProfileParams):
pass
class SessionMostRecentResult(Result):
session_id: str | None
title: str | None = None
started_at: float | None = None
source: str | None = None
method("session.most_recent", params=SessionMostRecentParams, result=SessionMostRecentResult,
doc="Most recent human-facing session; errors fold into a null session_id.")
class SessionActiveListParams(ProfileParams):
current_session_id: str | None = None
class SessionActiveItem(Result):
"""``server._session_live_item``."""
current: bool
id: str
last_active: float
message_count: int
model: str
preview: str
session_key: str
started_at: float
status: LiveSessionStatus
title: str
class SessionActiveListResult(Result):
sessions: list[SessionActiveItem]
method("session.active_list", params=SessionActiveListParams, result=SessionActiveListResult,
doc="Live sessions in this process, insertion order (not a DB browser).")
# ── stored-row mutation ───────────────────────────────────────────────────────────────────────
class SessionDeleteParams(SessionParams):
"""``session_id`` is the STORED id."""
class SessionDeleteResult(Result):
deleted: str
method("session.delete", params=SessionDeleteParams, result=SessionDeleteResult,
doc="Delete a stored session + transcripts; refused while it is live here.")
class SessionTitleParams(SessionParams):
title: str | None = None # absent: read; present: set (non-empty)
class SessionTitleResult(Result):
title: str
session_key: str | None = None
pending: bool | None = None # True: no row yet, applied on the first turn
method("session.title", params=SessionTitleParams, result=SessionTitleResult,
doc="Read or set a live session's title; a title set before the row exists is queued.")
class SessionSetHiddenParams(Params):
"""``session_id`` is a live runtime id first, else a stored id / key / title."""
session_id: str
hidden: bool = True
profile: str | None = None
class SessionSetHiddenResult(Result):
hidden: bool
session_key: str
method("session.set_hidden", params=SessionSetHiddenParams, result=SessionSetHiddenResult,
doc="Set/clear hidden (out of the default list, still resumable by its owner) on a session + lineage.")
class SessionWorkspaceMoveParams(ProfileParams):
session_key: str
cwd: str
class SessionWorkspaceMoveResult(Result):
cwd: str
branch: str | None = None
git_repo_root: str | None = None
method("session.workspace.move", params=SessionWorkspaceMoveParams, result=SessionWorkspaceMoveResult,
doc="Re-home a stored session's workspace; git identity is replaced and a live agent follows.")
class SessionCwdSetParams(SessionParams):
cwd: str
class SessionCwdSetResult(SessionLiveInfo):
"""The refreshed ``session.info`` view (full agent view, or the lazy shape)."""
method("session.cwd.set", params=SessionCwdSetParams, result=SessionCwdSetResult,
doc="Change a live, idle session's working directory.")
# ── live-session lifecycle ────────────────────────────────────────────────────────────────────
class SessionCloseParams(SessionParams):
pass
class SessionCloseResult(Result):
closed: bool # False when the runtime id was already gone
method("session.close", params=SessionCloseParams, result=SessionCloseResult,
doc="Tear down a live session (its stored row stays resumable).")
class SessionBranchParams(SessionParams):
name: str | None = None
count: int | None = None # keep only the first N rows of the source history
class SessionBranchResult(Result):
session_id: str
stored_session_id: str
title: str
parent: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
method("session.branch", params=SessionBranchParams, result=SessionBranchResult,
doc="Fork a live session into a new stored child that shares the parent's history so far.")
class SessionUndoParams(SessionParams):
pass
class SessionUndoResult(Result):
removed: int
method("session.undo", params=SessionUndoParams, result=SessionUndoResult,
doc="Drop the last user turn (and everything after it) from an idle session.")
class SessionSaveParams(SessionParams):
pass
class SessionSaveResult(Result):
"""Under turn isolation the compute host's result passes through verbatim."""
file: str | None = None
model_config = Result.model_config | {"extra": "allow"}
method("session.save", params=SessionSaveParams, result=SessionSaveResult,
doc="Export the transcript to ~/.hermes/sessions/saved (classic /save).")
class SessionStatusParams(SessionParams):
pass
class SessionStatusResult(Result):
output: str
method("session.status", params=SessionStatusParams, result=SessionStatusResult,
doc="Rendered /status text for the session.")
class SessionHistoryParams(SessionParams):
pass
class SessionHistoryResult(Result):
count: int
messages: list[TranscriptMessage]
method("session.history", params=SessionHistoryParams, result=SessionHistoryResult,
doc="The durable display transcript (ancestors included, row ids attached).")
class SessionUsageParams(SessionParams):
pass
class SessionUsageResult(Usage):
credits_lines: list[str] | None = None
method("session.usage", params=SessionUsageParams, result=SessionUsageResult,
doc="Token / context / cost counters for the session (+ Nous credit lines when available).")
class SessionContextBreakdownParams(SessionParams):
pass
class ContextCategory(Result):
color: str
id: str
label: str
tokens: int
class SessionContextBreakdownResult(Result):
"""``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)."""
categories: list[ContextCategory]
context_max: int
context_percent: int
context_used: int
estimated_total: int
context_estimated: bool
context_source: str
model: str
method("session.context_breakdown", params=SessionContextBreakdownParams, result=SessionContextBreakdownResult,
doc="Cursor-style split of the context window by category.")
# ── compression ───────────────────────────────────────────────────────────────────────────────
class CompressionSummary(OpenModel):
"""``agent.manual_compression_feedback.summarize_manual_compression``."""
noop: bool = False
aborted: bool = False
refused_would_grow: bool | None = None
fallback_used: bool | None = None
headline: str = ""
token_line: str = ""
note: str | None = None
class SessionCompressParams(SessionParams):
focus_topic: str | None = None
class SessionCompressResult(Result):
"""In-process: the before/after summary + replacement transcript. Compute host: its result passes
through (hence open) with ``turn_isolation``; a lock held elsewhere answers ``compressed: false``."""
status: str | None = None # compressed | aborted | pending
removed: int | None = None
before_messages: int | None = None
after_messages: int | None = None
before_tokens: int | None = None
after_tokens: int | None = None
summary: CompressionSummary | None = None
usage: Usage | None = None
info: SessionLiveInfo | None = None
messages: list[TranscriptMessage] | None = None
compressed: bool | None = None
lock_held: bool | None = None
message: str | None = None
turn_isolation: bool | None = None
host_ack: dict[str, JsonValue] | None = None
model_config = Result.model_config | {"extra": "allow"}
method("session.compress", params=SessionCompressParams, result=SessionCompressResult,
doc="Manual /compress of an idle session, optionally focused on a topic.")
# ── interrupt / steer / redirect ──────────────────────────────────────────────────────────────
class SessionInterruptParams(SessionParams):
expected_hosted_task_id: str | None = None # only interrupt if this hosted task is the running one
class InterruptStatus(WireEnum):
interrupted = "interrupted"
not_interrupted = "not_interrupted"
class SessionInterruptResult(Result):
status: InterruptStatus
interrupted: bool | None = None
turn_isolation: bool | None = None
method("session.interrupt", params=SessionInterruptParams, result=SessionInterruptResult,
doc="Stop the running turn (and streaming TTS); retires the crash-recovery marker.")
class CorrectionStatus(WireEnum):
queued = "queued"
redirected = "redirected"
rejected = "rejected"
class SessionCorrectionParams(SessionParams):
text: str
class SessionCorrectionResult(Result):
status: CorrectionStatus
text: str
method("session.steer", params=SessionCorrectionParams, result=SessionCorrectionResult,
doc="Inject text into the next tool result without interrupting the turn.")
method("session.redirect", params=SessionCorrectionParams, result=SessionCorrectionResult,
doc="Redirect the active turn (queued for the next turn while the agent is still building).")
# ── spawn trees ───────────────────────────────────────────────────────────────────────────────
class SpawnTreeSaveParams(ProfileParams):
subagents: list[dict[str, JsonValue]]
session_id: str | None = None # stored key; "default" when absent
started_at: float | None = None
finished_at: float | None = None
label: str | None = None
class SpawnTreeSaveResult(Result):
path: str
session_id: str
method("spawn_tree.save", params=SpawnTreeSaveParams, result=SpawnTreeSaveResult,
doc="Persist a finished delegation tree snapshot under the session's spawn-trees dir.")
class SpawnTreeListParams(ProfileParams):
session_id: str | None = None
cross_session: bool = False
limit: int | None = None
class SpawnTreeEntry(OpenModel):
"""Index row (``server._append_spawn_tree_index``) or a legacy file scan."""
path: str
session_id: str | None = None
started_at: float | None = None
finished_at: float | None = None
label: str = ""
count: int = 0
class SpawnTreeListResult(Result):
entries: list[SpawnTreeEntry]
method("spawn_tree.list", params=SpawnTreeListParams, result=SpawnTreeListResult,
doc="Saved spawn-tree snapshots, newest first.")
class SpawnTreeLoadParams(ProfileParams):
path: str
class SpawnTreeLoadResult(Result):
"""The snapshot file as written by ``spawn_tree.save`` (open: the file is the contract)."""
session_id: str | None = None
started_at: float | None = None
finished_at: float | None = None
label: str | None = None
subagents: list[dict[str, JsonValue]] = Field(default_factory=list)
model_config = Result.model_config | {"extra": "allow"}
method("spawn_tree.load", params=SpawnTreeLoadParams, result=SpawnTreeLoadResult,
doc="Read one saved spawn-tree snapshot (path must be under the spawn-trees root).")
# ── terminal / event replay ───────────────────────────────────────────────────────────────────
class TerminalResizeParams(SessionParams):
cols: int | None = None
class TerminalResizeResult(Result):
cols: int
method("terminal.resize", params=TerminalResizeParams, result=TerminalResizeResult,
doc="Record the client's column width for server-side rendering.")
class SessionEventsSinceParams(SessionParams):
last_seen: int | None = None
class SessionEventsSinceResult(Result):
events: list[dict[str, JsonValue]] # recorded event frames' ``params`` objects
latest_seq: int
truncated: bool
count: int
epoch: str
open_requests: list[OpenRequestEntry]
method("session.events.since", params=SessionEventsSinceParams, result=SessionEventsSinceResult,
doc="Replay events after a seq watermark on WS reconnect; truncated means refetch state.")
class SessionEventsStatsParams(ProfileParams):
pass
class SessionEventsStatsResult(Result):
"""``event_replay.replay_stats``."""
sessions: int
events: int
bytes: int
max_per_session: int
max_bytes_per_session: int
max_bytes_process: int
method("session.events.stats", params=SessionEventsStatsParams, result=SessionEventsStatsResult,
doc="Replay-buffer occupancy telemetry (ops/debug).")
# ── one-shot LLM ──────────────────────────────────────────────────────────────────────────────
class LlmOneshotParams(ProfileParams):
"""Needs a ``template`` or ``instructions`` / ``input``; a live ``session_id`` lends its model."""
template: str | None = None
instructions: str | None = None
input: str | None = None
variables: dict[str, JsonValue] | None = None
task: str | None = None
temperature: float | None = None
max_tokens: int | None = None
session_id: str | None = None
class LlmOneshotResult(Result):
text: str
method("llm.oneshot", params=LlmOneshotParams, result=LlmOneshotResult,
doc="Stateless one-shot LLM completion (titles, ideas) on the session's or the task backend.")
+530 -1
View File
@@ -1 +1,530 @@
"""Contracts: tools_commands (authored by the contract worker)."""
"""Contracts: system / process / slash-command / rollback / cron / browser / config RPCs
(handlers in ``tui_gateway/methods_tools.py``, browser helpers in ``methods_browser.py``).
Several results here are pass-throughs of dicts another module owns (``tools/process_registry.py``,
``tools/checkpoint_manager.py``, ``tools/cronjob_tools.py``): those declare every key the producer is
known to emit plus ``extra="allow"`` so a new upstream key never trips the strict gate.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .registry import method
class _Open(Result):
"""A pass-through row whose closed set is owned by another module."""
model_config = Result.model_config | {"extra": "allow"}
# ── system.battery ────────────────────────────────────────────────────────────────────────────
class BatteryCategory(WireEnum):
"""``agent/battery.py::battery_category`` colour bucket."""
good = "good"
warn = "warn"
bad = "bad"
critical = "critical"
dim = "dim"
class SystemBatteryParams(Params):
profile: str | None = None
class SystemBatteryResult(Result):
available: bool
percent: int | None = None
plugged: bool | None = None
category: BatteryCategory = BatteryCategory.dim
method("system.battery", params=SystemBatteryParams, result=SystemBatteryResult,
doc="Host battery for the status bar; always resolves, ``available: false`` when unreadable.")
# ── process.* / agents.list ───────────────────────────────────────────────────────────────────
class ProcessStopParams(Params):
session_id: str | None = None
profile: str | None = None
class ProcessStopResult(Result):
killed: int
method("process.stop", params=ProcessStopParams, result=ProcessStopResult,
doc="Kill every background process in the registry (``/stop``), answering the count killed.")
class AgentsListParams(Params):
profile: str | None = None
class AgentProcessRow(Result):
session_id: str
command: str
status: str
uptime: int
class AgentsListResult(Result):
processes: list[AgentProcessRow] = Field(default_factory=list)
method("agents.list", params=AgentsListParams, result=AgentsListResult,
doc="Registry-wide background process summary for ``/agents``.")
class ProcessListParams(Params):
session_id: str
profile: str | None = None
class ProcessEntry(_Open):
"""``tools/process_registry.py::list_sessions`` row plus the gateway's ``output_tail``."""
session_id: str
command: str = ""
cwd: str | None = None
pid: int | None = None
owner_task_id: str | None = None
started_at: str | None = None
uptime_seconds: int | None = None
status: str = "running"
output_preview: str = ""
output_tail: str | None = None
session_scoped: bool | None = None
watch_patterns: list[str] | None = None
watch_hit: bool | None = None
notify_on_complete: bool | None = None
exit_code: int | None = None
detached: bool | None = None
class ProcessListResult(Result):
processes: list[ProcessEntry] = Field(default_factory=list)
method("process.list", params=ProcessListParams, result=ProcessListResult,
doc="Background processes owned by the caller's session (desktop status stack poll).")
class ProcessKillParams(Params):
session_id: str
process_id: str
profile: str | None = None
class ProcessKillStatus(WireEnum):
killed = "killed"
already_exited = "already_exited"
not_found = "not_found"
error = "error"
class ProcessKillResult(_Open):
"""``tools/process_registry.py::kill_process`` snapshot; ``error`` rides on the failure statuses."""
status: ProcessKillStatus
session_id: str | None = None
command: str | None = None
exit_code: int | None = None
completion_reason: str | None = None
termination_source: str | None = None
output: str | None = None
error: str | None = None
method("process.kill", params=ProcessKillParams, result=ProcessKillResult,
doc="Kill one background process the caller's session owns and return its output snapshot.")
# ── shell.exec / cli.exec ─────────────────────────────────────────────────────────────────────
class ShellExecParams(Params):
command: str
profile: str | None = None
class ShellExecResult(Result):
stdout: str
stderr: str
code: int
method("shell.exec", params=ShellExecParams, result=ShellExecResult,
doc="Run a safe (non-dangerous) shell command captured for ``!cmd`` / inline substitution.")
class CliExecParams(Params):
argv: list[str]
timeout: int | None = None
profile: str | None = None
class CliExecResult(Result):
blocked: bool
code: int
output: str
hint: str | None = None
method("cli.exec", params=CliExecParams, result=CliExecResult,
doc="Run ``hermes <argv>`` non-interactively and capture its output; ``blocked`` explains a refusal.")
# ── command catalog / resolve / dispatch / slash.exec ─────────────────────────────────────────
class CommandsCatalogParams(Params):
session_id: str | None = None
profile: str | None = None
class ArgumentMode(WireEnum):
options = "options"
text = "text"
mixed = "mixed"
class CommandCatalogMeta(Result):
argument_mode: ArgumentMode | None = None
desktop: str | None = None
class CommandCategory(Result):
name: str
pairs: list[list[str]] = Field(default_factory=list)
class SkillCatalogEntry(Result):
usage: int = 0
origin: str = "local"
class CommandsCatalogResult(Result):
pairs: list[list[str]] = Field(default_factory=list)
sub: dict[str, list[str]] = Field(default_factory=dict)
canon: dict[str, str] = Field(default_factory=dict)
commands: dict[str, CommandCatalogMeta] = Field(default_factory=dict)
categories: list[CommandCategory] = Field(default_factory=list)
skills: dict[str, SkillCatalogEntry] = Field(default_factory=dict)
skill_count: int = 0
warning: str = ""
method("commands.catalog", params=CommandsCatalogParams, result=CommandsCatalogResult,
doc="Categorized slash metadata (registry, quick, plugin, skill) for completion menus.")
class CommandResolveParams(Params):
name: str | None = None
profile: str | None = None
class CommandResolveResult(Result):
canonical: str
description: str
category: str
method("command.resolve", params=CommandResolveParams, result=CommandResolveResult,
doc="Canonical registry command for a name or alias.")
class DispatchType(WireEnum):
"""``apps/shared/src/slash.ts::parseCommandDispatch`` branches on this."""
exec = "exec"
alias = "alias"
plugin = "plugin"
send = "send"
skill = "skill"
prefill = "prefill"
class CommandDispatchParams(Params):
name: str
arg: str | None = None
session_id: str | None = None
profile: str | None = None
class CommandDispatchResult(Result):
"""One structured directive: ``exec``/``plugin`` carry ``output``; ``alias`` a ``target``;
``send``/``prefill``/``skill`` a ``message`` (UIs render ``display``, never ``message``)."""
type: DispatchType
output: str | None = None
target: str | None = None
message: str | None = None
notice: str | None = None
display: str | None = None
name: str | None = None
status: str | None = None
method("command.dispatch", params=CommandDispatchParams, result=CommandDispatchResult,
doc="Run a quick/plugin/bundle/skill/built-in slash command and answer a structured directive.")
class SlashExecParams(Params):
session_id: str
command: str
profile: str | None = None
class SlashExecResult(Result):
"""Plain worker/plugin text in ``output`` (+ ``warning``), or — when the command was rerouted to
``command.dispatch`` — that method's directive fields with ``type`` set."""
output: str | None = None
warning: str | None = None
type: DispatchType | None = None
target: str | None = None
message: str | None = None
notice: str | None = None
display: str | None = None
name: str | None = None
status: str | None = None
method("slash.exec", params=SlashExecParams, result=SlashExecResult,
doc="Execute a slash command against the session's slash worker (or a live/plugin shortcut).")
# ── insights.get / config.show ────────────────────────────────────────────────────────────────
class InsightsGetParams(Params):
days: int | None = None
profile: str | None = None
class InsightsGetResult(Result):
days: int
sessions: int
messages: int
method("insights.get", params=InsightsGetParams, result=InsightsGetResult,
doc="Session/message counts over the last ``days`` for the (optionally scoped) profile store.")
class ConfigShowParams(Params):
profile: str | None = None
class ConfigSection(Result):
title: str
rows: list[list[str]] = Field(default_factory=list)
class ConfigShowResult(Result):
model_config = Result.model_config | {"extra": "allow"}
sections: list[ConfigSection] = Field(default_factory=list)
method("config.show", params=ConfigShowParams, result=ConfigShowResult,
doc="Masked, display-ready config summary (model / agent / environment rows).")
# ── rollback.* ────────────────────────────────────────────────────────────────────────────────
class RollbackListParams(Params):
session_id: str
profile: str | None = None
class RollbackCheckpoint(Result):
hash: str = ""
timestamp: str = ""
message: str = ""
class RollbackListResult(Result):
enabled: bool
checkpoints: list[RollbackCheckpoint] = Field(default_factory=list)
method("rollback.list", params=RollbackListParams, result=RollbackListResult,
doc="Checkpoints for the session's cwd; ``enabled: false`` when checkpointing is off.")
class RollbackRestoreParams(Params):
session_id: str
hash: str
file_path: str | None = None
profile: str | None = None
class RollbackRestoreResult(_Open):
"""``tools/checkpoint_manager.py::restore`` outcome; ``history_removed`` is added for a full
(non-file) restore that also rewound the live transcript."""
success: bool
restored_to: str | None = None
reason: str | None = None
directory: str | None = None
file: str | None = None
restored_files: list[str] | None = None
skipped_user_edits: list[str] | None = None
skipped_oversize: list[str] | None = None
failed_deletes: list[str] | None = None
history_removed: int | None = None
error: str | None = None
debug: JsonValue | None = None
method("rollback.restore", params=RollbackRestoreParams, result=RollbackRestoreResult,
doc="Restore the working tree (or one file) to a checkpoint by hash or 1-based index.")
class RollbackDiffParams(Params):
session_id: str
hash: str
profile: str | None = None
class RollbackDiffResult(Result):
stat: str = ""
diff: str = ""
rendered: str | None = None
method("rollback.diff", params=RollbackDiffParams, result=RollbackDiffResult,
doc="Diff between a checkpoint and the working tree, with an ANSI rendering sized to the TUI.")
# ── cron.manage ───────────────────────────────────────────────────────────────────────────────
class CronAction(WireEnum):
list = "list"
add = "add"
remove = "remove"
pause = "pause"
resume = "resume"
class CronManageParams(Params):
action: CronAction = CronAction.list
name: str | None = None
include_disabled: bool | str | None = None
schedule: str | None = None
prompt: str | None = None
repeat: int | str | None = None
continuity: bool | str | None = None
deliver: str | None = None
profile: str | None = None
class CronJobRow(_Open):
"""``tools/cronjob_job_args.py::_format_job``."""
job_id: str
name: str = ""
skill: str | None = None
skills: list[str] = Field(default_factory=list)
prompt_preview: str = ""
model: str | None = None
provider: str | None = None
base_url: str | None = None
schedule: str = "?"
repeat: int | str | None = None
deliver: str | None = None
next_run_at: str | None = None
last_run_at: str | None = None
last_status: str | None = None
last_delivery_error: str | None = None
last_delivery_unverified: bool | None = None
last_fire_error: str | None = None
last_error: str | None = None
enabled: bool = True
state: str | None = None
paused_at: str | None = None
paused_reason: str | None = None
workdir: str | None = None
script: str | None = None
reasoning_effort: str | None = None
monitor_script: str | None = None
monitor_url: str | None = None
monitor_state: JsonValue | None = None
no_agent: bool | None = None
enabled_toolsets: list[str] | None = None
continuity: bool | None = None
context_from: list[str] | None = None
attach_to_session: bool | None = None
class CronRemovedJob(Result):
id: str
name: str = ""
schedule: str | None = None
class CronManageResult(_Open):
"""Pass-through of ``tools/cronjob_tools.py::cronjob`` JSON: ``list`` → ``jobs``/``count``
(+ ``scoped`` when profile-scoped); ``add`` → the created job's summary + ``job``; ``remove`` →
``removed_job``; ``pause``/``resume`` → ``job``. A tool-level failure lands in ``error``."""
success: bool | None = None
error: str | None = None
count: int | None = None
jobs: list[CronJobRow] | None = None
scoped: str | None = None
gateway_running: bool | None = None
warning: str | None = None
job_id: str | None = None
name: str | None = None
skill: str | None = None
skills: list[str] | None = None
schedule: str | None = None
repeat: int | str | None = None
deliver: str | None = None
next_run_at: str | None = None
job: CronJobRow | None = None
message: str | None = None
guidance: JsonValue | None = None
removed_job: CronRemovedJob | None = None
method("cron.manage", params=CronManageParams, result=CronManageResult,
doc="List/add/remove/pause/resume cron jobs in the (optionally profile-scoped) cron store.")
# ── browser.manage ────────────────────────────────────────────────────────────────────────────
class BrowserAction(WireEnum):
status = "status"
connect = "connect"
disconnect = "disconnect"
class BrowserManageParams(Params):
action: BrowserAction = BrowserAction.status
url: str | None = None
session_id: str | None = None
profile: str | None = None
class BrowserManageResult(Result):
connected: bool
url: str | None = None
messages: list[str] | None = None
method("browser.manage", params=BrowserManageParams, result=BrowserManageResult,
doc="Inspect, attach to, or drop the CDP browser the tools use; ``messages`` narrate a connect.")
+629 -1
View File
@@ -1 +1,629 @@
"""Contracts: tools_mcp_plugins (authored by the contract worker)."""
"""Tools / toolsets / MCP servers / plugins / skills / learning graph / reload contracts
(``tui_gateway/methods_tools.py``).
Every ``mcp.servers.*``, ``mcp.catalog``, ``skills.manage`` and ``plugins.manage`` handler runs under
``_profile_scoped_rpc`` and the desktop routes them via ``requestGatewayForProfile`` /
``requestForBot``, so all of them accept the optional ``profile`` key.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import OpenModel, ProfileParams, SessionLiveInfo
from .registry import method
class _SessionScoped(Params):
"""Handlers that look a live session up with ``_sessions.get(params.get("session_id"))``: an
absent / unknown id falls back to the launch profile's config, so it is never required."""
session_id: str | None = None
# ── tools / toolsets ──────────────────────────────────────────────────────────────────────────
class ToolsetRow(Result):
"""One row of ``methods_tools._toolset_rows``; ``tools`` only when the caller asked for them
(``tools.list``)."""
name: str
description: str
tool_count: int
enabled: bool
tools: list[str] | None = None
class ToolsetsListResult(Result):
toolsets: list[ToolsetRow]
method("tools.list", params=_SessionScoped, result=ToolsetsListResult,
doc="Every toolset with its resolved tool names, flagged against the session's (or config's) enabled set.")
method("toolsets.list", params=_SessionScoped, result=ToolsetsListResult,
doc="Toolset summaries (no tool names) for the desktop Toolsets tab.")
class ToolShowRow(Result):
name: str
description: str
class ToolShowSection(Result):
name: str
tools: list[ToolShowRow]
class ToolsShowResult(Result):
sections: list[ToolShowSection]
total: int
method("tools.show", params=_SessionScoped, result=ToolsShowResult,
doc="The /tools listing grouped by toolset, including tools deferred behind the tool_search bridge.")
class ToolsAction(WireEnum):
enable = "enable"
disable = "disable"
class ToolsConfigureParams(Params):
"""``names`` are toolset keys or ``server:tool`` MCP targets; with ``session_id`` the live session's
profile is authoritative and its agent is rebuilt."""
action: ToolsAction
names: list[str]
session_id: str | None = None
profile: str | None = None
class ToolsConfigureResult(Result):
changed: list[str]
enabled_toolsets: list[str]
info: SessionLiveInfo | None = None
missing_servers: list[str]
reset: bool
unknown: list[str]
method("tools.configure", params=ToolsConfigureParams, result=ToolsConfigureResult,
doc="Persist a toolset / MCP enable-disable change and rebuild the session agent so it takes effect now.")
# ── reload ────────────────────────────────────────────────────────────────────────────────────
class ReloadEnvParams(Params):
pass
class ReloadEnvResult(Result):
updated: int
method("reload.env", params=ReloadEnvParams, result=ReloadEnvResult,
doc="Re-read ~/.hermes/.env (CLI /reload parity); built agents keep their pool until /new.")
class ReloadMcpParams(Params):
"""Without ``confirm`` the handler may answer ``confirm_required`` (per ``approvals.mcp_reload_confirm``);
``always`` persists the opt-out; ``rev`` is the config revision the caller wants loaded (coalescing)."""
session_id: str | None = None
confirm: bool = False
always: bool = False
rev: str | None = None
class ReloadMcpStatus(WireEnum):
confirm_required = "confirm_required"
reloaded = "reloaded"
class ReloadMcpResult(Result):
status: ReloadMcpStatus
message: str | None = None
loaded_rev: str | None = None
coalesced: bool | None = None
turn_isolation: bool | None = None
host_ack: JsonValue | None = None
method("reload.mcp", params=ReloadMcpParams, result=ReloadMcpResult,
doc="Tear down and rediscover MCP servers for every live session (prompt cache is invalidated).")
# ── skills ────────────────────────────────────────────────────────────────────────────────────
class SkillsAction(WireEnum):
list = "list"
search = "search"
install = "install"
browse = "browse"
inspect = "inspect"
class SkillsManageParams(ProfileParams):
"""``query`` is the search text / hub identifier / browse page (digits); ``page`` / ``page_size``
apply to ``browse``."""
action: SkillsAction = SkillsAction.list
query: str | None = None
page: int | None = None
page_size: int | None = None
class SkillHubHit(Result):
name: str
description: str
class SkillBrowseItem(OpenModel):
"""``hermes_cli.skills_hub.browse_skills`` row."""
name: str = ""
description: str = ""
source: str = ""
trust: str | None = None
identifier: str | None = None
class SkillInspectInfo(OpenModel):
"""``hermes_cli.skills_hub.inspect_skill``; ``{}`` when the identifier resolves nowhere."""
name: str | None = None
description: str | None = None
source: str | None = None
identifier: str | None = None
tags: list[str] | None = None
skill_md_preview: str | None = None
class SkillsManageResult(Result):
"""Shape follows the action: ``list`` → ``skills`` (category → names); ``search`` → ``results``;
``install`` → ``installed`` + ``name``; ``browse`` → ``items`` + paging; ``inspect`` → ``info``."""
skills: dict[str, list[str]] | None = None
results: list[SkillHubHit] | None = None
installed: bool | None = None
name: str | None = None
items: list[SkillBrowseItem] | None = None
page: int | None = None
total_pages: int | None = None
total: int | None = None
info: SkillInspectInfo | None = None
method("skills.manage", params=SkillsManageParams, result=SkillsManageResult,
doc="Skills hub backend: list the profile's skills or search / browse / inspect / install from the hub.")
class SkillsReloadParams(Params):
pass
class SkillCommandRef(Result):
name: str
description: str = ""
class SkillsReloadDiff(OpenModel):
"""``agent.skill_commands.reload_skills``."""
added: list[SkillCommandRef] = Field(default_factory=list)
removed: list[SkillCommandRef] = Field(default_factory=list)
unchanged: list[str] = Field(default_factory=list)
total: int = 0
commands: int = 0
class SkillsReloadResult(Result):
output: str
result: SkillsReloadDiff
method("skills.reload", params=SkillsReloadParams, result=SkillsReloadResult,
doc="Re-scan skill dirs; the pre-rendered ``output`` is what /reload-skills prints.")
# ── learning graph (/journey) ─────────────────────────────────────────────────────────────────
class LearningFramesParams(Params):
cols: int | None = None
rows: int | None = None
frames: int | None = None
class LearningFrame(Result):
"""``agent.learning_graph_render.render_graph`` projection; ``grid`` rows are lists of
``[text, styleKey, alpha?, hexOverride?]`` runs."""
reveal: float
date: str
visible: int
grid: list[list[JsonValue]]
labels: list[dict[str, JsonValue]] = Field(default_factory=list)
class LearningLegendItem(Result):
glyph: str
label: str
style: str | None = None
color: str | None = None
class LearningNodeRow(Result):
id: str
glyph: str
label: str
fullLabel: str # noqa: N815 — wire key from learning_graph_render._bucket_rows
meta: str
body: str
style: str
class LearningBucketRow(Result):
index: int
label: str
date: str
skills: int
memories: int
total: int
category: str | None = None
color: str | None = None
nodes: list[LearningNodeRow]
class LearningAxis(Result):
start: str
end: str
class LearningFramesResult(Result):
frames: list[LearningFrame]
legend: list[LearningLegendItem]
categories: list[LearningLegendItem]
buckets: list[LearningBucketRow]
summary: list[str]
axis: LearningAxis
count: int
cols: int
rows: int
method("learning.frames", params=LearningFramesParams, result=LearningFramesResult,
doc="Pre-render the /journey timeline (frames + legend/summary) so the TUI walks it locally.")
class LearningNodeParams(Params):
id: str | None = None
class LearningEditParams(LearningNodeParams):
content: str | None = None
class LearningMutationResult(Result):
"""``agent.learning_mutations`` — ``ok: false`` carries the reason in ``message``."""
ok: bool
message: str | None = None
class LearningDetailResult(LearningMutationResult):
kind: str | None = None
id: str | None = None
label: str | None = None
content: str | None = None
method("learning.detail", params=LearningNodeParams, result=LearningDetailResult,
doc="Node content (SKILL.md or memory chunk) for an edit prefill.")
method("learning.delete", params=LearningNodeParams, result=LearningMutationResult,
doc="Archive a skill (restorable via curator) or remove a memory chunk.")
method("learning.edit", params=LearningEditParams, result=LearningMutationResult,
doc="Rewrite a node's content (SKILL.md or memory chunk).")
# ── MCP catalog + per-profile server lifecycle ────────────────────────────────────────────────
class McpCatalogEntry(Result):
name: str
description: str
installed: bool
enabled: bool
requires: list[str]
transport: str
class McpCatalogResult(Result):
servers: list[McpCatalogEntry]
method("mcp.catalog", params=ProfileParams, result=McpCatalogResult,
doc="Curated MCP presets with per-profile installed/enabled state and the env keys each needs.")
class McpServerSummary(Result):
"""``tui_gateway/mcp_rpc_helpers.summarize_server`` — a server's config without secret values."""
name: str
transport: str
url: str | None = None
command: str | None = None
args: list[str]
env: list[str]
auth: str | None = None
oauth_tokens_present: bool | None = None
enabled: bool
tools: JsonValue | None = None
class McpServersListResult(Result):
servers: list[McpServerSummary]
method("mcp.servers.list", params=ProfileParams, result=McpServersListResult,
doc="Configured MCP servers for the (scoped) profile, secrets redacted to env-key names.")
class McpRuntimeStatus(WireEnum):
connected = "connected"
disabled = "disabled"
connecting = "connecting"
failed = "failed"
configured = "configured"
class McpServerRuntimeRow(Result):
"""Safe projection of ``tools.mcp_tool_discovery.get_mcp_status`` rows."""
name: str
transport: str
tools: int
connected: bool
disabled: bool
status: McpRuntimeStatus
class McpServersStatusResult(Result):
servers: list[McpServerRuntimeRow]
checked_at: int
method("mcp.servers.status", params=ProfileParams, result=McpServersStatusResult,
doc="Cached runtime state per configured server; never connects, probes, or starts auth.")
class McpServerNameParams(ProfileParams):
name: str
class McpServersAddParams(McpServerNameParams):
"""``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/tools); a
``bearer_token`` is written to the profile's .env, only the header template persists."""
preset: str | None = None
config: dict[str, JsonValue] | None = None
bearer_token: str | None = None
class McpServersAddResult(Result):
ok: bool
name: str
server: McpServerSummary
method("mcp.servers.add", params=McpServersAddParams, result=McpServersAddResult,
doc="Add a server to the profile's config from a catalog preset and/or an explicit config.")
class McpServersSetApiKeyParams(McpServerNameParams):
value: str
env_var: str | None = None
class McpServersSetApiKeyResult(Result):
ok: bool
name: str
env_var: str
server: McpServerSummary
method("mcp.servers.set_api_key", params=McpServersSetApiKeyParams, result=McpServersSetApiKeyResult,
doc="Store a credential in the profile's .env and reference it from the server config (header or env).")
class McpProbeTool(Result):
name: str
description: str
class McpServersTestResult(Result):
"""``ok: false`` carries ``error``; ``prompts`` / ``resources`` are only counted on success."""
ok: bool
tools: list[McpProbeTool]
error: str | None = None
prompts: int | None = None
resources: int | None = None
oauth_needed: bool
oauth_tokens_present: bool | None = None
method("mcp.servers.test", params=McpServerNameParams, result=McpServersTestResult,
doc="Connect, list tools, disconnect — an OAuth server with no token on disk is reported as not ok.")
class McpServersRemoveResult(Result):
ok: bool
removed: bool
method("mcp.servers.remove", params=McpServerNameParams, result=McpServersRemoveResult,
doc="Drop a server from the profile's config.yaml.")
class McpOauthStartParams(McpServerNameParams):
"""With ``client_redirect_uri`` the CLIENT hosts the loopback and relays the code via
``mcp.servers.oauth.callback``."""
client_redirect_uri: str | None = None
class McpOauthStartResult(Result):
ok: bool
session_id: str
auth_url: str
flow: str
method("mcp.servers.oauth.start", params=McpOauthStartParams, result=McpOauthStartResult,
doc="Begin a PKCE OAuth flow; the client opens auth_url and polls mcp.servers.oauth.poll.")
class McpOauthFlowParams(McpServerNameParams):
"""``session_id`` is the OAuth flow id returned by ``oauth.start`` (not a gateway session)."""
session_id: str
class McpOauthPollStatus(WireEnum):
pending = "pending"
approved = "approved"
error = "error"
class McpOauthPollResult(Result):
ok: bool
status: McpOauthPollStatus
session_id: str | None = None
error_message: str | None = None
auth_url: str | None = None
tools: list[McpProbeTool] | None = None
method("mcp.servers.oauth.poll", params=McpOauthFlowParams, result=McpOauthPollResult,
doc="Poll a flow; approved persists tokens for the profile and returns the probed tools.")
class McpOauthCancelResult(Result):
ok: bool
status: str | None = None
error_message: str | None = None
method("mcp.servers.oauth.cancel", params=McpOauthFlowParams, result=McpOauthCancelResult,
doc="Cancel a flow owned by the resolved profile, waking its callback worker.")
class McpOauthCallbackParams(McpOauthFlowParams):
code: str | None = None
state: str | None = None
error: str | None = None
class McpOauthCallbackResult(Result):
ok: bool
session_id: str | None = None
error_message: str | None = None
method("mcp.servers.oauth.callback", params=McpOauthCallbackParams, result=McpOauthCallbackResult,
doc="Relay a client-captured redirect into a client_redirect_uri flow.")
# ── plugins ───────────────────────────────────────────────────────────────────────────────────
class PluginsListParams(Params):
pass
class LegacyPluginRow(Result):
name: str
version: str
enabled: bool
class PluginsListResult(Result):
plugins: list[LegacyPluginRow]
method("plugins.list", params=PluginsListParams, result=PluginsListResult,
doc="Loaded plugin manager entries (legacy flat view); the Plugins Hub uses plugins.manage list.")
class PluginsAction(WireEnum):
list = "list"
toggle = "toggle"
install = "install"
update = "update"
class PluginsManageParams(ProfileParams):
"""``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``
(+ ``force``, ``enable``, ``ref``); ``update``: ``name``."""
action: PluginsAction = PluginsAction.list
key: str | None = None
name: str | None = None
enable: bool | None = None
identifier: str | None = None
repo: str | None = None
catalog_name: str | None = None
force: bool | None = None
ref: str | None = None
class AgentPluginRow(Result):
"""``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance."""
name: str
key: str
version: str
description: str
source: str
status: str
portable: bool
install_dir: str
has_desktop_half: bool
catalog_name: str | None = None
catalog_tier: str | None = None
installed_sha: str | None = None
catalog_sha: str | None = None
update_available: bool | None = None
pinned_sha: str | None = None
class PluginsManageResult(Result):
"""``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``name``/``plugin``;
``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s ok payload; ``update`` →
``ok``/``unchanged``/``sha``."""
plugins: list[AgentPluginRow] | None = None
user_count: int | None = None
bundled_count: int | None = None
ok: bool | None = None
unchanged: bool | None = None
name: str | None = None
plugin: AgentPluginRow | None = None
plugin_name: str | None = None
warnings: list[str] | None = None
missing_env: list[str] | None = None
after_install_path: str | None = None
enabled: bool | None = None
sha: str | None = None
method("plugins.manage", params=PluginsManageParams, result=PluginsManageResult,
doc="Plugins Hub backend: list installed plugins, toggle, git-install or re-pin a catalog install.")