ring-cli 0.8.1__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {ring_cli-0.8.1 → ring_cli-0.10.0}/PKG-INFO +10 -11
  2. {ring_cli-0.8.1 → ring_cli-0.10.0}/README.en.md +9 -10
  3. {ring_cli-0.8.1 → ring_cli-0.10.0}/pyproject.toml +1 -1
  4. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/cli.py +2 -23
  5. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/config.py +5 -4
  6. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/hook.py +38 -6
  7. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  8. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -9
  9. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/locale/ring.pot +69 -84
  10. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/__init__.py +4 -17
  11. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/base.py +1 -12
  12. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/registry.py +82 -11
  13. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/sources/__init__.py +8 -0
  14. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/tui.py +4 -16
  15. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/watcher.py +29 -32
  16. {ring_cli-0.8.1 → ring_cli-0.10.0}/LICENSE +0 -0
  17. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/__init__.py +0 -0
  18. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/__main__.py +0 -0
  19. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/__init__.py +0 -0
  20. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/_args.py +0 -0
  21. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/completion.py +0 -0
  22. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/digest.py +0 -0
  23. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/doctor.py +0 -0
  24. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/focus.py +0 -0
  25. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/gc.py +0 -0
  26. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/hook.py +0 -0
  27. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/commands/stats.py +0 -0
  28. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/__init__.py +0 -0
  29. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/applescript.py +0 -0
  30. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/base.py +0 -0
  31. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/iterm2.py +0 -0
  32. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/linux_wm.py +0 -0
  33. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/neovim.py +0 -0
  34. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/terminal.py +0 -0
  35. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/focus/tmux.py +0 -0
  36. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/gc.py +0 -0
  37. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/hook_protocol.py +0 -0
  38. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/i18n.py +0 -0
  39. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/ipc.py +0 -0
  40. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/labels.py +0 -0
  41. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/command.py +0 -0
  42. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/notify_send.py +0 -0
  43. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/ntfy.py +0 -0
  44. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/osascript_notifier.py +0 -0
  45. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/terminal_notifier.py +0 -0
  46. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/notify/webhook.py +0 -0
  47. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/osascript.py +0 -0
  48. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/plugins.py +0 -0
  49. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/sources/base.py +0 -0
  50. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/sources/claude_code.py +0 -0
  51. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/sources/codex.py +0 -0
  52. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/sources/hook_registry.py +0 -0
  53. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/stats.py +0 -0
  54. {ring_cli-0.8.1 → ring_cli-0.10.0}/src/ring/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.8.1
3
+ Version: 0.10.0
4
4
  Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
5
5
  Keywords: claude-code,codex,tui,dashboard,session,monitor,rich
6
6
  Author: Wei Lee
@@ -179,13 +179,6 @@ and sends a system notification right then — **no RiNG board needs to be open*
179
179
  after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
180
180
  also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
181
181
 
182
- A second kind of reminder is 🟡 **idle for too long** (`idle_threshold_seconds`, default 2h): once a
183
- session has sat idle past the threshold, it notifies once. Unlike waiting, idle is time-based — there
184
- is no event to hang a hook on, so it can only be detected by polling. That means it **fires only while
185
- `ring watch` or a TUI is open** (and when both are open, only one of them fires it, never twice). Close
186
- every board and idle reminders go silent — waiting still rings you, since that path runs off the hook.
187
- This is a deliberate trade-off: idle is a secondary signal, not worth a resident background process.
188
-
189
182
  For clickable notifications on macOS, install `terminal-notifier`:
190
183
 
191
184
  ```sh
@@ -343,8 +336,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
343
336
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
344
337
 
345
338
  Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`,
346
- `notify_repeat_seconds`, `notify_repeat_max`, and `notify_backend`. Custom notification channels
347
- can be added by registering another `Notifier`.
339
+ `notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`.
340
+ Custom notification channels can be added by registering another `Notifier`.
341
+
342
+ Background subagent permission requests can make a session flap between waiting and working in
343
+ quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system
344
+ notification and the TUI's bell/reminder from firing again the instant a session re-enters waiting
345
+ within the cooldown window of its last alert; set it to `0` to disable the cooldown (alert on
346
+ every re-entry, the old behavior).
348
347
 
349
348
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
350
349
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -373,13 +372,13 @@ legend = true
373
372
  active_window_seconds = 21600
374
373
  working_threshold_seconds = 90
375
374
  waiting_window_seconds = 1800
376
- idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
377
375
  notify_sound = true
378
376
  notify_sound_name = "Glass"
379
377
  notify_ignore_dnd = false
380
378
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
381
379
  notify_repeat_seconds = [30, 120, 300]
382
380
  notify_repeat_max = 3
381
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
383
382
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
384
383
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
385
384
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
@@ -154,13 +154,6 @@ and sends a system notification right then — **no RiNG board needs to be open*
154
154
  after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
155
155
  also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
156
156
 
157
- A second kind of reminder is 🟡 **idle for too long** (`idle_threshold_seconds`, default 2h): once a
158
- session has sat idle past the threshold, it notifies once. Unlike waiting, idle is time-based — there
159
- is no event to hang a hook on, so it can only be detected by polling. That means it **fires only while
160
- `ring watch` or a TUI is open** (and when both are open, only one of them fires it, never twice). Close
161
- every board and idle reminders go silent — waiting still rings you, since that path runs off the hook.
162
- This is a deliberate trade-off: idle is a secondary signal, not worth a resident background process.
163
-
164
157
  For clickable notifications on macOS, install `terminal-notifier`:
165
158
 
166
159
  ```sh
@@ -318,8 +311,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
318
311
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
319
312
 
320
313
  Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`,
321
- `notify_repeat_seconds`, `notify_repeat_max`, and `notify_backend`. Custom notification channels
322
- can be added by registering another `Notifier`.
314
+ `notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`.
315
+ Custom notification channels can be added by registering another `Notifier`.
316
+
317
+ Background subagent permission requests can make a session flap between waiting and working in
318
+ quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system
319
+ notification and the TUI's bell/reminder from firing again the instant a session re-enters waiting
320
+ within the cooldown window of its last alert; set it to `0` to disable the cooldown (alert on
321
+ every re-entry, the old behavior).
323
322
 
324
323
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
325
324
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -348,13 +347,13 @@ legend = true
348
347
  active_window_seconds = 21600
349
348
  working_threshold_seconds = 90
350
349
  waiting_window_seconds = 1800
351
- idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
352
350
  notify_sound = true
353
351
  notify_sound_name = "Glass"
354
352
  notify_ignore_dnd = false
355
353
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
356
354
  notify_repeat_seconds = [30, 120, 300]
357
355
  notify_repeat_max = 3
356
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
358
357
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
359
358
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
360
359
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
@@ -2,7 +2,7 @@
2
2
  # import 的 module(見 [tool.uv.build-backend] module-name)與 CLI 指令仍是 `ring`。
3
3
  [project]
4
4
  name = "ring-cli"
5
- version = "0.8.1"
5
+ version = "0.10.0"
6
6
  description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
7
7
  readme = "README.en.md"
8
8
  requires-python = ">=3.13"
@@ -33,12 +33,10 @@ from ring.config import get_config as get_config
33
33
  from ring.config import set_value as set_value
34
34
  from ring.i18n import gettext as _
35
35
  from ring.i18n import ngettext, set_lang
36
- from ring.ipc import read_tui_presence
37
36
  from ring.labels import load_labels
38
37
  from ring.plugins import load_plugins
39
38
  from ring.registry import Session, Status, running_agent_pids
40
39
  from ring.sources import discover_sessions
41
- from ring.watcher import IdleAlertScheduler
42
40
 
43
41
  try:
44
42
  from rich.box import SIMPLE_HEAD
@@ -402,33 +400,15 @@ def run_config(args: list[str]) -> int:
402
400
 
403
401
 
404
402
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
405
- # 🔴 等你的通知由 ``ring hook`` 在 session 轉態的當下就地發出(見 hook._ring_waiting_now),
406
- # 這樣關掉看板也照樣 ring 你;watch 迴圈額外負責輪詢發 🟡 閒置提醒——
407
- # 閒置是時間累積出來的,沒有事件可掛,只能靠輪詢偵測。
403
+ # 系統通知由 ``ring hook`` 在 session 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
404
+ # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
408
405
  frames = 0
409
406
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
410
- idle_alerts = IdleAlertScheduler(get_config().idle_threshold_seconds)
411
-
412
- def _notify_idle_alerts(sessions: list[Session]) -> None:
413
- # scheduler 照常 feed 讓狀態持續前進;但若 TUI(ring tui)也開著,
414
- # 就讓它去發、watch 這邊不重複發系統通知——否則同一 session 會被兩個行程各響一次。
415
- # 已標記為 due 的 session 在這裡仍算「提醒過」,所以 TUI 關掉後 watch 不會補發。
416
- alerts = idle_alerts.feed(sessions)
417
- if not alerts or read_tui_presence() is not None:
418
- return
419
- try:
420
- from ring.notify import notify_idle
421
-
422
- notify_idle(alerts)
423
- except Exception:
424
- pass
425
-
426
407
  if not HAVE_RICH:
427
408
  try:
428
409
  while True:
429
410
  sys.stdout.write("\033[2J\033[H")
430
411
  sessions = board(show_all)
431
- _notify_idle_alerts(sessions)
432
412
  print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
433
413
  print(f"\n{footer_text}")
434
414
  sys.stdout.flush()
