Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions docs/guide.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Start with the [documentation home page](index.md) for the product overview, ins
| `ring focus SESSION_ID` | Focus a specific session; unique prefixes work |
| `ring config` | Show config path and effective settings |
| `ring config set KEY VALUE` | Write one config value |
| `ring quiet` | Show temporary global mute (quiet) status |
| `ring quiet on` / `off` / `30m` | Turn quiet on/off, or on for a while (auto-clears) |
| `ring doctor` | Read-only environment diagnosis |
| `ring digest --since 4h` | Away summary: recent session state and wait stats |
| `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
Expand Down Expand Up @@ -127,6 +129,38 @@ notify_also = ["ntfy"] # desktop notification as usu
`notify_backend = "ntfy"` pushes to the phone only. For Slack / your own bot / IFTTT, set
`notify_webhook_url` to use the generic webhook backend (JSON POST with a stable, additive-only payload).

### Notification Coalescing And Temporary Quiet (`notify_debounce_seconds` / `ring quiet`)

When several sessions flip to 🔴 waiting at once, sending one notification per session becomes
spam. Two independent, stackable mechanisms address this:

- **Coalescing (debounce)**: set `notify_debounce_seconds` (default `0`, off, backward compatible)
to a positive number (e.g. `5`) and only the first notification within that window is sent right
away (leading edge); the rest within the same window merge into a single "N more sessions are
waiting for you" summary. RiNG has no long-running daemon, so the summary is flushed lazily by
the next hook event, the TUI poll loop, or when quiet is turned off — never dropping the one
notification that already fired.
- **Temporary global mute (`ring quiet`)**: mute for a while; every notification during that window
is queued first, and a coalesced summary is sent once quiet is cleared. It shares the same queue
as debounce, but the two mechanisms are independent — quiet is always available regardless of
`notify_debounce_seconds`.

```sh
ring quiet # show current status (on/off, remaining time)
ring quiet on # turn on; stays muted until manually cleared
ring quiet 30m # turn on for 30 minutes; auto-clears on expiry
ring quiet off # turn off, flushing any coalesced summary right away
```

```toml
# ~/.config/ring/config.toml
notify_debounce_seconds = 5 # 0 = off (default); positive = coalescing window in seconds
```

While the TUI is open, the header shows quiet status and the queue count (e.g.
`🔇 QUIET · 12m left | queue: 3`); it stays out of the way when quiet is off and the queue is
empty. Quiet is a global setting, not per-session.

### Cleaning RiNG State Files (`ring gc`)

When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
Expand Down Expand Up @@ -297,8 +331,9 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
on Linux. If none are available, RiNG keeps the board running and skips notifications.

Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`,
`notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`.
Custom notification channels can be added by registering another `Notifier`.
`notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, `waiting_cooldown_seconds`, and
`notify_debounce_seconds`. Custom notification channels can be added by registering another
`Notifier`.

Background subagent permission requests can make a session flap between waiting and working in
quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system
Expand Down Expand Up @@ -342,6 +377,7 @@ notify_backend = "auto" # auto / terminal-notifier / osascript / notify
notify_repeat_seconds = [30, 120, 300]
notify_repeat_max = 3
waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
notify_debounce_seconds = 0 # cross-session notification coalescing window, in seconds; 0 = off (default)
notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
Expand Down
33 changes: 32 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
| `ring focus SESSION_ID` | 聚焦指定 session;可用唯一前綴 |
| `ring config` | 顯示設定檔路徑與生效設定 |
| `ring config set KEY VALUE` | 寫入單一設定 |
| `ring quiet` | 顯示暫時全域靜音(quiet)現況 |
| `ring quiet on` / `off` / `30m` | 開啟 / 解除 quiet,或開啟一段時間後自動解除 |
| `ring doctor` | 唯讀環境診斷 |
| `ring digest --since 4h` | 離席摘要:最近 session 狀態與等待統計 |
| `ring gc --dry-run` | 預覽 RiNG 自己的 stale 狀態檔清理 |
Expand Down Expand Up @@ -126,6 +128,33 @@ notify_also = ["ntfy"] # 桌面通知照發,再「
`notify_backend = "ntfy"` 則是只推手機、不發桌面通知。要接 Slack / 自家 bot / IFTTT,
用 `notify_webhook_url` 走通用 webhook 後端(JSON POST,欄位穩定只加不改)。

### 通知合流與暫時靜音(`notify_debounce_seconds` / `ring quiet`)

多個 session 同時轉 🔴 等你時,各自發一則系統通知會被轟炸。兩個獨立、可疊加的機制:

- **通知合流(debounce)**:`notify_debounce_seconds`(預設 `0`,關閉、向後相容)設成正數
(例如 `5`)後,n 秒內冒出的多則通知只有第一則立即發(leading edge),之後的合併成一則
「另有 N 個 session 在等你」彙總——RiNG 沒有常駐 daemon,彙總由下一個 hook 事件、TUI 輪詢
或 quiet 解除時**懶惰補發**,不會吞掉唯一的通知。
- **暫時全域靜音(`ring quiet`)**:手動靜音一段時間,期間所有通知先進 queue,解除時一併
補發彙總;跟 debounce 共用同一個 queue,但兩者互相獨立——quiet 不受
`notify_debounce_seconds` 影響,永遠可用。

```sh
ring quiet # 顯示目前現況(開/關、剩餘時間)
ring quiet on # 開啟,手動解除前一直靜音
ring quiet 30m # 開啟 30 分鐘,到期自動解除
ring quiet off # 解除,立即補發合流中的彙總通知
```

```toml
# ~/.config/ring/config.toml
notify_debounce_seconds = 5 # 0 = 關閉(預設);正數=合流窗口秒數
```

開著 TUI 時,header 會顯示 quiet 狀態與排隊計數(例如 `🔇 QUIET · 剩 12 分 | queue: 3`),
沒有靜音、queue 也是空的時候不佔版面。quiet 是全域粒度,不分特定 session。

### 清理 RiNG 狀態檔(`ring gc`)

RiNG 正常收到 `SessionEnd` 時會刪掉自己的 hook registry;如果 agent crash 或 hook 沒跑到結尾,
Expand Down Expand Up @@ -305,7 +334,8 @@ RiNG 會優先相信這些欄位;沒有時才退回 event / notification type
退回 `osascript`(macOS)或 `notify-send`(Linux)。如果全部不可用,就只保留看板,不讓通知失敗打斷主流程。

- **設定**:`notify_sound`、`notify_sound_name`、`notify_ignore_dnd`、`notify_repeat_seconds`、
`notify_repeat_max`、`notify_backend`、`waiting_cooldown_seconds` 都可在 `~/.config/ring/config.toml` 調整。
`notify_repeat_max`、`notify_backend`、`waiting_cooldown_seconds`、`notify_debounce_seconds`
都可在 `~/.config/ring/config.toml` 調整。
- **防翻轉轟炸**:背景 subagent 的權限請求可能讓 session 在等你/工作中之間快速翻轉。
`waiting_cooldown_seconds`(預設 180 秒)讓 `ring hook` 的系統通知與 TUI 的響鈴/提醒,
在 session 離開等你又很快轉回時,距上次通知未滿冷卻期就不再立即發;設 `0` 關閉冷卻
Expand Down Expand Up @@ -357,6 +387,7 @@ notify_backend = "auto" # auto / terminal-notifier / osascript / notify
notify_repeat_seconds = [30, 120, 300] # 持續等你時,幾秒後重複提醒
notify_repeat_max = 3 # 重複提醒上限;0 = 不限
waiting_cooldown_seconds = 180 # 離開等你又轉回時,距上次提醒未滿這段時間就不再立即提醒;0 = 關閉
notify_debounce_seconds = 0 # 跨 session 通知合流窗口秒數;0 = 關閉(預設,向後相容)
notify_ntfy_url = "" # 設完整 ntfy topic URL 啟用手機推播(如 https://ntfy.sh/my-topic)
notify_webhook_url = "" # 設 URL 啟用通用 webhook 後端(JSON POST)
notify_also = [] # 主後端之外「加發」的後端,如 ["ntfy"](桌面+手機各一份)
Expand Down
37 changes: 37 additions & 0 deletions src/ring/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ring.commands.focus import run_focus
from ring.commands.gc import run_gc
from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks
from ring.commands.quiet import run_quiet
from ring.commands.stats import run_stats
from ring.config import CONFIG_PATH as CONFIG_PATH
from ring.config import Config as Config
Expand All @@ -34,6 +35,7 @@
from ring.i18n import gettext as _
from ring.i18n import ngettext, set_lang
from ring.labels import load_labels
from ring.notify_queue import flush_if_due
from ring.plugins import load_plugins
from ring.registry import Session, Status, running_agent_pids
from ring.sources import discover_sessions
Expand Down Expand Up @@ -399,16 +401,32 @@ def run_config(args: list[str]) -> int:
return 2


def _watch_flush_if_due() -> None:
"""headless watch 輪詢時的懶惰 flush 觸發點(同 hook.py / tui.py 既有呼叫慣例)。

quiet active 或仍在 debounce 視窗內時,``flush_if_due`` 內部本來就會 skip(跟
``test_run_hook_does_not_flush_while_quiet_active`` 同機制),這裡不需另外判斷。
失敗安靜吞掉,不影響看板本身。
"""
try:
flush_if_due()
except Exception:
pass


def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
# 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
# watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
# 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的
# 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。
# debounce/quiet 合流 queue 的懶惰 flush:headless watch 沒有 hook 事件也沒有 TUI 輪詢
# 可以觸發,每輪自己補上一次,否則純 headless 使用者開了 quiet/debounce 通知會無限期卡住。
frames = 0
footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
if not HAVE_RICH:
try:
while True:
_watch_flush_if_due()
sys.stdout.write("\033[2J\033[H")
sessions = board(show_all)
print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
Expand All @@ -425,6 +443,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
try:
with Live(console=console, screen=True, auto_refresh=False) as live:
while True:
_watch_flush_if_due()
sessions = board(show_all)
body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
Expand Down Expand Up @@ -457,6 +476,10 @@ def _commands_help() -> str:
config 顯示設定檔位置與目前生效的設定
config get KEY 讀單一設定的目前值
config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
quiet 顯示 quiet(暫時全域靜音)現況
quiet on 開啟 quiet(手動解除前一直靜音)
quiet off 解除 quiet(立即補發合流的彙總通知)
quiet DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除
focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
Expand Down Expand Up @@ -493,6 +516,17 @@ def _subcommand_help(name: str) -> str:
不帶參數:顯示設定檔位置(~/.config/ring/config.toml)與目前生效的所有設定。
get KEY 印出單一設定的目前值(colors 子鍵用 colors.<name>)。
set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。
"""
),
"quiet": _(
"""usage: ring quiet [on | off | DURATION]

暫時全域靜音:靜音期間所有轉 🔴 等你的通知都先進 queue,解除時懶惰補發一則彙總。

不帶參數:顯示目前 quiet 現況(開/關、剩餘時間)。
on 開啟 quiet,手動解除前一直靜音。
off 解除 quiet,並立即補發合流的彙總通知。
DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除。
"""
),
"focus": _(
Expand Down Expand Up @@ -569,6 +603,7 @@ def main(argv: list[str] | None = None) -> int:
"digest",
"stats",
"completion",
"quiet",
}
and any(arg in {"-h", "--help"} for arg in raw[1:])
):
Expand All @@ -583,6 +618,8 @@ def main(argv: list[str] | None = None) -> int:
return run_remove_hooks(raw[1:])
if raw and raw[0] == "config":
return run_config(raw[1:])
if raw and raw[0] == "quiet":
return run_quiet(raw[1:])
if raw and raw[0] == "gc":
return run_gc(raw[1:])
if raw and raw[0] == "doctor":
Expand Down
54 changes: 54 additions & 0 deletions src/ring/commands/quiet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""``ring quiet`` command handler:暫時全域靜音(跟 debounce 共用同一份 queue/flush 機制)。"""

from __future__ import annotations

import sys
import time

from ring.commands._args import strip_lang
from ring.gc import parse_duration
from ring.i18n import gettext as _
from ring.notify_queue import clear_quiet, flush_if_due, format_remaining, quiet_active, quiet_remaining, set_quiet


def _print_status(now: float) -> None:
if not quiet_active(now):
print(_("🔈 quiet:目前關閉"))
return
remaining = quiet_remaining(now)
if remaining is None:
print(_("🔇 quiet:開啟中(手動解除前一直靜音)"))
else:
print(_("🔇 quiet:開啟中,剩 {remaining}", remaining=format_remaining(remaining)))


def run_quiet(args: list[str]) -> int:
"""不帶參數→顯示現況;``on``→無限靜音;``off``→解除並立即 flush;``<duration>``→限時靜音。"""
args = strip_lang(args)
now = time.time()

if not args:
_print_status(now)
return 0

action = args[0]
if action == "on":
set_quiet(None)
print(_("🔇 quiet 已開啟(手動解除前一直靜音)"))
return 0

if action == "off":
clear_quiet()
flush_if_due(force=True)
print(_("🔈 quiet 已解除"))
return 0

try:
seconds = parse_duration(action)
except ValueError:
print(_("無效的 duration:{value}(例如 30m、1h)", value=action), file=sys.stderr)
return 2

set_quiet(now + seconds)
print(_("🔇 quiet 已開啟,{duration} 後自動解除", duration=action))
return 0
28 changes: 27 additions & 1 deletion src/ring/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
notify_repeat_max = 3 # 重複提醒上限;0 = 不限
waiting_cooldown_seconds = 180 # session 離開 WAITING 又轉回時,距上次提醒未滿這段時間就
# 不再當「新轉入」立即提醒(防翻轉轟炸);0 = 關閉(現行為)
notify_debounce_seconds = 0 # 多個 session 同時轉 🔴 等你時的通知合流窗口(秒):窗口內
# 第一批立即發(leading edge),之後的合併成一則彙總,由下個
# hook 事件 / TUI 輪詢 / quiet 解除懶惰 flush;0 = 關閉(現行為,
# 向後相容,每次轉入都各自發)
notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
Expand All @@ -40,12 +44,15 @@

from __future__ import annotations

import sys
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import dataclass, field, fields
from functools import lru_cache
from pathlib import Path

from ring.i18n import gettext as _

CONFIG_PATH = Path.home() / ".config" / "ring" / "config.toml"

# Rich 樣式字串。深淺底都看得到(避開 dim / ANSI blue)。config 的 [colors] 可逐項覆寫。
Expand Down Expand Up @@ -88,6 +95,7 @@ class Config:
notify_repeat_seconds: tuple[int, ...] = (30, 120, 300)
notify_repeat_max: int = 3 # 0 = 不限
waiting_cooldown_seconds: int = 180 # 離開 WAITING 又轉回時的提醒冷卻期;0 = 關閉(現行為)
notify_debounce_seconds: int = 0 # 跨 session 通知合流窗口(秒);0 = 關閉(現行為,向後相容)
notify_ntfy_url: str = "" # 完整 ntfy topic URL;空=ntfy 後端不可用
notify_webhook_url: str = "" # webhook URL;空=webhook 後端不可用
notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
Expand All @@ -97,6 +105,21 @@ class Config:
colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))


# config.toml 頂層合法鍵(含巢狀 table 名,如 "colors")——都是 Config dataclass 的欄位名。
_KNOWN_KEYS = {f.name for f in fields(Config)}


def _warn_unknown_keys(raw: dict[str, object], path: Path) -> None:
"""未知的頂層鍵(多半是打錯字)→ 印一次警告到 stderr;只警告,絕不 raise、不影響載入。"""
unknown = sorted(k for k in raw if k not in _KNOWN_KEYS)
if not unknown:
return
print(
_("⚠️ {path} 有未知的設定鍵(可能打錯字,已忽略):{keys}", path=path, keys=", ".join(unknown)),
file=sys.stderr,
)


def _as_int(v: object, default: int) -> int:
return v if isinstance(v, int) and not isinstance(v, bool) else default

Expand Down Expand Up @@ -137,6 +160,7 @@ def load(path: Path | None = None) -> Config:
raw = tomllib.loads(p.read_text())
except (OSError, tomllib.TOMLDecodeError):
return Config()
_warn_unknown_keys(raw, p)
d = Config()
lang = raw.get("lang")
# 任意非空字串都接受(後端名稱由 notify 層的可插拔 registry 決定);認不得的名稱
Expand All @@ -163,6 +187,7 @@ def load(path: Path | None = None) -> Config:
notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds),
notify_repeat_max=max(0, _as_int(raw.get("notify_repeat_max"), d.notify_repeat_max)),
waiting_cooldown_seconds=max(0, _as_int(raw.get("waiting_cooldown_seconds"), d.waiting_cooldown_seconds)),
notify_debounce_seconds=max(0, _as_int(raw.get("notify_debounce_seconds"), d.notify_debounce_seconds)),
notify_backend=notify_backend,
notify_ntfy_url=(raw["notify_ntfy_url"] if isinstance(raw.get("notify_ntfy_url"), str) else ""),
notify_webhook_url=(raw["notify_webhook_url"] if isinstance(raw.get("notify_webhook_url"), str) else ""),
Expand Down Expand Up @@ -237,6 +262,7 @@ def _coerce_str_list(s: str) -> list[str]:
"notify_repeat_seconds": _coerce_int_list,
"notify_repeat_max": _coerce_int,
"waiting_cooldown_seconds": _coerce_int,
"notify_debounce_seconds": _coerce_int,
"notify_ntfy_url": str,
"notify_webhook_url": str,
"notify_also": _coerce_str_list,
Expand Down
Loading