fix(gateway): /toolsets and /platforms read on the owner for a local session

Neither has a messaging handler; main's classic CLI and its TUI slash worker
printed them from the process holding the agent, which on the shared gateway
is the owner. Ink and Desktop got unsupported_command. The report bodies move
verbatim into hermes_cli/cli_info_lines.py (byte-identical CLI output,
checked old vs new), and slash.exec runs them for a local session
(gateway/session_info_reads.py): /toolsets marks THIS session's toolsets
(policy.current_toolsets), /platforms reads the owning profile's gateway
config. One text on every surface.

Proof: test_session_read_commands runs both through slash.exec on a real
daemon (session created with toolsets=['todo']).
This commit is contained in:
Teknium committed 2026-10-13 02:51:17 -07:00
1 parent 0d93ab57fa
commit 5707483e83
6 files changed
+117 -67

No files matched your search

+8 -1
View File
@@ -34,6 +34,9 @@ _SETTING_COMMANDS = frozenset({'reasoning', 'voice', 'approvals', 'codex-runtime
# Typed `/approve` / `/deny`: the messaging handler resolves THIS session's waiting approval (its
# route is the session key), the same waiter `approval.respond` answers by prompt id.
_APPROVAL_COMMANDS = frozenset({'approve', 'deny'})
# Reports with no messaging handler: the classic CLI's own text, built by the owner for this
# session (gateway/session_info_reads.py).
_INFO_READS = frozenset({'toolsets', 'platforms'})
# Kanban verbs that only read the board. ``create`` would also subscribe the LOCAL chat to
# messaging notifications, and every other verb mutates the board.
@@ -67,7 +70,7 @@ def command_verdict(name, arg=''):
if (name in _CONTROL_COMMANDS or name in _HANDLER_CONTROLS or name in _PROMPT_COMMANDS
or name in _SETTING_COMMANDS or name in _APPROVAL_COMMANDS):
return 'control'
if name in _READ_COMMANDS and _READ_ARGUMENTS.get(name, lambda _arg: True)(arg):
if name in _INFO_READS or (name in _READ_COMMANDS and _READ_ARGUMENTS.get(name, lambda _arg: True)(arg)):
return 'read'
return 'refused'
@@ -195,6 +198,10 @@ async def execute_command(connection, ref, params, *, dispatch=False):
return _skill_directive(name, arg, live.route)
return await asyncio.to_thread(build)
if canonical in _INFO_READS:
from gateway.session_info_reads import run_info_read
return await run_info_read(authority, live, canonical)
from gateway.platforms.base import MessageEvent
from gateway.session_command_sink import capture
typed = '/' + canonical + (' ' + arg if arg else '')
+35
View File
@@ -0,0 +1,35 @@
"""Reports with no messaging handler that the shared gateway serves a local session itself.
Main's classic CLI and its TUI slash worker printed `/toolsets` and `/platforms` from the process
that held the agent. On the shared gateway that process is the owner, so ``slash.exec`` runs the
classic CLI's own line builders (hermes_cli/cli_info_lines.py) here, inside the owning profile, for
this session's toolsets: the same text on the classic CLI, Ink and every other local client.
"""
from __future__ import annotations
import asyncio
def _toolsets(authority, policy):
from hermes_cli.cli_info_lines import toolsets_lines
return toolsets_lines(list(policy.current_toolsets(policy.config(authority, secrets=False))) if policy else [])
def _platforms(authority, policy):
from hermes_cli.cli_info_lines import platforms_lines
return platforms_lines()
READS = {'toolsets': _toolsets, 'platforms': _platforms}
async def run_info_read(authority, live, name) -> dict:
"""Render *name* off the owner loop, in the owning profile; None policy = no frozen route."""
from gateway.session_authorities import owner_scope
from gateway.session_policy import policy_for_source
policy = policy_for_source(authority.runner, live.source)
def build():
with owner_scope(authority):
return '\n'.join(READS[name](authority, policy)).strip('\n')
return {'type': 'exec', 'output': await asyncio.to_thread(build)}
+61
View File
@@ -0,0 +1,61 @@
"""The classic CLI's `/toolsets` and `/platforms` reports as plain lines.
Shared by the classic CLI (`CLIInfoMixin.show_toolsets` / `_show_gateway_status`) and the shared
gateway, which runs the same report for a local session's `slash.exec` (gateway/session_info_reads.py):
one text on every surface, read where the state lives (the session's toolsets, the gateway's
platform config).
"""
from __future__ import annotations
from agent.i18n import t
def _box(title: str, width: int) -> list[str]:
pad = width - len(title)
return ["+" + "-" * width + "+", "|" + " " * (pad // 2) + title + " " * (pad - pad // 2) + "|",
"+" + "-" * width + "+"]
def toolsets_lines(enabled) -> list[str]:
"""``/toolsets``: every toolset, ``(*)`` on the ones *enabled* names."""
from toolsets import get_all_toolsets, get_toolset_info
out = ["", *_box(t("cli.toolsets.header"), 58), ""]
for name in sorted(get_all_toolsets()):
info = get_toolset_info(name)
if info:
marker = "(*)" if enabled and name in enabled else " "
out.append(f" {marker} {name:<18} [{info['tool_count']:>2} tools] - {info['description']}")
return [*out, "", f" {t('cli.toolsets.currently_enabled_legend')}", "",
f" {t('cli.toolsets.tip_enable_all')}", f" {t('cli.toolsets.example')}", ""]
def platforms_lines() -> list[str]:
"""``/platforms``: the messaging platforms the profile's gateway config enables."""
from hermes_constants import display_hermes_home
from gateway.config import load_gateway_config, Platform
out = ["", *_box(t("cli.gateway_status.header"), 60), ""]
config_file = f"{display_hermes_home()}/config.yaml"
try:
config = load_gateway_config()
out += [f" {t('cli.gateway_status.platform_config_header')}", " " + "-" * 55]
platform_status = {
Platform.TELEGRAM: ("Telegram", "TELEGRAM_BOT_TOKEN"),
Platform.DISCORD: ("Discord", "DISCORD_BOT_TOKEN"),
Platform.SLACK: ("Slack", "SLACK_BOT_TOKEN"),
Platform.WHATSAPP: ("WhatsApp", "WHATSAPP_ENABLED")}
for platform, (name, env_var) in platform_status.items():
pconfig = config.platforms.get(platform)
if pconfig and pconfig.enabled:
home = config.get_home_channel(platform)
home_str = f" → {home.name}" if home else ""
out.append(f" ✓ {name:<12} {t('cli.gateway_status.enabled')}{home_str}")
else:
out.append(f" ○ {name:<12} {t('cli.gateway_status.not_configured', env_var=env_var)}")
return [*out, "", f" {t('cli.gateway_status.conversations_persist')}", "",
f" {t('cli.gateway_status.to_start')}", " python cli.py --gateway", "",
f" {t('cli.gateway_status.config_file', path=config_file)}", ""]
except Exception as e:
return [*out, f" {t('cli.gateway_status.load_error', error=str(e))}", "",
f" {t('cli.gateway_status.to_configure')}", f" {t('cli.gateway_status.step_env_vars')}",
" TELEGRAM_BOT_TOKEN=your_token", " DISCORD_BOT_TOKEN=your_token",
f" {t('cli.gateway_status.step_config', path=config_file)}", ""]
+4 -61
View File
@@ -443,25 +443,8 @@ class CLIInfoMixin:
def show_toolsets(self):
"""Display available toolsets with kawaii ASCII art."""
from toolsets import get_all_toolsets, get_toolset_info
all_toolsets = get_all_toolsets()
print()
_ascii_box(t("cli.toolsets.header"), 58)
print()
for name in sorted(all_toolsets.keys()):
info = get_toolset_info(name)
if info:
marker = "(*)" if self.enabled_toolsets and name in self.enabled_toolsets else " "
print(f" {marker} {name:<18} [{info['tool_count']:>2} tools] - {info['description']}")
print()
print(f" {t('cli.toolsets.currently_enabled_legend')}")
print()
print(f" {t('cli.toolsets.tip_enable_all')}")
print(f" {t('cli.toolsets.example')}")
print()
from hermes_cli.cli_info_lines import toolsets_lines
print("\n".join(toolsets_lines(self.enabled_toolsets)))
def _handle_whoami_command(self):
"""Display slash-command access for the local CLI surface."""
@@ -554,48 +537,8 @@ class CLIInfoMixin:
def _show_gateway_status(self):
"""Show status of the gateway and connected messaging platforms."""
from hermes_constants import display_hermes_home
from gateway.config import load_gateway_config, Platform
print()
_ascii_box(t("cli.gateway_status.header"), 60)
print()
try:
config = load_gateway_config()
print(f" {t('cli.gateway_status.platform_config_header')}")
print(" " + "-" * 55)
platform_status = {
Platform.TELEGRAM: ("Telegram", "TELEGRAM_BOT_TOKEN"),
Platform.DISCORD: ("Discord", "DISCORD_BOT_TOKEN"),
Platform.SLACK: ("Slack", "SLACK_BOT_TOKEN"),
Platform.WHATSAPP: ("WhatsApp", "WHATSAPP_ENABLED")}
for platform, (name, env_var) in platform_status.items():
pconfig = config.platforms.get(platform)
if pconfig and pconfig.enabled:
home = config.get_home_channel(platform)
home_str = f" → {home.name}" if home else ""
print(f" ✓ {name:<12} {t('cli.gateway_status.enabled')}{home_str}")
else:
print(f" ○ {name:<12} {t('cli.gateway_status.not_configured', env_var=env_var)}")
print()
print(f" {t('cli.gateway_status.conversations_persist')}")
print()
print(f" {t('cli.gateway_status.to_start')}")
print(" python cli.py --gateway")
print()
print(f" {t('cli.gateway_status.config_file', path=f'{display_hermes_home()}/config.yaml')}")
print()
except Exception as e:
print(f" {t('cli.gateway_status.load_error', error=str(e))}")
print()
print(f" {t('cli.gateway_status.to_configure')}")
print(f" {t('cli.gateway_status.step_env_vars')}")
print(" TELEGRAM_BOT_TOKEN=your_token")
print(" DISCORD_BOT_TOKEN=your_token")
print(f" {t('cli.gateway_status.step_config', path=f'{display_hermes_home()}/config.yaml')}")
print()
from hermes_cli.cli_info_lines import platforms_lines
print("\n".join(platforms_lines()))
def _print_random_tip(self) -> None:
"""Best-effort discovery tip (startup + /clear); never raises."""
+6 -2
View File
@@ -41,7 +41,7 @@ def test_local_session_reads_report_this_session_and_refuse_their_write_forms(tm
async def probe(desc):
async with websocket(home, desc) as ws:
created = await rpc(ws, 'session.create', request_id='reads', source='cli', toolsets=[], cwd=str(work))
created = await rpc(ws, 'session.create', request_id='reads', source='cli', toolsets=['todo'], cwd=str(work))
sid = created['result']['session_id']
await rpc(ws, 'prompt.submit', session_id=sid, input_id='warm', text='WARM_READS')
deadline = time.monotonic() + 45
@@ -54,7 +54,8 @@ def test_local_session_reads_report_this_session_and_refuse_their_write_forms(tm
await asyncio.sleep(.1)
for command in ('usage', 'insights', 'profile', 'topup', 'diff', 'memory', 'kanban list',
'suggestions', 'bundles', 'usage reset', 'memory approval on', 'kanban create x',
'suggestions catalog', 'agents', 'sessions', 'platform'):
'suggestions catalog', 'agents', 'sessions', 'platform',
'toolsets', 'platforms'):
try:
reply = await rpc(ws, 'slash.exec', session_id=sid, command=command)
except TimeoutError:
@@ -84,6 +85,9 @@ def test_local_session_reads_report_this_session_and_refuse_their_write_forms(tm
assert out['kanban list'] == '(no matching tasks)', out['kanban list']
assert 'No suggested automations' in out['suggestions'], out['suggestions']
assert str(home / 'skill-bundles') in out['bundles'], out['bundles']
# Reports with no messaging handler: the classic CLI's text for this session's toolsets.
assert '(*) todo ' in out['toolsets'] and ' web ' in out['toolsets'], out['toolsets']
assert 'Telegram' in out['platforms'] and 'TELEGRAM_BOT_TOKEN' in out['platforms'], out['platforms']
# Write forms, messaging-model state and gateway-wide views stay refused (the per-session settings
# run: tests/gateway/test_session_setting_commands.py).
for command in ('usage reset', 'memory approval on', 'kanban create x', 'suggestions catalog',
@@ -26,7 +26,7 @@ narrower routes (`hermes_cli/gateway_chat_commands.py`).
setting to this local session (its next turn and `config.get`), saving config.yaml only where the
handler does; `client-local (cli_only)` commands are terminal/UI features of a client.
103 registry commands: 29 control, 15 read, 59 refused.
103 registry commands: 29 control, 17 read, 57 refused.
| Command | Gateway (`slash.exec`) | Classic CLI | Ink | Desktop | ACP | Notes |
|---|---|---|---|---|---|---|
@@ -86,7 +86,7 @@ handler does; `client-local (cli_only)` commands are terminal/UI features of a c
| `/pet` | refused | HermesCLI._handle_pet_command | slash.exec | action: pet | refused | client-local (cli_only) |
| `/plan` | control | HermesCLI._handle_plan_command | slash.exec | slash.exec | refused | |
| `/platform` | refused | messaging-only: unknown command | slash.exec: refused | slash.exec: refused | refused | messaging adapters; `pause`/`resume` mutate them |
| `/platforms` (/gateway) | refused | HermesCLI._show_gateway_status | slash.exec: refused | unavailable: terminal | refused | client-local (cli_only) |
| `/platforms` (/gateway) | read | HermesCLI._show_gateway_status | slash.exec | unavailable: terminal | refused | client-local (cli_only) |
| `/plugins` | refused | HermesCLI._cmd_plugins | slash.exec | unavailable: terminal | refused | client-local (cli_only) |
| `/profile` | read | HermesCLI._handle_profile_command | slash.exec | action: profile | refused | |
| `/prompt` (/compose) | refused | HermesCLI._handle_prompt_compose_command | local | slash.exec: refused | refused | client-local (cli_only) |
@@ -120,7 +120,7 @@ handler does; `client-local (cli_only)` commands are terminal/UI features of a c
| `/timestamps` (/ts) | refused | HermesCLI._handle_timestamps_command | slash.exec: refused | slash.exec: refused | refused | client-local (cli_only) |
| `/title` | control | HermesCLI._cmd_title | canonical route | action: title | refused | |
| `/tools` | refused | HermesCLI._handle_tools_command | canonical route | slash.exec: refused | refused | client-local (cli_only) |
| `/toolsets` | refused | HermesCLI.show_toolsets | slash.exec: refused | unavailable: terminal | refused | client-local (cli_only) |
| `/toolsets` | read | HermesCLI.show_toolsets | slash.exec | unavailable: terminal | refused | client-local (cli_only) |
| `/topic` | refused | messaging-only: unknown command | slash.exec: refused | slash.exec: refused | refused | messaging-only |
| `/topup` | read | HermesCLI._show_billing | sidecar: billing.state | slash.exec | refused | |
| `/undo` | refused | HermesCLI._cmd_undo | canonical route | slash.exec: refused | refused | |