mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: YOLO mode on|off|once for soft destructive confirms
Session /yolo and --yes skip classify_danger prompts; once expires after the next user turn. Hard path/command denylists unchanged.
This commit is contained in:
@@ -191,6 +191,11 @@ class ToolRegistry:
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._tools)
|
return len(self._tools)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def soft_confirm(self) -> bool:
|
||||||
|
"""True when dangerous tools are gated by ``on_confirm``."""
|
||||||
|
return self._danger is not None and self._on_confirm is not None
|
||||||
|
|
||||||
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
|
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
|
||||||
try:
|
try:
|
||||||
result = definition.handler(**args)
|
result = definition.handler(**args)
|
||||||
|
|||||||
@@ -171,9 +171,12 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
|
|||||||
try:
|
try:
|
||||||
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
|
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
state.expire_yolo_once(quiet=True)
|
||||||
return EXIT_CANCELLED
|
return EXIT_CANCELLED
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
state.expire_yolo_once(quiet=True)
|
||||||
return EXIT_CANCELLED
|
return EXIT_CANCELLED
|
||||||
|
state.expire_yolo_once(quiet=True)
|
||||||
return EXIT_OK if ok else EXIT_TURN_FAILED
|
return EXIT_OK if ok else EXIT_TURN_FAILED
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +209,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
|||||||
_warn_recoverable_providers(store.recoverable_providers)
|
_warn_recoverable_providers(store.recoverable_providers)
|
||||||
|
|
||||||
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
|
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
|
||||||
confirm_destructive: bool | None = False if yes else None
|
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
|
||||||
|
from plyngent.cli.state import YoloMode
|
||||||
|
|
||||||
|
yolo: YoloMode | None = "on" if yes else None
|
||||||
|
|
||||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||||
try:
|
try:
|
||||||
@@ -255,7 +261,7 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
|||||||
max_rounds=max_rounds,
|
max_rounds=max_rounds,
|
||||||
stream_enabled=stream,
|
stream_enabled=stream,
|
||||||
interactive_limits=interactive,
|
interactive_limits=interactive,
|
||||||
confirm_destructive=confirm_destructive,
|
yolo=yolo,
|
||||||
)
|
)
|
||||||
if not quiet and not oneshot:
|
if not quiet and not oneshot:
|
||||||
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
|
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
|
||||||
@@ -367,7 +373,7 @@ def main(ctx: click.Context, log_level: str) -> None:
|
|||||||
"--yes",
|
"--yes",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Allow destructive tools without confirm (one-shot / non-interactive).",
|
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--quiet",
|
"--quiet",
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ def _echo_user(text: str) -> None:
|
|||||||
async def run_repl(state: ReplState) -> None:
|
async def run_repl(state: ReplState) -> None:
|
||||||
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
||||||
setup_readline(state)
|
setup_readline(state)
|
||||||
|
yolo = state.effective_yolo()
|
||||||
|
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
|
||||||
click.echo(
|
click.echo(
|
||||||
f"plyngent chat provider={state.provider_name} model={state.model} "
|
f"plyngent chat provider={state.provider_name} model={state.model} "
|
||||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
||||||
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
||||||
f"stream={'on' if state.agent.stream else 'off'} "
|
f"stream={'on' if state.agent.stream else 'off'} "
|
||||||
f"verbose={'on' if state.verbose else 'off'}"
|
f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
|
||||||
)
|
)
|
||||||
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||||
|
|
||||||
@@ -52,8 +54,14 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
text = state.pending_user_text
|
text = state.pending_user_text
|
||||||
state.pending_user_text = None
|
state.pending_user_text = None
|
||||||
_echo_user(text)
|
_echo_user(text)
|
||||||
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, text)
|
_ = await run_user_text_with_retries(state.agent, text)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_echo_user(entry)
|
_echo_user(entry)
|
||||||
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, entry)
|
_ = await run_user_text_with_retries(state.agent, entry)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ from plyngent.runtime import ProviderNotSupportedError
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Sequence
|
from collections.abc import Awaitable, Sequence
|
||||||
|
|
||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState, YoloMode
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
_DEFAULT_HISTORY_LINES = 20
|
_DEFAULT_HISTORY_LINES = 20
|
||||||
_CONTENT_PREVIEW = 200
|
_CONTENT_PREVIEW = 200
|
||||||
_COMPACT_PREVIEW = 400
|
_COMPACT_PREVIEW = 400
|
||||||
_ON_OFF_CHOICES = ("on", "off")
|
_ON_OFF_CHOICES = ("on", "off")
|
||||||
|
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||||
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
||||||
|
|
||||||
HELP_FOOTER = (
|
HELP_FOOTER = (
|
||||||
@@ -38,7 +39,7 @@ HELP_FOOTER = (
|
|||||||
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Tab completes slash commands and some arguments (provider, model, tools,\n"
|
"Tab completes slash commands and some arguments (provider, model, tools,\n"
|
||||||
"stream, verbose, export). Use --session ID or /resume to continue a prior\n"
|
"stream, verbose, yolo, export). Use --session ID or /resume to continue a prior\n"
|
||||||
"chat after restart.\n"
|
"chat after restart.\n"
|
||||||
"\n"
|
"\n"
|
||||||
'Multiline: start a message with """ then end a later line with """.\n'
|
'Multiline: start a message with """ then end a later line with """.\n'
|
||||||
@@ -86,6 +87,30 @@ class OnOffParam(click.ParamType[bool]):
|
|||||||
ON_OFF = OnOffParam()
|
ON_OFF = OnOffParam()
|
||||||
|
|
||||||
|
|
||||||
|
class YoloModeParam(click.ParamType[str]):
|
||||||
|
"""Accept on|off|once for soft destructive-tool confirms."""
|
||||||
|
|
||||||
|
name: str = "yolo_mode"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
|
||||||
|
if isinstance(value, str) and value in _YOLO_MODE_CHOICES:
|
||||||
|
return value
|
||||||
|
token = str(value).strip().lower()
|
||||||
|
if token in _YOLO_MODE_CHOICES:
|
||||||
|
return token
|
||||||
|
msg = "expected on, off, or once"
|
||||||
|
raise click.BadParameter(msg, ctx=ctx, param=param)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del ctx, param
|
||||||
|
return _filter_choices(incomplete, _YOLO_MODE_CHOICES)
|
||||||
|
|
||||||
|
|
||||||
|
YOLO_MODE = YoloModeParam()
|
||||||
|
|
||||||
|
|
||||||
class ExportFormatParam(click.ParamType[str]):
|
class ExportFormatParam(click.ParamType[str]):
|
||||||
"""First token of /export: md|json (or a path if not a format)."""
|
"""First token of /export: md|json (or a path if not a format)."""
|
||||||
|
|
||||||
@@ -355,7 +380,8 @@ def status_cmd(state: ReplState) -> None:
|
|||||||
f"tools={'on' if state.tools_enabled else 'off'} "
|
f"tools={'on' if state.tools_enabled else 'off'} "
|
||||||
f"rounds={state.max_rounds} "
|
f"rounds={state.max_rounds} "
|
||||||
f"stream={'on' if state.agent.stream else 'off'} "
|
f"stream={'on' if state.agent.stream else 'off'} "
|
||||||
f"verbose={'on' if state.verbose else 'off'}\n"
|
f"verbose={'on' if state.verbose else 'off'} "
|
||||||
|
f"yolo={state.effective_yolo()}\n"
|
||||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||||
f"context_chars={ctx_chars} "
|
f"context_chars={ctx_chars} "
|
||||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||||
@@ -698,6 +724,24 @@ def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
|
|||||||
click.echo(f"tools={'on' if enabled else 'off'}")
|
click.echo(f"tools={'on' if enabled else 'off'}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("yolo")
|
||||||
|
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
|
||||||
|
@click.pass_obj
|
||||||
|
def yolo_cmd(state: ReplState, mode: str | None) -> None:
|
||||||
|
"""Show or set YOLO mode for soft destructive-tool confirms.
|
||||||
|
|
||||||
|
``off`` (default when config ``confirm_destructive`` is true): prompt on
|
||||||
|
delete/move/overwrite (deny in non-TTY). ``on``: skip confirms for the
|
||||||
|
process. ``once``: skip for the next user turn only, then return to ``off``.
|
||||||
|
Path/command denylists still apply. Omit the argument to print the value.
|
||||||
|
"""
|
||||||
|
if mode is None:
|
||||||
|
click.echo(f"yolo={state.effective_yolo()}")
|
||||||
|
return
|
||||||
|
state.set_yolo(cast("YoloMode", mode))
|
||||||
|
click.echo(f"yolo={state.effective_yolo()}")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("stream")
|
@slash.command("stream")
|
||||||
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
|
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import contextlib
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
|
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
|
||||||
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
||||||
@@ -25,6 +25,8 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
from plyngent.memory.database.schema import Session as SessionRow
|
from plyngent.memory.database.schema import Session as SessionRow
|
||||||
|
|
||||||
|
type YoloMode = Literal["off", "on", "once"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplState:
|
class ReplState:
|
||||||
@@ -44,8 +46,9 @@ class ReplState:
|
|||||||
markdown_enabled: bool = True
|
markdown_enabled: bool = True
|
||||||
# One-shot / scripts: never prompt to raise tool-loop limits.
|
# One-shot / scripts: never prompt to raise tool-loop limits.
|
||||||
interactive_limits: bool = True
|
interactive_limits: bool = True
|
||||||
# When False, skip destructive-tool confirms (e.g. --yes).
|
# Soft destructive-tool confirms: None → derive from config.confirm_destructive.
|
||||||
confirm_destructive: bool | None = None
|
# off = confirm; on = skip (sticky); once = skip next user turn then off.
|
||||||
|
yolo: YoloMode | None = None
|
||||||
# Set by /edit; REPL sends as the next user turn then clears.
|
# Set by /edit; REPL sends as the next user turn then clears.
|
||||||
pending_user_text: str | None = None
|
pending_user_text: str | None = None
|
||||||
client: ChatClient = field(init=False)
|
client: ChatClient = field(init=False)
|
||||||
@@ -77,10 +80,32 @@ class ReplState:
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
def _confirm_destructive(self) -> bool:
|
def effective_yolo(self) -> YoloMode:
|
||||||
if self.confirm_destructive is not None:
|
"""Resolved YOLO mode (session override or config default)."""
|
||||||
return self.confirm_destructive
|
if self.yolo is not None:
|
||||||
return self.config.agent_config.confirm_destructive
|
return self.yolo
|
||||||
|
return "off" if self.config.agent_config.confirm_destructive else "on"
|
||||||
|
|
||||||
|
def soft_confirm_enabled(self) -> bool:
|
||||||
|
"""Whether destructive tools should prompt (or deny non-interactively)."""
|
||||||
|
return self.effective_yolo() == "off"
|
||||||
|
|
||||||
|
def set_yolo(self, mode: YoloMode) -> None:
|
||||||
|
"""Set YOLO mode; rebuild tool registry when soft-confirm hooks change."""
|
||||||
|
prev = self.soft_confirm_enabled()
|
||||||
|
self.yolo = mode
|
||||||
|
if prev != self.soft_confirm_enabled():
|
||||||
|
self.rebuild_client()
|
||||||
|
|
||||||
|
def expire_yolo_once(self, *, quiet: bool = False) -> None:
|
||||||
|
"""If mode is ``once``, drop back to ``off`` after a user turn."""
|
||||||
|
if self.effective_yolo() != "once":
|
||||||
|
return
|
||||||
|
self.set_yolo("off")
|
||||||
|
if not quiet:
|
||||||
|
import click
|
||||||
|
|
||||||
|
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
|
||||||
|
|
||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
@@ -88,7 +113,7 @@ class ReplState:
|
|||||||
from plyngent.cli.limits import prompt_confirm_tool_async
|
from plyngent.cli.limits import prompt_confirm_tool_async
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
|
|
||||||
if self._confirm_destructive():
|
if self.soft_confirm_enabled():
|
||||||
return ToolRegistry(
|
return ToolRegistry(
|
||||||
list(DEFAULT_TOOLS),
|
list(DEFAULT_TOOLS),
|
||||||
danger=classify_danger,
|
danger=classify_danger,
|
||||||
|
|||||||
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
|
|||||||
|
|
||||||
async def test_no_hooks_skips_confirm() -> None:
|
async def test_no_hooks_skips_confirm() -> None:
|
||||||
registry = ToolRegistry([delete_path])
|
registry = ToolRegistry([delete_path])
|
||||||
|
assert registry.soft_confirm is False
|
||||||
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
|
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_soft_confirm_property() -> None:
|
||||||
|
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
|
del name, args, reason
|
||||||
|
return True
|
||||||
|
|
||||||
|
gated = ToolRegistry([delete_path], danger=classify_danger, on_confirm=on_confirm)
|
||||||
|
assert gated.soft_confirm is True
|
||||||
|
|||||||
@@ -181,3 +181,5 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
|||||||
assert complete_slash_args(state, "/model", "a") == ["alpha"]
|
assert complete_slash_args(state, "/model", "a") == ["alpha"]
|
||||||
assert complete_slash_args(state, "/export", "j") == ["json"]
|
assert complete_slash_args(state, "/export", "j") == ["json"]
|
||||||
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
|
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
|
||||||
|
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
|
||||||
|
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
|
||||||
|
|||||||
@@ -341,6 +341,63 @@ async def test_verbose_toggle(state: ReplState) -> None:
|
|||||||
assert get_verbose_tool_results() is False
|
assert get_verbose_tool_results() is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert state.effective_yolo() == "off"
|
||||||
|
assert state.soft_confirm_enabled() is True
|
||||||
|
assert await handle_slash(state, "/yolo") is True
|
||||||
|
assert "yolo=off" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/yolo on") is True
|
||||||
|
assert state.effective_yolo() == "on"
|
||||||
|
assert state.soft_confirm_enabled() is False
|
||||||
|
assert "yolo=on" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/yolo once") is True
|
||||||
|
assert state.effective_yolo() == "once"
|
||||||
|
assert state.soft_confirm_enabled() is False
|
||||||
|
|
||||||
|
state.expire_yolo_once(quiet=True)
|
||||||
|
assert state.effective_yolo() == "off"
|
||||||
|
assert state.soft_confirm_enabled() is True
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/yolo once") is True
|
||||||
|
state.expire_yolo_once()
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "yolo=off (once expired)" in err
|
||||||
|
assert state.effective_yolo() == "off"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
|
||||||
|
_ = set_workspace_root(tmp_path)
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
st = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert st.agent.tools is not None
|
||||||
|
assert st.agent.tools.soft_confirm is True
|
||||||
|
st.set_yolo("on")
|
||||||
|
assert st.agent.tools is not None
|
||||||
|
assert st.agent.tools.soft_confirm is False
|
||||||
|
st.set_yolo("once")
|
||||||
|
assert st.agent.tools is not None
|
||||||
|
assert st.agent.tools.soft_confirm is False
|
||||||
|
st.set_yolo("off")
|
||||||
|
assert st.agent.tools is not None
|
||||||
|
assert st.agent.tools.soft_confirm is True
|
||||||
|
finally:
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_resume(state: ReplState) -> None:
|
async def test_resume(state: ReplState) -> None:
|
||||||
sid = state.session_id
|
sid = state.session_id
|
||||||
assert sid is not None
|
assert sid is not None
|
||||||
@@ -378,6 +435,7 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
|
|||||||
assert "(est)" in out # no API usage yet
|
assert "(est)" in out # no API usage yet
|
||||||
assert "context_chars=" in out
|
assert "context_chars=" in out
|
||||||
assert "tool_result_max=" in out
|
assert "tool_result_max=" in out
|
||||||
|
assert "yolo=off" in out
|
||||||
assert str(state.workspace) in out
|
assert str(state.workspace) in out
|
||||||
|
|
||||||
state.agent.last_request_usage = TokenUsage(
|
state.agent.last_request_usage = TokenUsage(
|
||||||
|
|||||||
Reference in New Issue
Block a user