@@ -444,7 +424,6 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
444
424
  with Live(console=console, screen=True, auto_refresh=False) as live:
445
425
  while True:
446
426
  sessions = board(show_all)
447
- _notify_idle_alerts(sessions)
448
427
  body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
449
428
  live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
450
429
  frames += 1
@@ -9,7 +9,6 @@
9
9
  active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
10
10
  working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
11
11
  waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
12
- idle_threshold_seconds = 7200 # 🟡 閒置多久後提醒一次(預設 2h;0 = 關閉)
13
12
  notify_sound = true # 系統通知帶聲音
14
13
  notify_sound_name = "Glass" # macOS / terminal-notifier sound name
15
14
  notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
@@ -21,6 +20,8 @@
21
20
  # "none" = 完全不發通知(RiNG 當純看板)
22
21
  notify_repeat_seconds = [30, 120, 300] # 持續等你時,多久後重複提醒
23
22
  notify_repeat_max = 3 # 重複提醒上限;0 = 不限
23
+ waiting_cooldown_seconds = 180 # session 離開 WAITING 又轉回時,距上次提醒未滿這段時間就
24
+ # 不再當「新轉入」立即提醒(防翻轉轟炸);0 = 關閉(現行為)
24
25
  notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
25
26
  notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
26
27
  notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
@@ -59,7 +60,6 @@ class Config:
59
60
  active_window_seconds: int = 6 * 60 * 60
60
61
  working_threshold_seconds: int = 90
61
62
  waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
62
- idle_threshold_seconds: int = 2 * 60 * 60 # 🟡 閒置多久後提醒一次;0 = 關閉
63
63
  notify_sound: bool = True
64
64
  notify_sound_name: str = "Glass"
65
65
  notify_ignore_dnd: bool = False
@@ -71,6 +71,7 @@ class Config:
71
71
  notify_backend: str = "auto"
72
72
  notify_repeat_seconds: tuple[int, ...] = (30, 120, 300)
73
73
  notify_repeat_max: int = 3 # 0 = 不限
74
+ waiting_cooldown_seconds: int = 180 # 離開 WAITING 又轉回時的提醒冷卻期;0 = 關閉(現行為)
74
75
  notify_ntfy_url: str = "" # 完整 ntfy topic URL;空=ntfy 後端不可用
75
76
  notify_webhook_url: str = "" # webhook URL;空=webhook 後端不可用
76
77
  notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
@@ -133,7 +134,6 @@ def load(path: Path | None = None) -> Config:
133
134
  active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
134
135
  working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
135
136
  waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
136
- idle_threshold_seconds=max(0, _as_int(raw.get("idle_threshold_seconds"), d.idle_threshold_seconds)),
137
137
  notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
138
138
  notify_sound_name=(
139
139
  raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
@@ -141,6 +141,7 @@ def load(path: Path | None = None) -> Config:
141
141
  notify_ignore_dnd=_as_bool(raw.get("notify_ignore_dnd"), d.notify_ignore_dnd),
142
142
  notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds),
143
143
  notify_repeat_max=max(0, _as_int(raw.get("notify_repeat_max"), d.notify_repeat_max)),
144
+ waiting_cooldown_seconds=max(0, _as_int(raw.get("waiting_cooldown_seconds"), d.waiting_cooldown_seconds)),
144
145
  notify_backend=notify_backend,
145
146
  notify_ntfy_url=(raw["notify_ntfy_url"] if isinstance(raw.get("notify_ntfy_url"), str) else ""),
146
147
  notify_webhook_url=(raw["notify_webhook_url"] if isinstance(raw.get("notify_webhook_url"), str) else ""),
