ring-cli 0.8.1__tar.gz → 0.9.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.9.0}/PKG-INFO +1 -9
  2. {ring_cli-0.8.1 → ring_cli-0.9.0}/README.en.md +0 -8
  3. {ring_cli-0.8.1 → ring_cli-0.9.0}/pyproject.toml +1 -1
  4. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/cli.py +2 -23
  5. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/config.py +0 -4
  6. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  7. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -9
  8. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/locale/ring.pot +69 -84
  9. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/__init__.py +4 -17
  10. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/base.py +1 -12
  11. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/registry.py +34 -8
  12. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/sources/__init__.py +8 -0
  13. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/tui.py +1 -15
  14. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/watcher.py +0 -30
  15. {ring_cli-0.8.1 → ring_cli-0.9.0}/LICENSE +0 -0
  16. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/__init__.py +0 -0
  17. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/__main__.py +0 -0
  18. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/__init__.py +0 -0
  19. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/_args.py +0 -0
  20. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/completion.py +0 -0
  21. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/digest.py +0 -0
  22. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/doctor.py +0 -0
  23. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/focus.py +0 -0
  24. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/gc.py +0 -0
  25. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/hook.py +0 -0
  26. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/commands/stats.py +0 -0
  27. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/__init__.py +0 -0
  28. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/applescript.py +0 -0
  29. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/base.py +0 -0
  30. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/iterm2.py +0 -0
  31. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/linux_wm.py +0 -0
  32. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/neovim.py +0 -0
  33. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/terminal.py +0 -0
  34. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/focus/tmux.py +0 -0
  35. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/gc.py +0 -0
  36. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/hook.py +0 -0
  37. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/hook_protocol.py +0 -0
  38. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/i18n.py +0 -0
  39. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/ipc.py +0 -0
  40. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/labels.py +0 -0
  41. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/command.py +0 -0
  42. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/notify_send.py +0 -0
  43. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/ntfy.py +0 -0
  44. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/osascript_notifier.py +0 -0
  45. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/terminal_notifier.py +0 -0
  46. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/notify/webhook.py +0 -0
  47. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/osascript.py +0 -0
  48. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/plugins.py +0 -0
  49. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/sources/base.py +0 -0
  50. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/sources/claude_code.py +0 -0
  51. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/sources/codex.py +0 -0
  52. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/sources/hook_registry.py +0 -0
  53. {ring_cli-0.8.1 → ring_cli-0.9.0}/src/ring/stats.py +0 -0
  54. {ring_cli-0.8.1 → ring_cli-0.9.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.9.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
@@ -373,7 +366,6 @@ legend = true
373
366
  active_window_seconds = 21600
374
367
  working_threshold_seconds = 90
375
368
  waiting_window_seconds = 1800
376
- idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
377
369
  notify_sound = true
378
370
  notify_sound_name = "Glass"
379
371
  notify_ignore_dnd = false
@@ -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
@@ -348,7 +341,6 @@ legend = true
348
341
  active_window_seconds = 21600
349
342
  working_threshold_seconds = 90
350
343
  waiting_window_seconds = 1800
351
- idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
352
344
  notify_sound = true
353
345
  notify_sound_name = "Glass"
354
346
  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.8.1"
5
+ version = "0.9.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)
@@ -59,7 +58,6 @@ class Config:
59
58
  active_window_seconds: int = 6 * 60 * 60
60
59
  working_threshold_seconds: int = 90
61
60
  waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
62
- idle_threshold_seconds: int = 2 * 60 * 60 # 🟡 閒置多久後提醒一次;0 = 關閉
63
61
  notify_sound: bool = True
64
62
  notify_sound_name: str = "Glass"
65
63
  notify_ignore_dnd: bool = False
@@ -133,7 +131,6 @@ def load(path: Path | None = None) -> Config:
133
131
  active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
134
132
  working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
135
133
  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
134
  notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
138
135
  notify_sound_name=(
139
136
  raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
@@ -205,7 +202,6 @@ _SETTERS: dict[str, Callable[[str], object]] = {
205
202
  "active_window_seconds": _coerce_int,
206
203
  "working_threshold_seconds": _coerce_int,
207
204
  "waiting_window_seconds": _coerce_int,
208
- "idle_threshold_seconds": _coerce_int,
209
205
  "notify_sound": _coerce_bool,
210
206
  "notify_sound_name": str,
211
207
  "notify_ignore_dnd": _coerce_bool,
@@ -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
 
@@ -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-08 21:39+0800\n"
11
+ "POT-Creation-Date: 2026-07-10 15:32+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"
@@ -17,144 +17,144 @@ msgstr ""
17
17
  "Content-Transfer-Encoding: 8bit\n"
18
18
  "Generated-By: Babel 2.18.0\n"
19
19
 
20
- #: src/ring/cli.py:129
20
+ #: src/ring/cli.py:127
21
21
  msgid "等你"
22
22
  msgstr ""
23
23
 
24
- #: src/ring/cli.py:130
24
+ #: src/ring/cli.py:128
25
25
  msgid "工作中"
26
26
  msgstr ""
27
27
 
28
- #: src/ring/cli.py:131
28
+ #: src/ring/cli.py:129
29
29
  msgid "跑完停著"
30
30
  msgstr ""
31
31
 
32
- #: src/ring/cli.py:132
32
+ #: src/ring/cli.py:130
33
33
  msgid "已離場"
34
34
  msgstr ""
35
35
 
36
- #: src/ring/cli.py:158
36
+ #: src/ring/cli.py:156
37
37
  #, python-brace-format
38
38
  msgid "{n} 個 session 在場"
39
39
  msgid_plural "{n} 個 session 在場"
40
40
  msgstr[0] ""
41
41
  msgstr[1] ""
42
42
 
43
- #: src/ring/cli.py:159
43
+ #: src/ring/cli.py:157
44
44
  #, python-brace-format
45
45
  msgid "{n} 個 agent process 跑著"
46
46
  msgid_plural "{n} 個 agent process 跑著"
47
47
  msgstr[0] ""
48
48
  msgstr[1] ""
49
49
 
50
- #: src/ring/cli.py:160
50
+ #: src/ring/cli.py:158
51
51
  #, python-brace-format
52
52
  msgid "🎤 RiNG — {sess} · {proc}"
53
53
  msgstr ""
54
54
 
55
- #: src/ring/cli.py:165 src/ring/cli.py:218 src/ring/tui.py:214
55
+ #: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:213
56
56
  msgid "圖例"
57
57
  msgstr ""
58
58
 
59
- #: src/ring/cli.py:182 src/ring/cli.py:220
59
+ #: src/ring/cli.py:180 src/ring/cli.py:218
60
60
  msgid "(場館目前沒人上台)"
61
61
  msgstr ""
62
62
 
63
- #: src/ring/cli.py:186 src/ring/cli.py:340 src/ring/commands/doctor.py:31
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:169
66
+ #: src/ring/tui.py:168
67
67
  msgid "狀態"
68
68
  msgstr ""
69
69
 
70
- #: src/ring/cli.py:188 src/ring/cli.py:237 src/ring/tui.py:171
70
+ #: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:170
71
71
  msgid "工具"
72
72
  msgstr ""
73
73
 
74
- #: src/ring/cli.py:189 src/ring/cli.py:237 src/ring/commands/stats.py:60
75
- #: src/ring/tui.py:172
74
+ #: src/ring/cli.py:187 src/ring/cli.py:235 src/ring/commands/stats.py:60
75
+ #: src/ring/tui.py:171
76
76
  msgid "專案"
77
77
  msgstr ""
78
78
 
79
- #: src/ring/cli.py:190 src/ring/cli.py:237 src/ring/tui.py:172
79
+ #: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:171
80
80
  msgid "進度"
81
81
  msgstr ""
82
82
 
83
- #: src/ring/cli.py:191 src/ring/cli.py:237 src/ring/tui.py:172
83
+ #: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:171
84
84
  msgid "閒置"
85
85
  msgstr ""
86
86
 
87
- #: src/ring/cli.py:192 src/ring/cli.py:237 src/ring/tui.py:172
87
+ #: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:171
88
88
  msgid "去哪"
89
89
  msgstr ""
90
90
 
91
- #: src/ring/cli.py:194 src/ring/cli.py:237 src/ring/tui.py:172
91
+ #: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:171
92
92
  msgid "動作"
93
93
  msgstr ""
94
94
 
95
- #: src/ring/cli.py:322
95
+ #: src/ring/cli.py:320
96
96
  msgid "(內建預設)"
97
97
  msgstr ""
98
98
 
99
- #: src/ring/cli.py:338
99
+ #: src/ring/cli.py:336
100
100
  msgid "RiNG 設定檔"
101
101
  msgstr ""
102
102
 
103
- #: src/ring/cli.py:339 src/ring/commands/doctor.py:133
103
+ #: src/ring/cli.py:337 src/ring/commands/doctor.py:133
104
104
  msgid "路徑"
105
105
  msgstr ""
106
106
 
107
- #: src/ring/cli.py:340 src/ring/commands/doctor.py:134
107
+ #: src/ring/cli.py:338 src/ring/commands/doctor.py:134
108
108
  msgid "已存在"
109
109
  msgstr ""
110
110
 
111
- #: src/ring/cli.py:340 src/ring/commands/doctor.py:134
111
+ #: src/ring/cli.py:338 src/ring/commands/doctor.py:134
112
112
  msgid "不存在(全部用內建預設)"
113
113
  msgstr ""
114
114
 
115
- #: src/ring/cli.py:342
115
+ #: src/ring/cli.py:340
116
116
  msgid "目前生效的設定(← = 你覆寫過的)"
117
117
  msgstr ""
118
118
 
119
- #: src/ring/cli.py:350
119
+ #: src/ring/cli.py:348
120
120
  msgid ""
121
121
  "用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 "
122
122
  "docstring。"
123
123
  msgstr ""
124
124
 
125
- #: src/ring/cli.py:361 src/ring/cli.py:364
125
+ #: src/ring/cli.py:359 src/ring/cli.py:362
126
126
  #, python-brace-format
127
127
  msgid "未知的鍵:{key}"
128
128
  msgstr ""
129
129
 
130
- #: src/ring/cli.py:377
130
+ #: src/ring/cli.py:375
131
131
  msgid "用法:ring config get KEY"
132
132
  msgstr ""
133
133
 
134
- #: src/ring/cli.py:388
134
+ #: src/ring/cli.py:386
135
135
  msgid "用法:ring config set KEY VALUE"
136
136
  msgstr ""
137
137
 
138
- #: src/ring/cli.py:396
138
+ #: src/ring/cli.py:394
139
139
  #, python-brace-format
140
140
  msgid "✅ 已設定 {key} = {value}({path})"
141
141
  msgstr ""
142
142
 
143
- #: src/ring/cli.py:397
143
+ #: src/ring/cli.py:395
144
144
  msgid "註:set 會重寫整個設定檔,原有註解不會保留。"
145
145
  msgstr ""
146
146
 
147
- #: src/ring/cli.py:400
147
+ #: src/ring/cli.py:398
148
148
  #, python-brace-format
149
149
  msgid "未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)"
150
150
  msgstr ""
151
151
 
152
- #: src/ring/cli.py:409
152
+ #: src/ring/cli.py:406
153
153
  #, python-brace-format
154
154
  msgid "每 {interval}s 刷新 · Ctrl-C 離場"
155
155
  msgstr ""
156
156
 
157
- #: src/ring/cli.py:470
157
+ #: src/ring/cli.py:449
158
158
  msgid ""
159
159
  "\n"
160
160
  "commands:\n"
@@ -174,28 +174,28 @@ msgid ""
174
174
  " completion SHELL 印出 shell 補全腳本(zsh / bash)\n"
175
175
  msgstr ""
176
176
 
177
- #: src/ring/cli.py:492
177
+ #: src/ring/cli.py:471
178
178
  msgid ""
179
179
  "usage: ring hook [PROVIDER] [--provider PROVIDER]\n"
180
180
  "\n"
181
181
  "從 stdin 讀 hook JSON,依 provider 正規化後寫入 RiNG registry。\n"
182
182
  msgstr ""
183
183
 
184
- #: src/ring/cli.py:498
184
+ #: src/ring/cli.py:477
185
185
  msgid ""
186
186
  "usage: ring install-hooks [--dry-run]\n"
187
187
  "\n"
188
188
  "安裝 Claude Code / Codex hooks。\n"
189
189
  msgstr ""
190
190
 
191
- #: src/ring/cli.py:504
191
+ #: src/ring/cli.py:483
192
192
  msgid ""
193
193
  "usage: ring remove-hooks [--dry-run]\n"
194
194
  "\n"
195
195
  "從 Claude Code / Codex hook 設定移除 RiNG 安裝的 hooks。\n"
196
196
  msgstr ""
197
197
 
198
- #: src/ring/cli.py:510
198
+ #: src/ring/cli.py:489
199
199
  msgid ""
200
200
  "usage: ring config [get KEY | set KEY VALUE]\n"
201
201
  "\n"
@@ -204,14 +204,14 @@ msgid ""
204
204
  " set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。\n"
205
205
  msgstr ""
206
206
 
207
- #: src/ring/cli.py:518
207
+ #: src/ring/cli.py:497
208
208
  msgid ""
209
209
  "usage: ring focus SESSION_ID\n"
210
210
  "\n"
211
211
  "聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。\n"
212
212
  msgstr ""
213
213
 
214
- #: src/ring/cli.py:524
214
+ #: src/ring/cli.py:503
215
215
  msgid ""
216
216
  "usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]\n"
217
217
  "\n"
@@ -223,7 +223,7 @@ msgid ""
223
223
  " --all-ended 清理所有目前判定已離場的 registry\n"
224
224
  msgstr ""
225
225
 
226
- #: src/ring/cli.py:535
226
+ #: src/ring/cli.py:514
227
227
  msgid ""
228
228
  "usage: ring doctor\n"
229
229
  "\n"
@@ -231,7 +231,7 @@ msgid ""
231
231
  "不寫任何檔案、不安裝、不發通知;固定回傳 0。\n"
232
232
  msgstr ""
233
233
 
234
- #: src/ring/cli.py:542
234
+ #: src/ring/cli.py:521
235
235
  msgid ""
236
236
  "usage: ring digest [--since DURATION] [--format text|json]\n"
237
237
  "\n"
@@ -242,7 +242,7 @@ msgid ""
242
242
  " --format text|json 輸出格式\n"
243
243
  msgstr ""
244
244
 
245
- #: src/ring/cli.py:552
245
+ #: src/ring/cli.py:531
246
246
  msgid ""
247
247
  "usage: ring stats [--since DURATION]\n"
248
248
  "\n"
@@ -253,46 +253,46 @@ msgid ""
253
253
  " --since DURATION 統計時間窗(例如 12h、7d、30d;預設 7d)\n"
254
254
  msgstr ""
255
255
 
256
- #: src/ring/cli.py:562
256
+ #: src/ring/cli.py:541
257
257
  msgid ""
258
258
  "usage: ring completion zsh|bash\n"
259
259
  "\n"
260
260
  "印出 shell 補全腳本。zsh 放 ~/.zshrc:eval \"$(ring completion zsh)\";bash 同理。\n"
261
261
  msgstr ""
262
262
 
263
- #: src/ring/cli.py:620
263
+ #: src/ring/cli.py:599
264
264
  msgid "看所有 agent CLI session 上台。"
265
265
  msgstr ""
266
266
 
267
- #: src/ring/cli.py:625
267
+ #: src/ring/cli.py:604
268
268
  msgid "持續刷新"
269
269
  msgstr ""
270
270
 
271
- #: src/ring/cli.py:626
271
+ #: src/ring/cli.py:605
272
272
  msgid "watch 刷新秒數"
273
273
  msgstr ""
274
274
 
275
- #: src/ring/cli.py:627
275
+ #: src/ring/cli.py:606
276
276
  msgid "watch 刷新幾格後自動結束(0=無限,預設)"
277
277
  msgstr ""
278
278
 
279
- #: src/ring/cli.py:628
279
+ #: src/ring/cli.py:607
280
280
  msgid "連已離場的 session 也顯示"
281
281
  msgstr ""
282
282
 
283
- #: src/ring/cli.py:633
283
+ #: src/ring/cli.py:612
284
284
  msgid "顯示顏色圖例(--no-legend 關閉)"
285
285
  msgstr ""
286
286
 
287
- #: src/ring/cli.py:635
287
+ #: src/ring/cli.py:614
288
288
  msgid "語言(如 en / zh-Hant;也吃 config / RING_LANG / LANG)"
289
289
  msgstr ""
290
290
 
291
- #: src/ring/cli.py:640
291
+ #: src/ring/cli.py:619
292
292
  msgid "輸出格式:table(預設)/ json(機器可讀)/ oneline(status bar 單行摘要)"
293
293
  msgstr ""
294
294
 
295
- #: src/ring/cli.py:646
295
+ #: src/ring/cli.py:625
296
296
  #, python-brace-format
297
297
  msgid "--format {fmt} 只能用在快照模式,不能配 --watch。"
298
298
  msgstr ""
@@ -386,71 +386,66 @@ msgstr ""
386
386
  msgid "跳過去"
387
387
  msgstr ""
388
388
 
389
- #: src/ring/tui.py:190
389
+ #: src/ring/tui.py:189
390
390
  msgid "💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks"
391
391
  msgstr ""
392
392
 
393
- #: src/ring/tui.py:192
393
+ #: src/ring/tui.py:191
394
394
  msgid "↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端"
395
395
  msgstr ""
396
396
 
397
- #: src/ring/tui.py:228
397
+ #: src/ring/tui.py:227
398
398
  #, python-brace-format
399
399
  msgid "🔔 {names} 在等你回話"
400
400
  msgstr ""
401
401
 
402
- #: src/ring/tui.py:240
403
- #, python-brace-format
404
- msgid "🟡 {names} 已閒置"
405
- msgstr ""
406
-
407
- #: src/ring/tui.py:281
402
+ #: src/ring/tui.py:268
408
403
  #, python-brace-format
409
404
  msgid "→ 已跳到 {project}(來自通知)"
410
405
  msgstr ""
411
406
 
412
- #: src/ring/tui.py:286
407
+ #: src/ring/tui.py:273
413
408
  msgid "那個 session 已不在場"
414
409
  msgstr ""
415
410
 
416
- #: src/ring/tui.py:346
411
+ #: src/ring/tui.py:332
417
412
  msgid " ⚠ hook 可能失效:來源檔有更新但沒有 heartbeat"
418
413
  msgstr ""
419
414
 
420
- #: src/ring/tui.py:375 src/ring/tui.py:399 src/ring/tui.py:425
415
+ #: src/ring/tui.py:361 src/ring/tui.py:385 src/ring/tui.py:411
421
416
  msgid "(沒有選到 session)"
422
417
  msgstr ""
423
418
 
424
- #: src/ring/tui.py:406
419
+ #: src/ring/tui.py:392
425
420
  #, python-brace-format
426
421
  msgid "→ {project}({where})"
427
422
  msgstr ""
428
423
 
429
- #: src/ring/tui.py:408
424
+ #: src/ring/tui.py:394
430
425
  #, python-brace-format
431
426
  msgid "{project}:{msg}"
432
427
  msgstr ""
433
428
 
434
- #: src/ring/tui.py:416
429
+ #: src/ring/tui.py:402
435
430
  msgid "(沒有正在等待的 session)"
436
431
  msgstr ""
437
432
 
438
- #: src/ring/tui.py:433
433
+ #: src/ring/tui.py:419
439
434
  #, python-brace-format
440
435
  msgid "再按一次 d 隱藏 {project}(有新活動會自動重新出現)"
441
436
  msgstr ""
442
437
 
443
- #: src/ring/tui.py:438
438
+ #: src/ring/tui.py:424
444
439
  #, python-brace-format
445
440
  msgid "{project} 是仍在執行的 process,無法隱藏;請跳過去結束它"
446
441
  msgstr ""
447
442
 
448
- #: src/ring/tui.py:446
443
+ #: src/ring/tui.py:432
449
444
  #, python-brace-format
450
445
  msgid "已隱藏 {project},並清掉 RiNG 狀態;有新活動會自動重新出現"
451
446
  msgstr ""
452
447
 
453
- #: src/ring/tui.py:448
448
+ #: src/ring/tui.py:434
454
449
  #, python-brace-format
455
450
  msgid "已隱藏 {project}(沒有 RiNG registry 可清;有新活動會自動重新出現)"
456
451
  msgstr ""
@@ -790,21 +785,11 @@ msgstr ""
790
785
  msgid "沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)"
791
786
  msgstr ""
792
787
 
793
- #: src/ring/notify/__init__.py:152
788
+ #: src/ring/notify/__init__.py:139
794
789
  msgid "💡 裝 terminal-notifier 可點擊通知直接跳回 session:brew install terminal-notifier"
795
790
  msgstr ""
796
791
 
797
- #: src/ring/notify/base.py:42
798
- #, python-brace-format
799
- msgid "RiNG · {project} 已閒置"
800
- msgstr ""
801
-
802
- #: src/ring/notify/base.py:43
792
+ #: src/ring/notify/base.py:41
803
793
  #, python-brace-format
804
794
  msgid "RiNG · {project} 在等你回話"
805
795
  msgstr ""
806
-
807
- #: src/ring/notify/base.py:53
808
- #, python-brace-format
809
- msgid "已閒置 {duration}"
810
- msgstr ""
@@ -81,17 +81,14 @@ def _select_notifier(backend: str) -> Notifier | None:
81
81
  return clickable[0] if clickable else available[0]
82
82
 
83
83
 
84
- def _dispatch(sessions: list[Session]) -> str | None:
85
- """共用發送流程:選後端、送、加發 notify_also,必要時回傳一次性安裝引導。
86
-
87
- 每則通知的標題/內文會依 ``session.status`` 分流(見 ``notify/base.py``),
88
- 所以 waiting 與 idle 共用這一條路徑,只差呼叫端傳進來的 session 狀態。
84
+ def notify_waiting(sessions: list[Session]) -> str | None:
85
+ """對一批「新轉為等你」的 session 發系統通知。
89
86
 
90
87
  依 config ``notify_backend`` 選一個 notifier 後端發送(見 ``_select_notifier``)。
91
88
  auto 模式下若只選到不支援點擊的後端、且在 macOS 上,回傳一次性的安裝引導字串
92
89
  (建議裝 terminal-notifier 取得點擊跳轉);其餘情況回 ``None``。失敗一律安靜吞掉。
93
90
 
94
- :param sessions: 要發通知的 session 清單;空清單時直接回傳。
91
+ :param sessions: 新轉為 waiting 的 session 清單;空清單時直接回傳。
95
92
  :returns: 安裝引導提示字串或 ``None``。
96
93
  """
97
94
  if not sessions:
@@ -113,16 +110,6 @@ def _dispatch(sessions: list[Session]) -> str | None:
113
110
  return None
114
111
 
115
112
 
116
- def notify_waiting(sessions: list[Session]) -> str | None:
117
- """對一批「新轉為等你」的 session 發系統通知。見 ``_dispatch`` 的回傳語義。"""
118
- return _dispatch(sessions)
119
-
120
-
121
- def notify_idle(sessions: list[Session]) -> str | None:
122
- """對一批「首次超過閒置門檻」的 session 發系統通知。見 ``_dispatch`` 的回傳語義。"""
123
- return _dispatch(sessions)
124
-
125
-
126
113
  def _send_also(sessions: list[Session], also: tuple[str, ...], primary: Notifier | None) -> None:
127
114
  """notify_also 的加發:主後端之外,再對指定名稱的後端各發一份。
128
115
 
@@ -157,4 +144,4 @@ def _maybe_show_install_hint() -> str | None:
157
144
  return None
158
145
 
159
146
 
160
- __all__ = ["Notifier", "notifiers", "notify_idle", "notify_waiting", "register_notifier"]
147
+ __all__ = ["Notifier", "notifiers", "notify_waiting", "register_notifier"]
@@ -10,7 +10,7 @@ from typing import Protocol
10
10
 
11
11
  from ring.i18n import gettext as _
12
12
  from ring.labels import get_label
13
- from ring.registry import Session, Status
13
+ from ring.registry import Session
14
14
 
15
15
 
16
16
  class Notifier(Protocol):
@@ -38,8 +38,6 @@ def display_name(session: Session) -> str:
38
38
 
39
39
  def notify_title(session: Session) -> str:
40
40
  """通知標題——「哪個 session 在等你」,全後端共用一句。"""
41
- if session.status is Status.IDLE:
42
- return _("RiNG · {project} 已閒置", project=display_name(session))
43
41
  return _("RiNG · {project} 在等你回話", project=display_name(session))
44
42
 
45
43
 
@@ -49,13 +47,4 @@ def notify_message(session: Session) -> str:
49
47
  標題已經說了「誰在等你」,內文就補「等什麼、去哪」,讓你看一眼就能決定要不要現在回去。
50
48
  """
51
49
  location = f"📍 {session.location}"
52
- if session.status is Status.IDLE:
53
- return _("已閒置 {duration}", duration=_idle_duration(session.idle_for)) + f"\n{location}"
54
50
  return f"{session.waiting_detail}\n{location}" if session.waiting_detail else location
55
-
56
-
57
- def _idle_duration(seconds: float) -> str:
58
- s = int(max(0, seconds))
59
- if s < 3600:
60
- return f"{s // 60}m"
61
- return f"{s // 3600}h{(s % 3600) // 60:02d}m"
@@ -855,10 +855,24 @@ def _scan_sessions(procs: list[tuple[str, str]]) -> list[Session]:
855
855
  out: list[Session] = []
856
856
  for _cwd, group in by_cwd.items():
857
857
  group.sort(key=lambda s: s.last_active, reverse=True)
858
+ skey = _real(group[0].cwd) # 同一 group 內 cwd 皆相同(by_cwd 就是照 s.cwd 分組)
859
+ live_n = counts.get(skey, 0)
860
+ ordered = group
861
+ if 0 < live_n < len(group):
862
+ # 曖昧情境:同 cwd 的 transcript 數多於 live claude 數,純 mtime 排名不可靠
863
+ # ——已崩潰的 session 若剛好在真正還活著、但已安靜一段時間的 session 之後
864
+ # 才寫入最後一筆,mtime 反而「更新」,會把真正活著的那個擠出 live_n 名額
865
+ # (見 session-detection-review.md 症狀 1)。若能從 tmux pane 子孫 process
866
+ # 的 args 找到明確提到 session id 的強訊號(比照 _tmux_process_tree_targets
867
+ # 用在 tmux_target 配對的同一套邏輯),優先信任它決定誰佔 live 名額;沒有這
868
+ # 種訊號(多數非 tmux/非 --resume 情境)就 fallback 回既有 mtime 排名。
869
+ confirmed = _tmux_process_tree_targets(group)
870
+ if confirmed:
871
+ front = [s for s in group if s.session_id in confirmed]
872
+ back = [s for s in group if s.session_id not in confirmed]
873
+ ordered = front + back
858
874
  # 同一 cwd 組內,各 session 依自己的當下 cwd 查 live 名額與 tty
859
- for i, s in enumerate(group):
860
- skey = _real(s.cwd)
861
- live_n = counts.get(skey, 0)
875
+ for i, s in enumerate(ordered):
862
876
  # 當下 cwd 只有一個 claude 時,把它的 tty 給那個活著的 session(終端跳轉用);
863
877
  # 多個 claude 同 cwd 無法精準對應,留給 hook 模式處理。
864
878
  uniq_tty = cwd_ttys[skey][0] if live_n == 1 and cwd_ttys.get(skey) else ""
@@ -966,10 +980,13 @@ def _hook_sessions(
966
980
  s.hook_stale = _hook_heartbeat_stale(s.source_path, s.heartbeat_at, s.status)
967
981
  # SessionEnd 沒觸發(crash)會留下幽靈檔。判定離場:
968
982
  # 1. 該 cwd 完全沒有 live proc → 一定離場。
969
- # 2. 該 cwd 的 hook row 數「多於」live proc 數(真的有多餘列要修剪)時,才用 tty
970
- # 挑出 tty 對不上的那幾筆標離場。row <= proc 數時每筆都可能還活著,不靠 tty 殺
971
- # ——因為 hook 寫進來的 tty 不一定可靠(終端 tty 會被作業系統重配,甚至跨 session
972
- # 錯置),拿它隱藏唯一活著的 session 會讓整列憑空消失。
983
+ # 2. 該 cwd 的 hook row 數「多於一筆」時,用 tty 挑出 tty 對不上的那幾筆標離場——
984
+ # 不論這筆數是否 <= live proc 數:計數只是巧合對上(例如同 cwd 剛好有跟 RiNG
985
+ # hook 無關的 live process 佔掉名額),不代表每筆 row 都真的還活著;row 數 > 1
986
+ # tty 交叉比對才有意義去挑出誰是 stale 的。
987
+ # 3. 該 cwd 只有「單一」hook row 時,無論 tty 是否對得上都不靠 tty 殺——hook 寫進來
988
+ # 的 tty 不一定可靠(終端 tty 會被作業系統重配,甚至跨 session 錯置),拿它隱藏
989
+ # 唯一活著的 session 會讓整列憑空消失。
973
990
  if out:
974
991
  proc_counts: dict[tuple[str, str], int] = {}
975
992
  proc_ttys: dict[tuple[str, str], set[str]] = {}
@@ -994,7 +1011,10 @@ def _hook_sessions(
994
1011
  for s in rows:
995
1012
  s.status = Status.ENDED
996
1013
  continue
997
- if len(rows) <= live_n:
1014
+
1015
+ # 單筆 row:不靠 tty 殺,理由見上方註解 3——避免唯一活著的 session 因 tty
1016
+ # 重配而憑空消失(test_hook_sessions_keeps_lone_live_session_with_wrong_tty)。
1017
+ if len(rows) == 1:
998
1018
  continue
999
1019
 
1000
1020
  live_ttys = proc_ttys.get(key, set())
@@ -1003,6 +1023,12 @@ def _hook_sessions(
1003
1023
  if s.tty and s.tty not in live_ttys:
1004
1024
  s.status = Status.ENDED
1005
1025
 
1026
+ if len(rows) <= live_n:
1027
+ # 沒有多餘列要修剪,但上面的 tty 交叉比對仍然有效——即使計數巧合對上,
1028
+ # tty 對不上的那幾筆(例如已 crash 的舊 row)還是會被標離場,不會永遠
1029
+ # 靠計數巧合躲過清理。
1030
+ continue
1031
+
1006
1032
  remaining = [s for s in rows if s.status is not Status.ENDED]
1007
1033
  if len(remaining) > live_n:
1008
1034
  remaining.sort(key=lambda s: s.last_active, reverse=True)
@@ -91,6 +91,13 @@ def _merge_duplicate_session(current: Session, candidate: Session) -> Session:
91
91
  action-required 狀態之後未必會送出能清掉 waiting 的 hook;如果 zero-config scan
92
92
  已看到同一 session 有更新紀錄且不再是 WAITING,就用 scan 清掉 stale waiting,
93
93
  同時保留 hook 拿到的 tty,避免跳轉能力退化。
94
+
95
+ 只看 last_active 先後還不夠:Claude Code 在送出需要權限的 tool_use 之後,會繼續
96
+ 往同一份 transcript 追加幾筆非對話簿記紀錄(last-prompt / ai-title / mode /
97
+ permission-mode 等),這些紀錄一樣會推進檔案 mtime,但不代表使用者真的回應了
98
+ 權限請求。因此還要求 candidate 的 ``_tail_kind`` 不是 ``"interrupted"``——
99
+ 只有真人 prompt("working")或對話真的收尾("waiting")才算「使用者真的回應了」,
100
+ 單純簿記寫入(tail 仍是 interrupted)不該觸發覆蓋。
94
101
  """
95
102
  if (
96
103
  current.source == "hook"
@@ -98,6 +105,7 @@ def _merge_duplicate_session(current: Session, candidate: Session) -> Session:
98
105
  and candidate.provider == current.provider
99
106
  and candidate.status is not Status.WAITING
100
107
  and candidate.last_active > current.last_active
108
+ and candidate._tail_kind != "interrupted"
101
109
  ):
102
110
  if not candidate.tty:
103
111
  candidate.tty = current.tty
@@ -38,7 +38,7 @@ from ring.i18n import set_lang
38
38
  from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence
39
39
  from ring.labels import get_label, load_labels, set_label
40
40
  from ring.registry import Session, Status, delete_session_state, hide_session, running_agent_pids
41
- from ring.watcher import IdleAlertScheduler, WaitingAlertScheduler
41
+ from ring.watcher import WaitingAlertScheduler
42
42
 
43
43
  _ORDER = (Status.WAITING, Status.WORKING, Status.IDLE, Status.ENDED)
44
44
 
@@ -126,7 +126,6 @@ class RingApp(App[None]):
126
126
  self._focused_sid: str | None = None
127
127
  cfg = get_config()
128
128
  self._alerts = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
129
- self._idle_alerts = IdleAlertScheduler(cfg.idle_threshold_seconds)
130
129
  self.title = "RiNG 🎤"
131
130
  # 記下自己的 controlling tty,供 _poll_focus_request activate 視窗用。
132
131
  self._own_tty: str = self._detect_own_tty()
@@ -227,18 +226,6 @@ class RingApp(App[None]):
227
226
  names = ", ".join(sorted(self._display_name(s) for s in alerts))
228
227
  self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
229
228
 
230
- def _ring_on_idle_alerts(self, alerts: list[Session]) -> None:
231
- if not alerts:
232
- return
233
- try:
234
- from ring.notify import notify_idle
235
-
236
- notify_idle(alerts)
237
- except Exception:
238
- pass
239
- names = ", ".join(sorted(self._display_name(s) for s in alerts))
240
- self.notify(_("🟡 {names} 已閒置", names=names), timeout=8)
241
-
242
229
  def _activate_own_window(self) -> None:
243
230
  """把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。
244
231
 
@@ -310,7 +297,6 @@ class RingApp(App[None]):
310
297
  # 這裡只留 TUI 自己的 in-app 響鈴 / 訊息列與醒目標記,不重複發系統通知。
311
298
  alerts = self._alerts.feed(self._sessions)
312
299
  self._ring_on_waiting_alerts(alerts)
313
- self._ring_on_idle_alerts(self._idle_alerts.feed(self._sessions))
314
300
  self.sub_title = _header(len(self._sessions), len(running_agent_pids()))
315
301
  labels = load_labels()
316
302
  table.clear()
@@ -105,33 +105,3 @@ class WaitingAlertScheduler:
105
105
  if state.repeats_sent < len(self._repeat_seconds):
106
106
  return now - state.first_seen >= self._repeat_seconds[state.repeats_sent]
107
107
  return now - state.last_alert >= self._repeat_seconds[-1]
108
-
109
-
110
- class IdleAlertScheduler:
111
- """🟡 閒置提醒:超過門檻只通知一次,離開 idle 後 reset。"""
112
-
113
- def __init__(self, threshold_seconds: int, *, now: Callable[[], float] = time.time) -> None:
114
- self._threshold_seconds = max(0, threshold_seconds)
115
- self._now = now
116
- self._alerted: set[str] = set()
117
- self._primed = False
118
-
119
- def feed(self, sessions: list[Session]) -> list[Session]:
120
- current = {s.session_id: s for s in sessions if s.status is Status.IDLE}
121
- if not self._threshold_seconds:
122
- self._alerted = set(current)
123
- self._primed = True
124
- return []
125
-
126
- due = [
127
- s
128
- for sid, s in current.items()
129
- if sid not in self._alerted and self._now() - s.last_active >= self._threshold_seconds
130
- ]
131
- current_ids = set(current)
132
- if not self._primed:
133
- self._alerted = current_ids
134
- self._primed = True
135
- return []
136
- self._alerted = (self._alerted & current_ids) | {s.session_id for s in due}
137
- return due
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes