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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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).")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user