@@ -205,13 +206,13 @@ _SETTERS: dict[str, Callable[[str], object]] = {
205
206
  "active_window_seconds": _coerce_int,
206
207
  "working_threshold_seconds": _coerce_int,
207
208
  "waiting_window_seconds": _coerce_int,
208
- "idle_threshold_seconds": _coerce_int,
209
209
  "notify_sound": _coerce_bool,
210
210
  "notify_sound_name": str,
211
211
  "notify_ignore_dnd": _coerce_bool,
212
212
  "notify_backend": str,
213
213
  "notify_repeat_seconds": _coerce_int_list,
214
214
  "notify_repeat_max": _coerce_int,
215
+ "waiting_cooldown_seconds": _coerce_int,
215
216
  "notify_ntfy_url": str,
216
217
  "notify_webhook_url": str,
217
218
  "notify_also": _coerce_str_list,
@@ -169,7 +169,8 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
169
169
 
170
170
  unhide_session(event.session_id)
171
171
  path = RING_REGISTRY / f"{quote(event.session_id, safe=':')}.json"
172
- prev_status = _previous_status(path)
172
+ prev_row = _previous_row(path)
173
+ prev_status = str(prev_row["status"]) if "status" in prev_row else None
173
174
  if event.status is Status.ENDED:
174
175
  path.unlink(missing_ok=True) # 乾淨離場:直接消失
175
176
  if prev_status is not None and prev_status != Status.ENDED.value:
@@ -213,6 +214,19 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
213
214
  if tty:
214
215
  payload["tty"] = tty
215
216
 
217
+ # waiting 通知冷卻(防翻轉轟炸):hook 是短命 process,冷卻狀態持久化在 registry row
218
+ # 的 waiting_notified_at(上次 waiting 通知的 epoch 時間戳)。離開 WAITING 也要把時間戳
219
+ # 帶進新 row(payload 每次重建,不帶會遺失),快速翻回 WAITING 時才判得出「還在冷卻期」。
220
+ # 舊 row 沒這欄位 = 從未通知過 → 照發。並發寫沿用既有 tmp+replace 的 last-write-wins。
221
+ last_notified = _as_epoch(prev_row.get("waiting_notified_at"))
222
+ if last_notified is not None:
223
+ payload["waiting_notified_at"] = last_notified
224
+ should_ring = False
225
+ if event.status is Status.WAITING:
226
+ should_ring = _waiting_ring_allowed(last_notified, now)
227
+ if should_ring:
228
+ payload["waiting_notified_at"] = now
229
+
216
230
  RING_REGISTRY.mkdir(parents=True, exist_ok=True)
217
231
  tmp = path.with_suffix(".json.tmp")
218
232
  tmp.write_text(json.dumps(payload))
@@ -221,17 +235,35 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
221
235
  if prev_status != event.status.value:
222
236
  log_transition(event.session_id, event.provider, event.cwd, event.status.value)
223
237
 
224
- if event.status is Status.WAITING:
238
+ if should_ring:
225
239
  _ring_waiting_now(event, payload, last_action)
226
240
 
227
241
 
228
- def _previous_status(path: Path) -> str | None:
229
- """讀 registry 檔目前的狀態值(給轉換偵測用);沒檔 / 壞檔回 None。"""
242
+ def _previous_row(path: Path) -> dict[str, Any]:
243
+ """讀 registry 檔目前的內容(給轉換偵測與通知冷卻用);沒檔 / 壞檔回空 dict。"""
230
244
  try:
231
245
  data = json.loads(path.read_text())
232
- return str(data["status"])
246
+ return data if isinstance(data, dict) else {}
233
247
  except Exception:
234
- return None
248
+ return {}
249
+
250
+
251
+ def _as_epoch(v: Any) -> float | None:
252
+ """把 row 欄位值安全轉成 epoch float;缺欄位 / 型別不對回 None(視為從未通知)。"""
253
+ return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
254
+
255
+
256
+ def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool:
257
+ """hook 的 waiting 通知是否放行:距上次通知未滿 ``waiting_cooldown_seconds`` 就不發。
258
+
259
+ 背景 subagent 的權限請求會讓 session 以 30 秒~2 分鐘頻率在 working↔waiting 間翻轉,
260
+ 每次轉入都發一則系統通知會轟炸使用者。cooldown=0 = 關閉(維持現行為:每次轉入都發);
261
+ 從未通知過(``last_notified`` 為 None,含舊版 row 沒有該欄位)一律放行。
262
+ """
263
+ cooldown = get_config().waiting_cooldown_seconds
264
+ if cooldown <= 0 or last_notified is None:
265
+ return True
266
+ return now - last_notified >= cooldown
235
267
 
236
268
 
237
269
  def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
@@ -124,9 +124,6 @@ msgstr "↑/↓ to select, Enter or Space to jump to its terminal"
124
124
  msgid "🔔 {names} 在等你回話"
125
125
  msgstr "🔔 {names} waiting for your reply"
126
126
 
127
- msgid "🟡 {names} 已閒置"
128
- msgstr "🟡 {names} has gone idle"
129
-
130
127
  msgid "(沒有選到 session)"
131
128
  msgstr "(no session selected)"
132
129
 
@@ -198,15 +195,9 @@ msgid "✅ 已移除 RiNG hooks({events})從 {path}"
198
195
  msgstr "✅ Removed RiNG hooks ({events}) from {path}"
199
196
 
200
197
  # ── notify 文案明確化 ──
201
- msgid "RiNG · {project} 已閒置"
202
- msgstr "RiNG · {project} has gone idle"
203
-
204
198
  msgid "RiNG · {project} 在等你回話"
205
199
  msgstr "RiNG · {project} is waiting for you"
206
200
 
207
- msgid "已閒置 {duration}"
208
- msgstr "Idle for {duration}"
209
-
210
201
  msgid "{project} · …/{tail}"
211
202
  msgstr "{project} · …/{tail}"
212
203