ring-cli 0.12.0__tar.gz → 0.13.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 (58) hide show
  1. {ring_cli-0.12.0 → ring_cli-0.13.0}/PKG-INFO +28 -4
  2. {ring_cli-0.12.0 → ring_cli-0.13.0}/README.en.md +27 -3
  3. {ring_cli-0.12.0 → ring_cli-0.13.0}/pyproject.toml +1 -1
  4. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/cli.py +2 -0
  5. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/config.py +18 -0
  6. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/hook.py +88 -11
  7. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/hook_protocol.py +15 -10
  8. ring_cli-0.13.0/src/ring/question_detect.py +93 -0
  9. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/registry.py +69 -21
  10. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/transcript.py +20 -0
  11. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/tui.py +23 -5
  12. {ring_cli-0.12.0 → ring_cli-0.13.0}/LICENSE +0 -0
  13. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/__init__.py +0 -0
  14. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/__main__.py +0 -0
  15. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/__init__.py +0 -0
  16. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/_args.py +0 -0
  17. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/completion.py +0 -0
  18. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/digest.py +0 -0
  19. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/doctor.py +0 -0
  20. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/focus.py +0 -0
  21. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/gc.py +0 -0
  22. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/hook.py +0 -0
  23. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/commands/stats.py +0 -0
  24. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/__init__.py +0 -0
  25. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/applescript.py +0 -0
  26. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/base.py +0 -0
  27. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/iterm2.py +0 -0
  28. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/linux_wm.py +0 -0
  29. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/neovim.py +0 -0
  30. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/terminal.py +0 -0
  31. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/focus/tmux.py +0 -0
  32. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/gc.py +0 -0
  33. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/i18n.py +0 -0
  34. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/ipc.py +0 -0
  35. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/labels.py +0 -0
  36. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  37. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  38. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/locale/ring.pot +0 -0
  39. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/__init__.py +0 -0
  40. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/base.py +0 -0
  41. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/command.py +0 -0
  42. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/notify_send.py +0 -0
  43. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/ntfy.py +0 -0
  44. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/osascript_notifier.py +0 -0
  45. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/terminal_notifier.py +0 -0
  46. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/notify/webhook.py +0 -0
  47. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/osascript.py +0 -0
  48. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/payload_log.py +0 -0
  49. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/permission.py +0 -0
  50. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/plugins.py +0 -0
  51. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/__init__.py +0 -0
  52. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/base.py +0 -0
  53. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/claude_code.py +0 -0
  54. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/codex.py +0 -0
  55. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/hook_registry.py +0 -0
  56. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/sources/local_llm.py +0 -0
  57. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/stats.py +0 -0
  58. {ring_cli-0.12.0 → ring_cli-0.13.0}/src/ring/watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
5
5
  Keywords: claude-code,codex,ollama,llama-cpp,tui,dashboard,session,monitor,rich
6
6
  Author: Wei Lee
@@ -326,9 +326,31 @@ Claude Code events:
326
326
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
327
327
  | `SessionEnd` | removed from the board |
328
328
 
329
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
330
- also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
331
- 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
329
+ `Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission
330
+ request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at
331
+ the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any
332
+ trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`),
333
+ with the question as its detail; a question that only appears mid-message, with a statement at the end,
334
+ does not count — conservative on purpose, biased toward missing a case rather than a false positive. This
335
+ applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the
336
+ `detect_stop_questions` config key can turn it off (on by default).
337
+
338
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`,
339
+ and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare
340
+ event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while
341
+ an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence
342
+ itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than
343
+ `codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command
344
+ as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it.
345
+
346
+ Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish
347
+ these):
348
+
349
+ - **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running
350
+ command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up
351
+ action, 🔴 lingers until the next event or `Stop`.
352
+ - The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook
353
+ event to notify from); headless `--watch` does not send this particular notification.
332
354
 
333
355
  Verify that hooks are writing:
334
356
 
@@ -409,6 +431,8 @@ legend = true
409
431
  active_window_seconds = 21600
410
432
  working_threshold_seconds = 90
411
433
  waiting_window_seconds = 1800
434
+ codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off
435
+ detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡
412
436
  notify_sound = true
413
437
  notify_sound_name = "Glass"
414
438
  notify_ignore_dnd = false
@@ -301,9 +301,31 @@ Claude Code events:
301
301
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
302
302
  | `SessionEnd` | removed from the board |
303
303
 
