From 10402f5be660b0004b8fac8f3817f9db27e9f418 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 21 Oct 2026 19:53:19 +0800 Subject: [PATCH] core/config: add the [notify] section `[notify]` decides what the CLI does when a prompt is waiting on the human: `grace_seconds` (default 5s, roughly Claude Code's permission notice) before anything is shown, `min_interval_seconds` between two notifications, `channels` (`auto` = desktop then terminal), `actions` for the allow/deny/review buttons, `include_reason` for the one-line reason, and `protocol` to pin the terminal escape protocol. The reason stays out by default because it names commands, argv and URLs, and a notification outlives the prompt and may be mirrored to a lock screen. Unknown keys degrade to the defaults, the way `[mcp]` does, and `reload()` re-reads the section. Desktop notifications need the optional `notify` extra (`desktop-notifier`), which is the only new dependency and stays off the default install. --- pyproject.toml | 4 ++ src/plyngent/config/__init__.py | 1 + src/plyngent/config/models.py | 26 ++++++++ src/plyngent/config/store.py | 27 ++++++++ tests/test_config/test_notify_section.py | 81 ++++++++++++++++++++++++ 5 files changed, 139 insertions(+) create mode 100644 tests/test_config/test_notify_section.py diff --git a/pyproject.toml b/pyproject.toml index b62facf..dada21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,10 @@ requires-python = ">=3.14,<4" readme = "README.md" license = { text = "MIT" } +[project.optional-dependencies] +# Desktop notifications while a prompt waits on the human (see `[notify]`). +notify = ["desktop-notifier>=6.2.0"] + [project.scripts] plyngent = "plyngent.cli:main" diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index 526e40d..e35ea07 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -17,6 +17,7 @@ from .models import McpConfig as McpConfig from .models import McpServerConfig as McpServerConfig from .models import ModelConfig as ModelConfig from .models import NetworkingConfig as NetworkingConfig +from .models import NotifyConfig as NotifyConfig from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAIProvider as OpenAIProvider from .models import PluginsConfig as PluginsConfig diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 998b2f6..9d471a4 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -278,6 +278,32 @@ class SkillsConfig(Struct, omit_defaults=True): allow_write: bool = False +class NotifyConfig(Struct, omit_defaults=True): + """Desktop notifications shown while a prompt waits on the human. + + A prompt is announced only after ``grace_seconds`` of waiting, so answering + at the terminal stays silent; ``min_interval_seconds`` throttles bursts. + ``channels`` picks the surfaces: ``auto`` tries the desktop first and falls + back to the terminal, ``desktop`` / ``terminal`` pin one of them. + + Desktop notifications come from the optional ``desktop-notifier`` extra + (``pip install plyngent[notify]``); without it the terminal channel is all + that is left. ``actions = false`` keeps the notification text-only, and + ``include_reason`` lets the one-line reason through — it can name a command + or a URL, so it stays off by default (notifications outlive the prompt and + may be mirrored to a lock screen). + """ + + enabled: bool = True + grace_seconds: float = 5.0 + min_interval_seconds: float = 10.0 + channels: list[str] = field(default_factory=lambda: ["auto"]) + actions: bool = True + include_reason: bool = False + # Terminal protocol override: "" = detect, else osc777 / osc9 / bell. + protocol: str = "" + + class ModelConfig(Struct, omit_defaults=True): """Capability flags and optional routing overrides for a model. diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 7217878..f252cf2 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -12,6 +12,7 @@ from .models import ( McpConfig, ModelConfig, NetworkingConfig, + NotifyConfig, PluginsConfig, Provider, SkillsConfig, @@ -66,6 +67,22 @@ def _parse_skills(raw: dict[str, object]) -> SkillsConfig: return SkillsConfig() +def _parse_notify(raw: dict[str, object]) -> NotifyConfig: + """Parse the ``[notify]`` section, falling back to defaults. + + Unknown keys are rejected like ``[mcp]`` does: a typo degrades to the + defaults (notifications on, ``auto`` channel) instead of half-applying. + """ + try: + config = msgspec.convert(raw, NotifyConfig) + except msgspec.ValidationError: + return NotifyConfig() + known = set(NotifyConfig.__struct_fields__) + if set(raw) - known: + return NotifyConfig() + return config + + def _parse_mcp(raw: dict[str, object]) -> McpConfig: """Parse the ``[mcp]`` section, falling back to defaults. @@ -160,6 +177,7 @@ class ConfigStore: _networking: NetworkingConfig _mcp: McpConfig _skills: SkillsConfig + _notify: NotifyConfig _providers: dict[str, Provider] _bad_providers: dict[str, object] _recoverable_providers: dict[str, Provider] @@ -174,6 +192,7 @@ class ConfigStore: self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {}))) + self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document) @property @@ -238,6 +257,13 @@ class ConfigStore: """Typed skills section (roots, discovery sources, catalog limits).""" return self._skills + # -- notify (read-only) -- + + @property + def notify_config(self) -> NotifyConfig: + """Typed notifications section (grace, channels, actions).""" + return self._notify + def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig: """Replace the plugin enable list (in-memory; call :meth:`write` to persist).""" cleaned = [name.strip() for name in names if name.strip()] @@ -442,6 +468,7 @@ class ConfigStore: self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) + self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document) # -- internal sync helpers -- diff --git a/tests/test_config/test_notify_section.py b/tests/test_config/test_notify_section.py new file mode 100644 index 0000000..0c74647 --- /dev/null +++ b/tests/test_config/test_notify_section.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from plyngent.config import load + +if TYPE_CHECKING: + from pathlib import Path + + +def test_notify_section_defaults(tmp_path: Path) -> None: + """Notifications are on by default: a five-second grace, desktop first.""" + path = tmp_path / "c.toml" + _ = path.write_text("", encoding="utf-8") + cfg = load(path).notify_config + assert cfg.enabled is True + assert cfg.grace_seconds == 5.0 + assert cfg.min_interval_seconds == 10.0 + assert cfg.channels == ["auto"] + assert cfg.actions is True + assert cfg.include_reason is False + assert cfg.protocol == "" + + +def test_notify_section_parse(tmp_path: Path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text( + """ +[notify] +enabled = false +grace_seconds = 2.5 +min_interval_seconds = 30.0 +channels = ["terminal"] +actions = false +include_reason = true +protocol = "osc9" +""", + encoding="utf-8", + ) + cfg = load(path).notify_config + assert cfg.enabled is False + assert cfg.grace_seconds == 2.5 + assert cfg.min_interval_seconds == 30.0 + assert cfg.channels == ["terminal"] + assert cfg.actions is False + assert cfg.include_reason is True + assert cfg.protocol == "osc9" + + +def test_notify_section_typo_falls_back(tmp_path: Path) -> None: + """An unknown key or a bad type degrades to the defaults, like ``[mcp]``.""" + path = tmp_path / "c.toml" + _ = path.write_text( + """ +[notify] +grace = 1.0 +""", + encoding="utf-8", + ) + assert load(path).notify_config.grace_seconds == 5.0 + + path2 = tmp_path / "d.toml" + _ = path2.write_text( + """ +[notify] +enabled = "yes" +""", + encoding="utf-8", + ) + assert load(path2).notify_config.enabled is True + + +def test_notify_section_survives_reload(tmp_path: Path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text("[notify]\nenabled = false\n", encoding="utf-8") + store = load(path) + assert store.notify_config.enabled is False + _ = path.write_text("[notify]\nenabled = true\ngrace_seconds = 1.0\n", encoding="utf-8") + store.reload() + assert store.notify_config.enabled is True + assert store.notify_config.grace_seconds == 1.0