ring-cli 0.9.0__tar.gz → 0.11.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 (56) hide show
  1. {ring_cli-0.9.0 → ring_cli-0.11.0}/PKG-INFO +36 -4
  2. {ring_cli-0.9.0 → ring_cli-0.11.0}/README.en.md +35 -3
  3. {ring_cli-0.9.0 → ring_cli-0.11.0}/pyproject.toml +1 -1
  4. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/config.py +5 -0
  5. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/hook.py +38 -6
  6. ring_cli-0.11.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  7. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/locale/en/LC_MESSAGES/ring.po +39 -0
  8. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/locale/ring.pot +104 -40
  9. ring_cli-0.11.0/src/ring/permission.py +334 -0
  10. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/registry.py +48 -3
  11. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/tui.py +136 -3
  12. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/watcher.py +29 -2
  13. ring_cli-0.9.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  14. {ring_cli-0.9.0 → ring_cli-0.11.0}/LICENSE +0 -0
  15. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/__init__.py +0 -0
  16. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/__main__.py +0 -0
  17. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/cli.py +0 -0
  18. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/__init__.py +0 -0
  19. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/_args.py +0 -0
  20. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/completion.py +0 -0
  21. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/digest.py +0 -0
  22. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/doctor.py +0 -0
  23. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/focus.py +0 -0
  24. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/gc.py +0 -0
  25. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/hook.py +0 -0
  26. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/commands/stats.py +0 -0
  27. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/__init__.py +0 -0
  28. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/applescript.py +0 -0
  29. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/base.py +0 -0
  30. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/iterm2.py +0 -0
  31. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/linux_wm.py +0 -0
  32. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/neovim.py +0 -0
  33. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/terminal.py +0 -0
  34. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/focus/tmux.py +0 -0
  35. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/gc.py +0 -0
  36. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/hook_protocol.py +0 -0
  37. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/i18n.py +0 -0
  38. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/ipc.py +0 -0
  39. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/labels.py +0 -0
  40. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/__init__.py +0 -0
  41. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/base.py +0 -0
  42. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/command.py +0 -0
  43. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/notify_send.py +0 -0
  44. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/ntfy.py +0 -0
  45. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/osascript_notifier.py +0 -0
  46. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/terminal_notifier.py +0 -0
  47. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/notify/webhook.py +0 -0
  48. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/osascript.py +0 -0
  49. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/plugins.py +0 -0
  50. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/sources/__init__.py +0 -0
  51. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/sources/base.py +0 -0
  52. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/sources/claude_code.py +0 -0
  53. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/sources/codex.py +0 -0
  54. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/sources/hook_registry.py +0 -0
  55. {ring_cli-0.9.0 → ring_cli-0.11.0}/src/ring/stats.py +0 -0
  56. {ring_cli-0.9.0 → ring_cli-0.11.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.9.0
3
+ Version: 0.11.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
@@ -59,6 +59,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
59
59
  - **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
60
60
  - **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
61
61
  - **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
62
+ - **Reply to permission requests in place**: with the cursor on a 🔴 waiting row, press `p` — RiNG reads the permission dialog from that session's terminal screen, pops the numbered options in a modal, and presses your choice for you, so you don't have to jump over one by one (sessions inside tmux, plus plain iTerm2 tabs on macOS).
62
63
  - **Name your sessions**: press `n` in the TUI to name a session, e.g. "auth refactor"; once named, the board and notifications show the name instead of the workspace directory.
63
64
  - **See what it is waiting for**: in hook mode, a 🔴 waiting session carries the concrete pending item (the command to run, the question asked) — shown in the TUI and in the notification body.
64
65
  - **Fits your status bar**: `ring --format oneline` prints a `🔴2 🟢1 🟡3` one-liner for tmux / SwiftBar / waybar; `--format json` feeds scripts.
@@ -153,7 +154,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
153
154
  ## Watch Mode
154
155
 
155
156
  - With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
156
- Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
157
+ Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
157
158
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
158
159
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
159
160
  - Otherwise: Rich polling; without Rich, plain text.
@@ -172,6 +173,30 @@ hands the request to the TUI so it selects that session; otherwise it focuses th
172
173
  TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
173
174
  one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
174
175
 
176
+ ### Reply To Permission Requests In Place (`p`)
177
+
178
+ With the cursor on a 🔴 waiting row, press `p`. RiNG reads that session's screen, parses the
179
+ permission dialog (Claude Code's "Do you want to proceed?" box, including background-subagent
180
+ ones with a "from the … agent" header), and lists the numbered options verbatim in a modal.
181
+ After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
182
+ unchanged (it may have been answered while you were deciding), only then sends that single
183
+ digit, and re-checks that the dialog actually disappeared.
184
+
185
+ - **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
186
+ `tmux send-keys`.
187
+ - **Sessions in a plain iTerm2 tab on macOS** (no tmux): locates the matching iTerm2 session by
188
+ its `tty` via AppleScript, and both reads the screen and sends keys through `osascript`. The
189
+ first use triggers the macOS Automation permission prompt ("allow control of iTerm2") — allow
190
+ it once.
191
+
192
+ Safety first: if no recognizable dialog can be parsed (missing markers, numbering, or cursor),
193
+ RiNG only shows a toast and **never sends a key** — without the dialog, keystrokes would land in
194
+ the chat input box as text. If the dialog happens to vanish in the instant the digit is sent and
195
+ the digit lands in the input box, RiNG sends a Backspace to clean it up and warns you.
196
+
197
+ **Limits**: reading the screen requires a tmux pane coordinate, or (on macOS) an iTerm2 session
198
+ with a detectable tty; for other sessions, press `Enter` to jump over and reply there.
199
+
175
200
  ### Notifications
176
201
 
177
202
  Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
@@ -336,8 +361,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
336
361
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
337
362
 
338
363
  Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`,
339
- `notify_repeat_seconds`, `notify_repeat_max`, and `notify_backend`. Custom notification channels
340
- can be added by registering another `Notifier`.
364
+ `notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`.
365
+ Custom notification channels can be added by registering another `Notifier`.
366
+
367
+ Background subagent permission requests can make a session flap between waiting and working in
368
+ quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system
369
+ notification and the TUI's bell/reminder from firing again the instant a session re-enters waiting
370
+ within the cooldown window of its last alert; set it to `0` to disable the cooldown (alert on
371
+ every re-entry, the old behavior).
341
372
 
342
373
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
343
374
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -372,6 +403,7 @@ notify_ignore_dnd = false
372
403
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
373
404
  notify_repeat_seconds = [30, 120, 300]
374
405
  notify_repeat_max = 3
406
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
375
407
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
376
408
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
377
409
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
@@ -34,6 +34,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
34
34
  - **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
35
35
  - **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
36
36
  - **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
37
+ - **Reply to permission requests in place**: with the cursor on a 🔴 waiting row, press `p` — RiNG reads the permission dialog from that session's terminal screen, pops the numbered options in a modal, and presses your choice for you, so you don't have to jump over one by one (sessions inside tmux, plus plain iTerm2 tabs on macOS).
37
38
  - **Name your sessions**: press `n` in the TUI to name a session, e.g. "auth refactor"; once named, the board and notifications show the name instead of the workspace directory.
38
39
  - **See what it is waiting for**: in hook mode, a 🔴 waiting session carries the concrete pending item (the command to run, the question asked) — shown in the TUI and in the notification body.
39
40
  - **Fits your status bar**: `ring --format oneline` prints a `🔴2 🟢1 🟡3` one-liner for tmux / SwiftBar / waybar; `--format json` feeds scripts.
@@ -128,7 +129,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
128
129
  ## Watch Mode
129
130
 
130
131
  - With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
131
- Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
132
+ Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
132
133
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
133
134
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
134
135
  - Otherwise: Rich polling; without Rich, plain text.
@@ -147,6 +148,30 @@ hands the request to the TUI so it selects that session; otherwise it focuses th
147
148
  TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
148
149
  one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
149
150
 
151
+ ### Reply To Permission Requests In Place (`p`)
152
+
153
+ With the cursor on a 🔴 waiting row, press `p`. RiNG reads that session's screen, parses the
154
+ permission dialog (Claude Code's "Do you want to proceed?" box, including background-subagent
155
+ ones with a "from the … agent" header), and lists the numbered options verbatim in a modal.
156
+ After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
157
+ unchanged (it may have been answered while you were deciding), only then sends that single
158
+ digit, and re-checks that the dialog actually disappeared.
159
+
160
+ - **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
161
+ `tmux send-keys`.
162
+ - **Sessions in a plain iTerm2 tab on macOS** (no tmux): locates the matching iTerm2 session by
163
+ its `tty` via AppleScript, and both reads the screen and sends keys through `osascript`. The
164
+ first use triggers the macOS Automation permission prompt ("allow control of iTerm2") — allow
165
+ it once.
166
+
167
+ Safety first: if no recognizable dialog can be parsed (missing markers, numbering, or cursor),
168
+ RiNG only shows a toast and **never sends a key** — without the dialog, keystrokes would land in
169
+ the chat input box as text. If the dialog happens to vanish in the instant the digit is sent and
170
+ the digit lands in the input box, RiNG sends a Backspace to clean it up and warns you.
171
+
172
+ **Limits**: reading the screen requires a tmux pane coordinate, or (on macOS) an iTerm2 session
173
+ with a detectable tty; for other sessions, press `Enter` to jump over and reply there.
174
+
150
175
  ### Notifications
151
176
 
152
177
  Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
@@ -311,8 +336,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
311
336
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
312
337
 
313
338
  Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`,
314
- `notify_repeat_seconds`, `notify_repeat_max`, and `notify_backend`. Custom notification channels
315
- 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).
316
347
 