304
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
305
- also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
306
- 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
304
+ `Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission
305
+ request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at
306
+ the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any
307
+ trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`),
308
+ with the question as its detail; a question that only appears mid-message, with a statement at the end,
309
+ does not count — conservative on purpose, biased toward missing a case rather than a false positive. This
310
+ applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the
311
+ `detect_stop_questions` config key can turn it off (on by default).
312
+
313
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`,
314
+ and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare
315
+ event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while
316
+ an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence
317
+ itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than
318
+ `codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command
319
+ as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it.
320
+
321
+ Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish
322
+ these):
323
+
324
+ - **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running
325
+ command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up
326
+ action, 🔴 lingers until the next event or `Stop`.
327
+ - The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook
328
+ event to notify from); headless `--watch` does not send this particular notification.
307
329
 
308
330
  Verify that hooks are writing:
309
331
 
@@ -384,6 +406,8 @@ legend = true
384
406
  active_window_seconds = 21600
385
407
  working_threshold_seconds = 90
386
408
  waiting_window_seconds = 1800
409
+ codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off
410
+ detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡
387
411
  notify_sound = true
388
412
  notify_sound_name = "Glass"
389
413
  notify_ignore_dnd = false
@@ -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.12.0"
5
+ version = "0.13.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"
@@ -402,6 +402,8 @@ def run_config(args: list[str]) -> int:
402
402
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
403
403
  # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
404
404
  # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
405
+ # 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的
406
+ # 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。
405
407
  frames = 0
406
408
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
407
409
  if not HAVE_RICH:
@@ -9,6 +9,11 @@
9
9
  active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
10
10
  working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
11
11
  waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
12
+ codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數
13
+ # → 看板判定真的停下來等核可(🔴 等你);0 = 關閉
14
+ detect_stop_questions = true # Stop 事件時,若最後一則 assistant 訊息「結尾」是純文字
15
+ # 提問(沒用 AskUserQuestion、沒有權限請求)→ 🔴 等你
16
+ # (waiting_kind="question");關閉則 Stop 一律 🟡
12
17
  notify_sound = true # 系統通知帶聲音
13
18
  notify_sound_name = "Glass" # macOS / terminal-notifier sound name
14
19
  notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
@@ -64,6 +69,13 @@ class Config:
64
69
  active_window_seconds: int = 6 * 60 * 60
65
70
  working_threshold_seconds: int = 90
66
71
  waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
72
+ # Codex 的 hook 沒有「使用者已核可」事件也沒有心跳(0.144.4 實證):policy 自動放行時
73
+ # 下一個事件幾秒內就到;真的停下來等人時 hook 通道完全靜默。所以「最後一個事件是
74
+ # PermissionRequest 且已靜默超過這個門檻」就判定在等核可。0 = 關閉這個判定。
75
+ codex_permission_wait_seconds: int = 10
76
+ # Stop 事件時,若最後一則 assistant 訊息結尾是純文字提問(見 question_detect.py)
77
+ # → 升級成 🔴 等你(waiting_kind="question")。預設開;關閉則 Stop 一律維持 🟡。
78
+ detect_stop_questions: bool = True
67
79
  notify_sound: bool = True
68
80
  notify_sound_name: str = "Glass"
69
81
  notify_ignore_dnd: bool = False
@@ -139,6 +151,10 @@ def load(path: Path | None = None) -> Config:
139
151
  active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
140
152
  working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
141
153
  waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
154
+ codex_permission_wait_seconds=_as_int(
155
+ raw.get("codex_permission_wait_seconds"), d.codex_permission_wait_seconds
156
+ ),
157
+ detect_stop_questions=_as_bool(raw.get("detect_stop_questions"), d.detect_stop_questions),
142
158
  notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
