Files
hermes-agent/tui_gateway/contracts/tools_commands.py
teknium1 2c78b9b39e feat(process_registry): stamp exited_at and expose it on process.list
The live-work docks retire a finished background process ~60 s after it
ends; the registry only knew started_at, so the age of an exit was not
observable. _move_to_finished is the single choke point every exit path
(reader loop, reconcile, kill) passes through, so the stamp lives there.
completion_reason rides along so a killed process can read "killed"
instead of "exit -15".
2026-09-20 13:55:03 -07:00

533 lines
16 KiB
Python

"""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
exited_at: float | None = None
completion_reason: str | 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.")