317
348
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
318
349
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -347,6 +378,7 @@ notify_ignore_dnd = false
347
378
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
348
379
  notify_repeat_seconds = [30, 120, 300]
349
380
  notify_repeat_max = 3
381
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
350
382
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
351
383
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
352
384
  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.9.0"
5
+ version = "0.11.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"
@@ -20,6 +20,8 @@
20
20
  # "none" = 完全不發通知(RiNG 當純看板)
21
21
  notify_repeat_seconds = [30, 120, 300] # 持續等你時,多久後重複提醒
22
22
  notify_repeat_max = 3 # 重複提醒上限;0 = 不限
23
+ waiting_cooldown_seconds = 180 # session 離開 WAITING 又轉回時,距上次提醒未滿這段時間就
24
+ # 不再當「新轉入」立即提醒(防翻轉轟炸);0 = 關閉(現行為)
23
25
  notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
24
26
  notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
25
27
  notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
@@ -69,6 +71,7 @@ class Config:
69
71
  notify_backend: str = "auto"
70
72
  notify_repeat_seconds: tuple[int, ...] = (30, 120, 300)
71
73
  notify_repeat_max: int = 3 # 0 = 不限
74
+ waiting_cooldown_seconds: int = 180 # 離開 WAITING 又轉回時的提醒冷卻期;0 = 關閉(現行為)
72
75
  notify_ntfy_url: str = "" # 完整 ntfy topic URL;空=ntfy 後端不可用
73
76
  notify_webhook_url: str = "" # webhook URL;空=webhook 後端不可用
74
77
  notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
@@ -138,6 +141,7 @@ def load(path: Path | None = None) -> Config:
138
141
  notify_ignore_dnd=_as_bool(raw.get("notify_ignore_dnd"), d.notify_ignore_dnd),
139
142
  notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds),
140
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)),
141
145
  notify_backend=notify_backend,
142
146
  notify_ntfy_url=(raw["notify_ntfy_url"] if isinstance(raw.get("notify_ntfy_url"), str) else ""),
143
147
  notify_webhook_url=(raw["notify_webhook_url"] if isinstance(raw.get("notify_webhook_url"), str) else ""),
@@ -208,6 +212,7 @@ _SETTERS: dict[str, Callable[[str], object]] = {
208
212
  "notify_backend": str,
209
213
  "notify_repeat_seconds": _coerce_int_list,
210
214
  "notify_repeat_max": _coerce_int,
215
+ "waiting_cooldown_seconds": _coerce_int,
211
216
  "notify_ntfy_url": str,
212
217
  "notify_webhook_url": str,
213
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:
@@ -653,3 +653,42 @@ msgstr "total wait"
653
653
 
654
654
  msgid "⚠️ 其中 {n} 段還在 🔴 等你(計到現在為止)。"
655
655
  msgstr "⚠️ {n} of these are still 🔴 waiting for you (counted up to now)."
656
+
657
+ msgid "回覆權限"
658
+ msgstr "reply perm"
659
+
660
+ msgid "回覆 {project} 的權限請求"
661
+ msgstr "Reply to the permission request from {project}"
662
+
663
+ msgid "Enter 或數字鍵送出、Esc 取消"
664
+ msgstr "Enter or a digit key sends, Esc cancels"
665
+
666
+ msgid "{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆"
667
+ msgstr "{project}: no tmux coordinates, and not a macOS iTerm2 session either — cannot reply in place"
668
+
669
+ msgid "{project}:讀不到 {backend} 畫面"
670
+ msgstr "{project}: cannot read the {backend} screen"
671
+
672
+ msgid "{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵"
673
+ msgstr "{project}: no recognizable permission dialog on screen; no key was sent"
674
+
675
+ msgid "→ {project}:已回覆權限({option})"
676
+ msgstr "→ {project}: permission answered ({option})"
677
+
678
+ msgid "{project}:權限對話框已不在,未送出任何按鍵"
679
+ msgstr "{project}: the permission dialog is gone; no key was sent"
680
+
681
+ msgid "{project}:對話框內容已變化,未送出;請再按一次 p"
682
+ msgstr "{project}: the dialog changed; nothing sent — press p again"
683
+
684
+ msgid "{project}:tmux send-keys 失敗,沒能回覆"
685
+ msgstr "{project}: tmux send-keys failed; could not reply"
686
+
687
+ msgid "{project}:已送出但對話框還在,請跳過去確認"
688
+ msgstr "{project}: key sent but the dialog is still there — jump over to check"
689
+
690
+ msgid "{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉"
691
+ msgstr "{project}: the dialog was gone and the digit landed in the input box — sent Backspace to clean it up"
692
+
693
+ msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認"
694
+ msgstr "{project}: key sent but the screen could not be re-read to verify — jump over to check"
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PROJECT VERSION\n"
10
10
  "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11
- "POT-Creation-Date: 2026-07-10 15:32+0800\n"
11
+ "POT-Creation-Date: 2026-07-12 09:22+0800\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -52,7 +52,7 @@ msgstr[1] ""
52
52
  msgid "🎤 RiNG — {sess} · {proc}"
53
53
  msgstr ""
54
54
 
55
- #: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:213
55
+ #: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:274
56
56
  msgid "圖例"
57
57
  msgstr ""
58
58
 
@@ -63,32 +63,32 @@ msgstr ""
63
63
  #: src/ring/cli.py:184 src/ring/cli.py:338 src/ring/commands/doctor.py:31
64
64
  #: src/ring/commands/doctor.py:68 src/ring/commands/doctor.py:70
65
65
  #: src/ring/commands/doctor.py:72 src/ring/commands/doctor.py:134
66
- #: src/ring/tui.py:168
66
+ #: src/ring/tui.py:229
67
67
  msgid "狀態"
68
68
  msgstr ""
69
69
 
70
- #: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:170
70
+ #: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:231
71
71
  msgid "工具"
72
72
  msgstr ""
73
73
 
74
74
  #: src/ring/cli.py:187 src/ring/cli.py:235 src/ring/commands/stats.py:60
75
- #: src/ring/tui.py:171
75
+ #: src/ring/tui.py:232
76
76
  msgid "專案"
77
77
  msgstr ""
78
78
 
79
- #: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:171
79
+ #: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:232
80
80
  msgid "進度"
81
81
  msgstr ""
82
82
 
83
- #: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:171
83
+ #: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:232
84
84
  msgid "閒置"
85
85
  msgstr ""
86
86
 
87
- #: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:171
87
+ #: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:232
88
88
  msgid "去哪"
89
89
  msgstr ""
90
90
 
91
- #: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:171
91
+ #: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:232
92
92
  msgid "動作"
93
93
  msgstr ""
94
94
 
@@ -305,41 +305,41 @@ msgstr ""
305
305
  msgid "重開 Codex session、並在它詢問時「信任」這個 hook,🔴 等你狀態才會精準起來(Codex 不會執行未信任的 hook)。"
306
306
  msgstr ""
307
307
 
308
- #: src/ring/hook.py:406
308
+ #: src/ring/hook.py:438
309
309
  #, python-brace-format
310
310
  msgid "⚠️ 偵測到其他工具也掛在 {events}:{cmds}"
311
311
  msgstr ""
312
312
 
313
- #: src/ring/hook.py:407
313
+ #: src/ring/hook.py:439
314
314
  msgid "它們會跟 RiNG 的通知重複觸發;要完全改用 RiNG,建議移除它們。"
315
315
  msgstr ""
316
316
 
317
- #: src/ring/hook.py:455 src/ring/hook.py:531
317
+ #: src/ring/hook.py:487 src/ring/hook.py:563
318
318
  #, python-brace-format
319
319
  msgid "⚠️ {path} 不是合法 JSON,先處理它再來。"
320
320
  msgstr ""
321
321
 
322
- #: src/ring/hook.py:493
322
+ #: src/ring/hook.py:525
323
323
  #, python-brace-format
324
324
  msgid "✅ RiNG hooks 已經裝過了,沒有變更。({path})"
325
325
  msgstr ""
326
326
 
327
- #: src/ring/hook.py:506
327
+ #: src/ring/hook.py:538
328
328
  #, python-brace-format
329
329
  msgid "✅ 已註冊 RiNG hooks({events})到 {path}"
330
330
  msgstr ""
331
331
 
332
- #: src/ring/hook.py:525
332
+ #: src/ring/hook.py:557
333
333
  #, python-brace-format
334
334
  msgid "ℹ️ {path} 不存在,沒有可移除的 hook。"
335
335
  msgstr ""
336
336
 
337
- #: src/ring/hook.py:552
337
+ #: src/ring/hook.py:584
338
338
  #, python-brace-format
339
339
  msgid "ℹ️ 沒有找到 RiNG hook 條目,無需移除。({path})"
340
340
  msgstr ""
341
341
 
342
- #: src/ring/hook.py:556
342
+ #: src/ring/hook.py:588
343
343
  #, python-brace-format
344
344
  msgid "✅ 已移除 RiNG hooks({events})從 {path}"
345
345
  msgstr ""
@@ -349,103 +349,167 @@ msgstr ""
349
349
  msgid "⚠️ ring plugin '{name}' 載入失敗:{error}"
350
350
  msgstr ""
351
351
 
352
- #: src/ring/tui.py:88
352
+ #: src/ring/tui.py:91
353
353
  #, python-brace-format
354
354
  msgid "為 {project} 命名(Enter 存、Esc 取消、清空移除)"
355
355
  msgstr ""
356
356
 
357
- #: src/ring/tui.py:89
357
+ #: src/ring/tui.py:92
358
358
  msgid "這個 session 在做什麼…"
359
359
  msgstr ""
360
360
 
361
- #: src/ring/tui.py:106
361
+ #: src/ring/tui.py:136
362
+ #, python-brace-format
363
+ msgid "回覆 {project} 的權限請求"
364
+ msgstr ""
365
+
366
+ #: src/ring/tui.py:141
367
+ msgid "Enter 或數字鍵送出、Esc 取消"
368
+ msgstr ""
369
+
370
+ #: src/ring/tui.py:164
362
371
  msgid "離場"
363
372
  msgstr ""
364
373
 
365
- #: src/ring/tui.py:107
374
+ #: src/ring/tui.py:165
366
375
  msgid "刷新"
367
376
  msgstr ""
368
377
 
369
- #: src/ring/tui.py:108
378
+ #: src/ring/tui.py:166
370
379
  msgid "含已離場"
371
380
  msgstr ""
372
381
 
373
- #: src/ring/tui.py:109
382
+ #: src/ring/tui.py:167
374
383
  msgid "命名"
375
384
  msgstr ""
376
385
 
377
- #: src/ring/tui.py:110
386
+ #: src/ring/tui.py:168
378
387
  msgid "隱藏"
379
388
  msgstr ""
380
389
 
381
- #: src/ring/tui.py:111
390
+ #: src/ring/tui.py:169
382
391
  msgid "最久等待"
383
392
  msgstr ""
384
393
 
385
- #: src/ring/tui.py:112 src/ring/tui.py:113
394
+ #: src/ring/tui.py:170
395
+ msgid "回覆權限"
396
+ msgstr ""
397
+
398
+ #: src/ring/tui.py:171 src/ring/tui.py:172
386
399
  msgid "跳過去"
387
400
  msgstr ""
388
401
 
389
- #: src/ring/tui.py:189
402
+ #: src/ring/tui.py:250
390
403
  msgid "💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks"
391
404
  msgstr ""
392
405
 
393
- #: src/ring/tui.py:191
406
+ #: src/ring/tui.py:252
394
407
  msgid "↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端"
395
408
  msgstr ""
396
409
 
397
- #: src/ring/tui.py:227
410
+ #: src/ring/tui.py:288
398
411
  #, python-brace-format
399
412
  msgid "🔔 {names} 在等你回話"
400
413
  msgstr ""
401
414
 
402
- #: src/ring/tui.py:268
415
+ #: src/ring/tui.py:329
403
416
  #, python-brace-format
404
417
  msgid "→ 已跳到 {project}(來自通知)"
405
418
  msgstr ""
406
419
 
407
- #: src/ring/tui.py:273
420
+ #: src/ring/tui.py:334
408
421
  msgid "那個 session 已不在場"
409
422
  msgstr ""
410
423
 
411
- #: src/ring/tui.py:332
424
+ #: src/ring/tui.py:393
412
425
  msgid " ⚠ hook 可能失效:來源檔有更新但沒有 heartbeat"
413
426
  msgstr ""
414
427
 
415
- #: src/ring/tui.py:361 src/ring/tui.py:385 src/ring/tui.py:411
428
+ #: src/ring/tui.py:422 src/ring/tui.py:446 src/ring/tui.py:479
429
+ #: src/ring/tui.py:544
416
430
  msgid "(沒有選到 session)"
417
431
  msgstr ""
418
432
 
419
- #: src/ring/tui.py:392
433
+ #: src/ring/tui.py:453
420
434
  #, python-brace-format
421
435
  msgid "→ {project}({where})"
422
436
  msgstr ""
423
437
 
424
- #: src/ring/tui.py:394
438
+ #: src/ring/tui.py:455
425
439
  #, python-brace-format
426
440
  msgid "{project}:{msg}"
427
441
  msgstr ""
428
442
 
429
- #: src/ring/tui.py:402
443
+ #: src/ring/tui.py:463
430
444
  msgid "(沒有正在等待的 session)"
431
445
  msgstr ""
432
446
 
433
- #: src/ring/tui.py:419
447
+ #: src/ring/tui.py:486
448
+ #, python-brace-format
449
+ msgid "{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆"
450
+ msgstr ""
451
+
452
+ #: src/ring/tui.py:493
453
+ #, python-brace-format
454
+ msgid "{project}:讀不到 {backend} 畫面"
455
+ msgstr ""
456
+
457
+ #: src/ring/tui.py:497
458
+ #, python-brace-format
459
+ msgid "{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵"
460
+ msgstr ""
461
+
462
+ #: src/ring/tui.py:526
463
+ #, python-brace-format
464
+ msgid "→ {project}:已回覆權限({option})"
465
+ msgstr ""
466
+
467
+ #: src/ring/tui.py:530
468
+ #, python-brace-format
469
+ msgid "{project}:權限對話框已不在,未送出任何按鍵"
470
+ msgstr ""
471
+
472
+ #: src/ring/tui.py:531
473
+ #, python-brace-format
474
+ msgid "{project}:對話框內容已變化,未送出;請再按一次 p"
475
+ msgstr ""
476
+
477
+ #: src/ring/tui.py:532
478
+ #, python-brace-format
479
+ msgid "{project}:tmux send-keys 失敗,沒能回覆"
480
+ msgstr ""
481
+
482
+ #: src/ring/tui.py:533
483
+ #, python-brace-format
484
+ msgid "{project}:已送出但對話框還在,請跳過去確認"
485
+ msgstr ""
486
+
487
+ #: src/ring/tui.py:535
488
+ #, python-brace-format
489
+ msgid "{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉"
490
+ msgstr ""
491
+
492
+ #: src/ring/tui.py:537
493
+ #, python-brace-format
494
+ msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認"
495
+ msgstr ""
496
+
497
+ #: src/ring/tui.py:552
434
498
  #, python-brace-format
435
499
  msgid "再按一次 d 隱藏 {project}(有新活動會自動重新出現)"
436
500
  msgstr ""
437
501
 
438
- #: src/ring/tui.py:424
502
+ #: src/ring/tui.py:557
439
503
  #, python-brace-format
440
504
  msgid "{project} 是仍在執行的 process,無法隱藏;請跳過去結束它"
441
505
  msgstr ""
442
506
 
443
- #: src/ring/tui.py:432
507
+ #: src/ring/tui.py:565
444
508
  #, python-brace-format
445
509
  msgid "已隱藏 {project},並清掉 RiNG 狀態;有新活動會自動重新出現"
446
510
  msgstr ""
447
511
 
448
- #: src/ring/tui.py:434
512
+ #: src/ring/tui.py:567
449
513
  #, python-brace-format
450
514
  msgid "已隱藏 {project}(沒有 RiNG registry 可清;有新活動會自動重新出現)"
451
515
  msgstr ""