143
159
  notify_sound_name=(
144
160
  raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
@@ -212,6 +228,8 @@ _SETTERS: dict[str, Callable[[str], object]] = {
212
228
  "active_window_seconds": _coerce_int,
213
229
  "working_threshold_seconds": _coerce_int,
214
230
  "waiting_window_seconds": _coerce_int,
231
+ "codex_permission_wait_seconds": _coerce_int,
232
+ "detect_stop_questions": _coerce_bool,
215
233
  "notify_sound": _coerce_bool,
216
234
  "notify_sound_name": str,
217
235
  "notify_ignore_dnd": _coerce_bool,
@@ -9,8 +9,15 @@ Agent CLI 在各事件把一段 JSON 從 stdin 餵進來。我們據此 upsert
9
9
  事件 → 狀態:
10
10
  SessionStart / UserPromptSubmit → 🟢 工作中(剛開始 / 你剛回話,台上在跑)
11
11
  PreToolUse(非 action)/ PostToolUse → 🟢 工作中(工具在動,順便清掉剛答完的等你)
12
- Stop → 🟡 跑完停著(回完一輪,不代表需要你回應)
13
- PermissionRequest / actionable Notification / AskUserQuestion → 🔴 等你(需要你決策)
12
+ Stop → 🟡 跑完停著(回完一輪,不代表需要你回應);但最後一則
13
+ assistant 訊息「結尾」像純文字提問時 → 🔴 等你
14
+ (waiting_kind="question",見 question_detect.py;
15
+ config 鍵 detect_stop_questions 可關閉,預設開)
16
+ PermissionRequest(裸) → 🟢 工作中(權限判定中,多半瞬間自動放行;Claude Code
17
+ 真的停下來等人由後續的 permission_prompt Notification
18
+ 兜底轉 🔴;Codex 沒有這種事件,改由讀取側的靜默逾時
19
+ 判定補上,見 registry._promote_codex_permission_wait)
20
+ actionable Notification / AskUserQuestion → 🔴 等你(需要你決策)
14
21
  SessionEnd → 刪檔(乾淨離場)
15
22
  """
16
23
 
@@ -22,16 +29,17 @@ import shutil
22
29
  import subprocess
23
30
  import sys
24
31
  import time
25
- from dataclasses import dataclass
32
+ from dataclasses import dataclass, replace
26
33
  from pathlib import Path
27
34
  from typing import Any
28
35
  from urllib.parse import quote
29
36
 
30
37
  from ring.config import get_config
31
- from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload
38
+ from ring.hook_protocol import HOOK_EVENTS, NormalizedHookEvent, adapter_for, provider_from_payload
32
39
  from ring.i18n import gettext as _
33
40
  from ring.i18n import set_lang
34
41
  from ring.payload_log import maybe_log_raw_payload
42
+ from ring.question_detect import stop_last_assistant_text, trailing_question_detail
35
43
  from ring.registry import (
36
44
  RING_REGISTRY,
37
45
  Session,
@@ -46,9 +54,10 @@ _HOOK_EVENTS = list(HOOK_EVENTS)
46
54
 
47
55
  # Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。
48
56
  # 保守取有實證可用的:PermissionRequest(裸事件只代表權限準備判定;明確需互動才 → 🔴)、
49
- # PreToolUse(→ 動作/清除)、Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件
50
- # 有風險,故不照搬 Claude 全套。
51
- _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"]
57
+ # PreToolUse(→ 動作/清除)、PostToolUse(工具跑完 → 清除;0.144.4 binary 內嵌 schema
58
+ # 證實存在——它是「核可後最早的下一個事件」,能盡早清掉讀取側的核可等待判定)、
59
+ # Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。
60
+ _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "PostToolUse", "Stop"]
52
61
 
53
62
  # hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會
54
63
  # block 到使用者作答。install 用它判斷「既有條目要不要更新」——舊版裝的 timeout=10 會被升上來。
@@ -171,6 +180,7 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
171
180
  event = adapter.normalize(data)
172
181
  if event is None:
173
182
  return
183
+ event = _maybe_flag_trailing_question(event, data)
174
184
 
175
185
  unhide_session(event.session_id)
176
186
  path = RING_REGISTRY / f"{quote(event.session_id, safe=':')}.json"
@@ -197,6 +207,9 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
197
207
  "cwd": event.cwd,
198
208
  "origin_cwd": event.cwd,
199
209
  "status": event.status.value,
210
+ # 最後一個 hook 事件名:讀取側據此做 codex 的核可等待判定(最後事件是
211
+ # PermissionRequest 且靜默逾時 → 🔴),任何後續事件覆寫它就自然清紅。
212
+ "last_event": event.event,
200
213
  "last_active": now,
201
214
  "heartbeat_at": now,
202
215
  "last_action": last_action,
@@ -211,10 +224,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
211
224
  payload["todo"] = list(todo)
212
225
  if event.waiting_for:
213
226
  payload["waiting_for"] = event.waiting_for
227
+
228
+ # 裸 PermissionRequest(→ WORKING)帶著最具體的 tool/指令摘要;真的停下來等人時,
229
+ # Claude Code 只補發籠統的 permission_prompt Notification("Claude needs your
230
+ # permission",不含 tool/指令)。所以把摘要暫存進 row,Notification 轉 WAITING 時
231
+ # (還新鮮的話)拿它當 waiting_detail,看板與通知才看得到具體是哪條指令在等核可。
232
+ # Codex 沒有 Notification:這份暫存改由讀取側的靜默逾時判定拿去當 waiting_detail
233
+ # (見 registry._promote_codex_permission_wait)。
234
+ pending_detail, pending_at = _pending_permission_detail(event, prev_row, now)
235
+ if pending_detail:
236
+ payload["pending_permission_detail"] = pending_detail
237
+ payload["pending_permission_detail_at"] = pending_at
238
+
214
239
  if event.status is Status.WAITING and event.waiting_kind:
215
240
  payload["waiting_kind"] = event.waiting_kind
216
- if event.status is Status.WAITING and event.detail:
217
- payload["waiting_detail"] = event.detail
241
+ if event.status is Status.WAITING:
242
+ waiting_detail = event.detail
243
+ if event.event == "Notification" and event.waiting_kind == "permission" and pending_detail:
244
+ waiting_detail = pending_detail
245
+ if waiting_detail:
246
+ payload["waiting_detail"] = waiting_detail
218
247
  tty = event.tty or _controlling_tty() or _session_tty(adapter.process_names)
219
248
  if tty:
220
249
  payload["tty"] = tty
@@ -244,6 +273,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
244
273
  _ring_waiting_now(event, payload, last_action)
245
274
 
246
275
 
276
+ def _maybe_flag_trailing_question(event: NormalizedHookEvent, data: dict[str, Any]) -> NormalizedHookEvent:
277
+ """Stop 事件、目前判定 🟡 跑完停著、且訊息結尾像純文字提問時,升級成 🔴 等你。
278
+
279
+ 這是 hook_protocol._ALWAYS_STATUS 的 Stop→IDLE 之外唯一會讀 assistant 文字的地方——
280
+ 寫死的映射本身測不到「agent 用文字問了問題但沒用 AskUserQuestion」這種情況(見
281
+ ring.question_detect 模組 docstring)。保守判準:只看訊息結尾,中段有問句、結尾是
282
+ 陳述句一律不算,寧可漏報不要誤報。config 關閉(detect_stop_questions=false)或非
283
+ Stop / 非 IDLE 一律原樣放行;抓不到文字或結尾不是問句也放行。
284
+ """
285
+ if event.event != "Stop" or event.status is not Status.IDLE:
286
+ return event
287
+ if not get_config().detect_stop_questions:
288
+ return event
289
+ text = stop_last_assistant_text(data)
290
+ detail = trailing_question_detail(text)
291
+ if not detail:
292
+ return event
293
+ return replace(event, status=Status.WAITING, waiting_kind="question", detail=detail)
294
+
295
+
247
296
  def _previous_row(path: Path) -> dict[str, Any]:
248
297
  """讀 registry 檔目前的內容(給轉換偵測與通知冷卻用);沒檔 / 壞檔回空 dict。"""
249
298
  try:
@@ -258,6 +307,32 @@ def _as_epoch(v: Any) -> float | None:
258
307
  return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
259
308
 
260
309
 
310
+ # 暫存的權限 detail 新鮮期(秒):permission_prompt Notification 通常在裸 PermissionRequest
311
+ # 後 ~6 秒到;超過這個窗口的暫存視為過期(大概率屬於已解決的舊請求),不再拿來當 detail。
312
+ _PENDING_DETAIL_TTL = 120.0
313
+
314
+ # 這些事件代表權限已放行(工具跑完)或回合已推進(新輸入 / 回合結束)→ 清掉暫存。
315
+ _PENDING_DETAIL_CLEAR_EVENTS = {"PostToolUse", "UserPromptSubmit", "Stop"}
316
+
317
+
318
+ def _pending_permission_detail(event: NormalizedHookEvent, prev_row: dict[str, Any], now: float) -> tuple[str, float]:
319
+ """決定這次要寫進 row 的「暫存權限 detail」:(detail, 暫存時間戳);不留就回 ("", 0.0)。
320
+
321
+ - 裸 PermissionRequest(→ WORKING)帶 detail → 以這次的 detail 起新暫存
322
+ - PostToolUse / UserPromptSubmit / Stop → 清掉(已放行或回合推進,舊暫存失效)
323
+ - 其餘事件 → 沿用 prev_row 的暫存(row 每次重建,不帶會遺失),但過期就丟
324
+ """
325
+ if event.event == "PermissionRequest" and event.status is Status.WORKING and event.detail:
326
+ return event.detail, now
327
+ if event.event in _PENDING_DETAIL_CLEAR_EVENTS:
328
+ return "", 0.0
329
+ prev_detail = prev_row.get("pending_permission_detail")
330
+ prev_at = _as_epoch(prev_row.get("pending_permission_detail_at"))
331
+ if isinstance(prev_detail, str) and prev_detail and prev_at is not None and now - prev_at <= _PENDING_DETAIL_TTL:
332
+ return prev_detail, prev_at
333
+ return "", 0.0
334
+
335
+
261
336
  def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool:
262
337
  """hook 的 waiting 通知是否放行:距上次通知未滿 ``waiting_cooldown_seconds`` 就不發。
263
338
 
@@ -274,9 +349,11 @@ def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool:
274
349
  def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
275
350
  """session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。
276
351
 
277
- WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」
352
+ WAITING 是 hook 資料驅動的(scan 模式永不標 WAITING),hook 是第一手知道「等你」
278
353
  的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
279
- 你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
354
+ 你)。唯一的例外是 codex 的核可等待:那是讀取側對 hook row 的靜默逾時判定,沒有
355
+ 對應的 hook 事件可在這裡發通知,由 TUI 的提醒排程器代發(見 tui._ring_on_waiting_alerts)。
356
+ backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
280
357
  (agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。
281
358
  失敗安靜吞掉,絕不擋住 session。
282
359
  """
@@ -44,7 +44,6 @@ _ALWAYS_STATUS = {
44
44
  "UserPromptSubmit": Status.WORKING,
45
45
  "Stop": Status.IDLE,
46
46
  "SessionEnd": Status.ENDED,
47
- "PermissionRequest": Status.WAITING,
48
47
  }
49
48
 
50
49
  _ACTION_REQUIRED_NOTIFICATION_TYPES = {
@@ -105,13 +104,17 @@ class CommonHookAdapter:
105
104
  status = Status.WORKING
106
105
  elif explicit_requires_action is not None:
107
106
  status = Status.WAITING if explicit_requires_action else Status.IDLE
108
- elif event == "PermissionRequest" and self.provider == "codex":
109
- # Codex 會在權限「準備判定」時送 PermissionRequest;即使既有 policy
110
- # 隨後自動放行、畫面從未停下來等人,也會經過這個 hook。裸事件因此只能
111
- # 證明 agent 還在處理工具呼叫,不能當成使用者需要回應。若 payload 有
112
- # requires_action / waiting_for 等明確訊號,已由上面的分支判成 WAITING;
113
- # 明確的 requires_action=false 也在上面判成 IDLE,不會落到這裡的 WORKING。
114
- status = Status.WORKING
107
+ elif event == "PermissionRequest":
108
+ # PermissionRequest 在權限「準備判定」時就發,不分 provider:Claude Code
109
+ # 連 subagent 的唯讀工具呼叫都會經過(帶 agent_id/agent_type),Codex 則在
110
+ # policy 判定前發。多數在幾秒內被 policy 自動放行、畫面從未停下來等人——
111
+ # 裸事件因此只能證明 agent 還在處理工具呼叫,不能當成使用者需要回應
112
+ # → WORKING。payload 本身就要求互動(AskUserQuestion / questions / options)
113
+ # 才直接判 WAITING。Claude Code 真的停下來等人時,會在數秒後補發
114
+ # notification_type=permission_prompt 的 Notification,由下面的 Notification
115
+ # 分支兜底轉 WAITING(內建 debounce)。顯式 requires_action / waiting_for
116
+ # 訊號已由上面的分支先處理(true → WAITING、false → IDLE),不會落到這裡。
117
+ status = Status.WAITING if _is_action_required_payload(data) else Status.WORKING
115
118
  elif event == "Notification":
116
119
  status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE
117
120
  elif event == "PreToolUse":
@@ -238,12 +241,14 @@ def _waiting_kind(data: Mapping[str, Any], event: str, status: Status) -> str:
238
241
 
239
242
  if "plan" in waiting_for or "plan" in tool_name or "plan" in detail:
240
243
  return "plan"
244
+ if tool_name == "askuserquestion":
245
+ # 先於 PermissionRequest 判斷:AskUserQuestion 也會以 PermissionRequest 事件
246
+ # 進來(權限判定包著問題),但使用者要回的是「問題」不是「權限」。
247
+ return "question"
241
248
  if event == "PermissionRequest" or notification_type == "permission_prompt":
242
249
  return "permission"
243
250
  if waiting_for in {"approval", "permission"}:
244
251
  return "permission"
245
- if tool_name == "askuserquestion":
246
- return "question"
247
252
  if waiting_for in {
248
253
  "choice",
249
254
  "choices",
@@ -0,0 +1,93 @@
1
+ """偵測 Stop 事件裡「回合結尾純文字提問」。
2
+
3
+ agent 有時不用 ``AskUserQuestion``、不觸發權限請求,只是在文字裡問了一句話就停下來
4
+ (例如「要不要順便修 B?」)。既有的 Stop → 🟡 跑完停著是寫死的(見
5
+ ``hook_protocol._ALWAYS_STATUS``),從不讀 assistant 文字,這類提問因此測不到、使用者
6
+ 以為沒東西要回。
7
+
8
+ 設計原則:保守,寧可漏報也不要誤報。只看**訊息結尾**:
9
+
10
+ - 問句出現在訊息中段、結尾是陳述句 → 不算(避免把「先確認一下:你要 A 還是 B?我會用
11
+ A 繼續做」這種訊息誤判——它結尾是陳述句,不該轉紅)。
12
+ - 結尾若是圍欄程式碼區塊(```` ``` ... ``` ````),先把整個區塊剝掉再看區塊之前的最後一行
13
+ ——常見於「這樣可以嗎?\\n\\n```bash\\nls -la\\n```」,圍欄本身不是問句,但它前面那句是。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from ring.transcript import _last_assistant_text, _tail_records
23
+
24
+ _DETAIL_MAX = 160
25
+
26
+ # 收尾判斷前,先剝掉這些包裹字元(markdown 強調記號、引號、括號),
27
+ # 例如「...要繼續嗎?**」「...要繼續嗎?」」。
28
+ _TRAILING_WRAPPERS = "*_`\"' )]>"
29
+
30
+ _QUESTION_MARKS = ("?", "?")
31
+
32
+
33
+ def stop_last_assistant_text(data: Mapping[str, Any]) -> str:
34
+ """取得 Stop payload 對應的最後一則 assistant 訊息文字。
35
+
36
+ 優先讀 payload 本身的 ``last_assistant_message``(Claude Code、Codex 的實際 Stop
37
+ payload 都帶這欄——見 ``~/.config/ring/hook_payloads.jsonl`` 實錄);缺欄位或空字串
38
+ 才退回從 ``transcript_path`` 尾端讀(有界讀取,缺檔/壞格式安全回空字串,不炸)。
39
+ """
40
+ text = data.get("last_assistant_message")
41
+ if isinstance(text, str) and text.strip():
42
+ return text
43
+ tp = data.get("transcript_path") or data.get("transcriptPath")
44
+ if isinstance(tp, str) and tp:
45
+ try:
46
+ records = _tail_records(Path(tp))
47
+ except Exception:
48
+ return ""
49
+ return _last_assistant_text(records)
50
+ return ""
51
+
52
+
53
+ def _strip_trailing_code_fence(text: str) -> str:
54
+ """剝掉文字尾端的圍欄程式碼區塊(可能不只一個),回傳剝除後的文字。"""
55
+ lines = text.splitlines()
56
+ changed = True
57
+ while changed and lines:
58
+ changed = False
59
+ while lines and not lines[-1].strip():
60
+ lines.pop()
61
+ if lines and lines[-1].strip().startswith("```"):
62
+ closing = len(lines) - 1
63
+ opening = None
64
+ for i in range(closing - 1, -1, -1):
65
+ if lines[i].strip().startswith("```"):
66
+ opening = i
67
+ break
68
+ if opening is not None:
69
+ lines = lines[:opening]
70
+ changed = True
71
+ return "\n".join(lines)
72
+
73
+
74
+ def _strip_trailing_wrappers(line: str) -> str:
75
+ line = line.rstrip()
76
+ while line and line[-1] in _TRAILING_WRAPPERS:
77
+ line = line[:-1].rstrip()
78
+ return line
79
+
80
+
81
+ def trailing_question_detail(text: str) -> str:
82
+ """``text`` 的結尾若是問句,回傳該行摘要(截斷合理長度);否則回傳空字串。"""
83
+ if not text:
84
+ return ""
85
+ stripped = _strip_trailing_code_fence(text.rstrip())
86
+ lines = [ln for ln in stripped.splitlines() if ln.strip()]
87
+ if not lines:
88
+ return ""
89
+ last_line = _strip_trailing_wrappers(lines[-1])
90
+ if not last_line or last_line[-1] not in _QUESTION_MARKS:
91
+ return ""
92
+ detail = " ".join(last_line.split())
93
+ return detail if len(detail) <= _DETAIL_MAX else detail[: _DETAIL_MAX - 1] + "…"
@@ -8,7 +8,9 @@
8
8
  2. zero-config fallback:直接掃 ``~/.claude/projects/**/*.jsonl``,用檔案 mtime
9
9
  推活躍度,從記錄裡的 ``cwd`` 欄位還原真實路徑(避開目錄名以 ``-`` 編碼
10
10
  造成的 hyphen 還原歧義)。scan 模式不把「回完一輪」當成 🔴 WAITING;
11
- WAITING 只保留給 hook 可確認的權限 / 選項等互動。
11
+ WAITING 一律由 hook 資料驅動:hook 事件直接標的權限 / 選項互動,加上
12
+ codex 的核可等待靜默逾時判定(``_promote_codex_permission_wait``——它讀的
13
+ 也是 hook row,是推遲判定而非 scan 猜測)。
12
14
 
13
15
  額外富化:
14
16
  - ``tmux_target``:靠 tmux pane 的 current_path 對 cwd,給你「去哪」的座標。
@@ -53,6 +55,8 @@ _CFG = get_config()
53
55
  ACTIVE_WINDOW_SECONDS = _CFG.active_window_seconds # 只看最近這段時間動過的 session(預設 6h)
54
56
  WORKING_THRESHOLD_SECONDS = _CFG.working_threshold_seconds # 多久沒動 → 🟢 工作中 變 🟡 閒置
55
57
  WAITING_WINDOW_SECONDS = _CFG.waiting_window_seconds # IDLE 升 WAITING 的時間窗上限(預設 30 分)
58
+ # codex 裸 PermissionRequest 後 hook 靜默超過這秒數 → 判定真的停下來等核可(0 = 關閉)
59
+ CODEX_PERMISSION_WAIT_SECONDS = _CFG.codex_permission_wait_seconds
56
60
  _SUBPROCESS_CACHE_TTL = 1.0 # ps / tmux 結果的短快取,省掉同一次刷新內的重複呼叫
57
61
 
58
62
  # Claude Code SessionStart payload 的 source 值(不是 provider)。舊版 bug 曾把它誤當
@@ -349,7 +353,9 @@ def _apply_waiting(
349
353
  """對話尾是 end_turn 且在時間窗內時,將 live/idle scan row 收斂為 IDLE。
350
354
 
351
355
  純函式、可單測,不依賴 module-level 常數。
352
- 不把回合結束升成 WAITING;WAITING 只保留給 hook 確認的權限 / 選項互動:
356
+ 不把回合結束升成 WAITING;WAITING 一律由 hook 資料驅動(hook 直接標的權限 /
357
+ 選項互動,或 ``_promote_codex_permission_wait`` 對 hook row 的靜默逾時判定),
358
+ scan 猜測永遠不標:
353
359
  - WORKING(< 90s):若尾端已是 end_turn,代表回合其實結束了,收斂成 IDLE。
354
360
  - ENDED:超過活躍窗,不升。
355
361
  """
@@ -358,6 +364,34 @@ def _apply_waiting(
358
364
  return status
359
365
 
360
366
 
367
+ def _promote_codex_permission_wait(
368
+ provider: str,
369
+ status: Status,
370
+ last_event: str,
371
+ age_seconds: float,
372
+ threshold: float,
373
+ ) -> bool:
374
+ """codex hook row 的核可等待判定:裸 PermissionRequest 後靜默逾時 → 該升 🔴 嗎。
375
+
376
+ Codex(0.144.4 實證)的 hook 是封閉的 10 事件枚舉:沒有「使用者已核可」事件、沒有
377
+ 心跳,rollout 檔在等核可期間也完全靜默。policy 自動放行時,下一個事件(PostToolUse /
378
+ 下一個 PreToolUse / Stop)幾秒內就會到;真的停下來等人時 hook 通道只會一直沉默。
379
+ 所以「最後一個 hook 事件是 PermissionRequest 且已靜默超過門檻」本身就是可靠的等待
380
+ 訊號——這是對 hook 資料的推遲判定,不是 scan 猜測。任何後續 hook 事件會覆寫
381
+ last_event,自然清紅。
382
+
383
+ 只對 codex 啟用:claude-code 真的停下來等人時會補發 permission_prompt Notification
384
+ (hook 直接標 🔴),不需要、也不該重複走這條路。純函式、可單測。
385
+ """
386
+ return (
387
+ provider == "codex"
388
+ and status is Status.WORKING
389
+ and last_event == "PermissionRequest"
390
+ and threshold > 0
391
+ and age_seconds > threshold
392
+ )
393
+
394
+
361
395
  def _hook_heartbeat_stale(
362
396
  source_path: str,
363
397
  heartbeat_at: float,
@@ -1329,26 +1363,40 @@ def _hook_sessions(
1329
1363
  if purge_session_start_phantoms:
1330
1364
  f.unlink(missing_ok=True)
1331
1365
  continue
1332
- out.append(
1333
- Session(
1334
- session_id=str(data["session_id"]),
1335
- cwd=str(data.get("cwd", "")),
1336
- status=Status(data.get("status", "idle")),
1337
- last_active=float(data.get("last_active", 0.0)),
1338
- last_action=str(data.get("last_action", "—")),
1339
- source="hook",
1340
- tmux_pane=str(data.get("tmux_pane", "")) or None,
1341
- tty=str(data.get("tty", "")) or None,
1342
- hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None,
1343
- heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))),
1344
- source_path=str(data.get("source_path", "")),
1345
- todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None,
1346
- provider=provider,
1347
- waiting_kind=str(data.get("waiting_kind", "")),
1348
- waiting_detail=str(data.get("waiting_detail", "")),
1349
- origin_cwd=str(data.get("origin_cwd", "")),
1350
- )
1366
+ row = Session(
1367
+ session_id=str(data["session_id"]),
1368
+ cwd=str(data.get("cwd", "")),
1369
+ status=Status(data.get("status", "idle")),
1370
+ last_active=float(data.get("last_active", 0.0)),
1371
+ last_action=str(data.get("last_action", "—")),
1372
+ source="hook",
1373
+ tmux_pane=str(data.get("tmux_pane", "")) or None,
1374
+ tty=str(data.get("tty", "")) or None,
1375
+ hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None,
1376
+ heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))),
1377
+ source_path=str(data.get("source_path", "")),
1378
+ todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None,
1379
+ provider=provider,
1380
+ waiting_kind=str(data.get("waiting_kind", "")),
1381
+ waiting_detail=str(data.get("waiting_detail", "")),
1382
+ origin_cwd=str(data.get("origin_cwd", "")),
1351
1383
  )
1384
+ if _promote_codex_permission_wait(
1385
+ _canonical_provider(provider),
1386
+ row.status,
1387
+ str(data.get("last_event", "")),
1388
+ time.time() - row.last_active,
1389
+ CODEX_PERMISSION_WAIT_SECONDS,
1390
+ ):
1391
+ row.status = Status.WAITING
1392
+ row.waiting_kind = "permission"
1393
+ pending_detail = data.get("pending_permission_detail")
1394
+ if isinstance(pending_detail, str) and pending_detail:
1395
+ # hook 在裸 PermissionRequest 當下暫存的指令摘要(120s TTL 只管
1396
+ # 「下一個事件來時還新不新鮮」;這裡 hook 靜默 = 同一筆請求還掛著,
1397
+ # 摘要必然還是它,直接沿用)。
1398
+ row.waiting_detail = pending_detail
1399
+ out.append(row)
1352
1400
  except (KeyError, ValueError):
1353
1401
  continue
1354
1402
  for s in out:
@@ -131,6 +131,26 @@ def _latest_action(records: list[dict[str, Any]]) -> str:
131
131
  return "—"
132
132
 
133
133
 
134
+ def _last_assistant_text(records: list[dict[str, Any]]) -> str:
135
+ """從新到舊找最後一筆帶文字的 assistant 記錄,回傳其 text block 合併後的完整文字。
136
+
137
+ 跳過沒有 text block 的 assistant 記錄(例如純 tool_use);找不到回空字串。
138
+ 給 Stop 事件「回合結尾純文字提問」偵測用——跟 ``_latest_action`` 不同,這裡要完整
139
+ 文字(不截斷、不退回 tool_use 摘要),才能判斷訊息結尾是不是問句。
140
+ """
141
+ for record in reversed(records):
142
+ if record.get("type") != "assistant":
143
+ continue
144
+ texts = [
145
+ str(b.get("text", ""))
146
+ for b in _blocks(record)
147
+ if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
148
+ ]
149
+ if texts:
150
+ return "\n".join(texts)
151
+ return ""
152
+
153
+
134
154
  def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None:
135
155
  """從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。"""
136
156
  for record in reversed(records):
@@ -289,11 +289,29 @@ class RingApp(App[None]):
289
289
  return labeled_project(s.project, get_label(s.session_id))
290
290
 
291
291
  def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None:
292
- """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。"""
293
- if alerts:
294
- self.bell()
295
- names = ", ".join(sorted(self._display_name(s) for s in alerts))
296
- self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
292
+ """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。
293
+
294
+ 系統通知原則上由 ``ring hook`` 在轉 🔴 的事件當下發(這裡不重複發,只響
295
+ in-app 鈴)。唯一的例外是 codex 的核可等待:它是讀取側的靜默逾時判定
296
+ (registry._promote_codex_permission_wait),沒有對應 hook 事件可發通知,
297
+ 所以由這裡代發系統通知——初次與重複提醒都走 WaitingAlertScheduler 同一條路。
298
+ codex + waiting_kind="permission" 只可能來自逾時判定(hook 對 codex 裸
299
+ PermissionRequest 一律記 working;AskUserQuestion 形狀的是 "question"),
300
+ 不會跟 hook 端的通知重複。失敗安靜吞,不影響看板。
301
+ """
302
+ if not alerts:
303
+ return
304
+ self.bell()
305
+ names = ", ".join(sorted(self._display_name(s) for s in alerts))
306
+ self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
307
+ promoted = [s for s in alerts if s.provider == "codex" and s.waiting_kind == "permission"]
308
+ if promoted:
309
+ try:
310
+ from ring.notify import notify_waiting
311
+
312
+ notify_waiting(promoted)
313
+ except Exception:
314
+ pass
297
315
 
298
316
  def _activate_own_window(self) -> None:
299
317
  """把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes