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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {ring_cli-0.9.0 → ring_cli-0.10.0}/PKG-INFO +10 -3
  2. {ring_cli-0.9.0 → ring_cli-0.10.0}/README.en.md +9 -2
  3. {ring_cli-0.9.0 → ring_cli-0.10.0}/pyproject.toml +1 -1
  4. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/config.py +5 -0
  5. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/hook.py +38 -6
  6. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/registry.py +48 -3
  7. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/tui.py +3 -1
  8. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/watcher.py +29 -2
  9. {ring_cli-0.9.0 → ring_cli-0.10.0}/LICENSE +0 -0
  10. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/__init__.py +0 -0
  11. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/__main__.py +0 -0
  12. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/cli.py +0 -0
  13. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/__init__.py +0 -0
  14. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/_args.py +0 -0
  15. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/completion.py +0 -0
  16. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/digest.py +0 -0
  17. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/doctor.py +0 -0
  18. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/focus.py +0 -0
  19. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/gc.py +0 -0
  20. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/hook.py +0 -0
  21. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/stats.py +0 -0
  22. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/__init__.py +0 -0
  23. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/applescript.py +0 -0
  24. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/base.py +0 -0
  25. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/iterm2.py +0 -0
  26. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/linux_wm.py +0 -0
  27. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/neovim.py +0 -0
  28. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/terminal.py +0 -0
  29. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/tmux.py +0 -0
  30. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/gc.py +0 -0
  31. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/hook_protocol.py +0 -0
  32. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/i18n.py +0 -0
  33. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/ipc.py +0 -0
  34. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/labels.py +0 -0
  35. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  36. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  37. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/ring.pot +0 -0
  38. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/__init__.py +0 -0
  39. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/base.py +0 -0
  40. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/command.py +0 -0
  41. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/notify_send.py +0 -0
  42. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/ntfy.py +0 -0
  43. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/osascript_notifier.py +0 -0
  44. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/terminal_notifier.py +0 -0
  45. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/webhook.py +0 -0
  46. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/osascript.py +0 -0
  47. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/plugins.py +0 -0
  48. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/__init__.py +0 -0
  49. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/base.py +0 -0
  50. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/claude_code.py +0 -0
  51. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/codex.py +0 -0
  52. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/hook_registry.py +0 -0
  53. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/stats.py +0 -0
  54. {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
5
5
  Keywords: claude-code,codex,tui,dashboard,session,monitor,rich
6
6
  Author: Wei Lee
@@ -336,8 +336,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
336
336
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
337
337
 
338
338
  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`.
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).
341
347
 
342
348
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
343
349
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -372,6 +378,7 @@ notify_ignore_dnd = false
372
378
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
373
379
  notify_repeat_seconds = [30, 120, 300]
374
380
  notify_repeat_max = 3
381
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
375
382
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
376
383
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
377
384
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
@@ -311,8 +311,14 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma
311
311
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
312
312
 
313
313
  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`.
314
+ `notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`.
315
+ Custom notification channels can be added by registering another `Notifier`.
316
+
317
+ Background subagent permission requests can make a session flap between waiting and working in
318
+ quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system
319
+ notification and the TUI's bell/reminder from firing again the instant a session re-enters waiting
320
+ within the cooldown window of its last alert; set it to `0` to disable the cooldown (alert on
321
+ every re-entry, the old behavior).
316
322
 
317
323
  `agent-hooks` is an optional external hook helper / decision UI. If it is installed and
318
324
  `notify_backend = "agent-hooks"`, `ring hook` still writes RiNG registry state, then passes the
@@ -347,6 +353,7 @@ notify_ignore_dnd = false
347
353
  notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
348
354
  notify_repeat_seconds = [30, 120, 300]
349
355
  notify_repeat_max = 3
356
+ waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off
350
357
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
351
358
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
352
359
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
@@ -2,7 +2,7 @@
2
2
  # import 的 module(見 [tool.uv.build-backend] module-name)與 CLI 指令仍是 `ring`。
3
3
  [project]
4
4
  name = "ring-cli"
5
- version = "0.9.0"
5
+ version = "0.10.0"
6
6
  description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
7
7
  readme = "README.en.md"
8
8
  requires-python = ">=3.13"
@@ -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:
@@ -545,11 +545,21 @@ def running_claude_pids() -> list[int]:
545
545
 
546
546
 
547
547
  def _is_claude_background_process(args: str) -> bool:
548
- """Claude daemon / bg pty host 不是使用者可聚焦的 CLI session。"""
548
+ """Claude daemon / bg pty host / bg-spare 備用 process 不是使用者可聚焦的 CLI session。
549
+
550
+ ``--bg-spare`` 是 Claude Code 預熱的備用 process(供下一個 ``claude`` 呼叫快速接手),
551
+ 跟 ``--bg-pty-host`` 一樣不代表真正的使用者 session,卻會被 ``_claude_procs`` 合成假
552
+ session 列上看板,冒出幽靈列。token 形狀(`--bg-spare`,`--` flag,不是位置參數)取自
553
+ 本機 claude CLI 2.1.206 二進位的 strings 掃描(無法用 ``ps`` 現場逮到活體 bg-spare
554
+ process,掃描時機是巧合——它壽命短、隨用隨滅):
555
+ ``[a,...l,"--bg-pty-host",r,"200","50","--",a,...l,"--bg-spare",n]`` spawn 呼叫,
556
+ 以及 bg-spare process 自己啟動時對 ``process.argv`` 做的
557
+ ``e.includes("--bg-spare", t+1)`` 檢查,兩處都證實是 ``--`` 前綴的 flag token。
558
+ """
549
559
  tokens = args.split()
550
560
  if len(tokens) >= 3 and tokens[1:3] == ["daemon", "run"]:
551
561
  return True
552
- return "--bg-pty-host" in tokens
562
+ return "--bg-pty-host" in tokens or "--bg-spare" in tokens
553
563
 
554
564
 
555
565
  def running_codex_pids() -> list[int]:
@@ -610,6 +620,30 @@ def _real(path: str) -> str:
610
620
  return path
611
621
 
612
622
 
623
+ def _is_ancestor_dir(ancestor: str, path: str) -> bool:
624
+ """``ancestor`` 是否為 ``path`` 本身或其祖先目錄(兩者皆須已用 ``_real`` 正規化)。
625
+
626
+ 純字串 ``startswith`` 裸比對會誤判 ``/foo`` 命中 ``/foobar``;用尾斜線組出前綴
627
+ (或直接相等)才能正確界定「目錄」邊界。
628
+ """
629
+ if not ancestor or not path:
630
+ return False
631
+ if ancestor == path:
632
+ return True
633
+ prefix = ancestor if ancestor.endswith(os.sep) else ancestor + os.sep
634
+ return path.startswith(prefix)
635
+
636
+
637
+ def _has_ancestor_live_process(row_cwd: str, live_cwds: list[str]) -> bool:
638
+ """``live_cwds`` 裡是否有任一筆是 ``row_cwd`` 本身或其祖先目錄。
639
+
640
+ 用於:使用者在 session 裡 cd 進子目錄後,hook payload 記的 cwd 變成子目錄,但
641
+ claude process 實際 cwd(lsof 量到的)仍停在啟動目錄——子目錄底下量不到 live
642
+ process,不代表 session 已離場,只是 process 沒跟著 cd。
643
+ """
644
+ return any(_is_ancestor_dir(live_cwd, row_cwd) for live_cwd in live_cwds)
645
+
646
+
613
647
  def _pid_tty(pid: int) -> str:
614
648
  """claude process 的控制終端,正規化成 iTerm2 認得的 "/dev/ttysNNN"。"""
615
649
  try:
@@ -990,13 +1024,16 @@ def _hook_sessions(
990
1024
  if out:
991
1025
  proc_counts: dict[tuple[str, str], int] = {}
992
1026
  proc_ttys: dict[tuple[str, str], set[str]] = {}
1027
+ proc_cwds_by_provider: dict[str, list[str]] = {}
993
1028
  for pk in _PROVIDER_PROCS:
994
1029
  provider_procs = procs_by_provider.get(pk, []) if procs_by_provider is not None else (procs or [])
995
1030
  for cwd, tty in provider_procs:
996
- key = (pk, _real(cwd))
1031
+ real_cwd = _real(cwd)
1032
+ key = (pk, real_cwd)
997
1033
  proc_counts[key] = proc_counts.get(key, 0) + 1
998
1034
  if tty:
999
1035
  proc_ttys.setdefault(key, set()).add(tty)
1036
+ proc_cwds_by_provider.setdefault(pk, []).append(real_cwd)
1000
1037
 
1001
1038
  rows_by_key: dict[tuple[str, str], list[Session]] = {}
1002
1039
  for s in out:
@@ -1008,6 +1045,14 @@ def _hook_sessions(
1008
1045
  for key, rows in rows_by_key.items():
1009
1046
  live_n = proc_counts.get(key, 0)
1010
1047
  if live_n == 0:
1048
+ pk, row_cwd = key
1049
+ if _has_ancestor_live_process(row_cwd, proc_cwds_by_provider.get(pk, [])):
1050
+ # hook payload 的 cwd 落在使用者 cd 進去的子目錄,但 claude process 實際
1051
+ # cwd(lsof 量到的)仍停在啟動目錄——兩者都正規化過,子目錄底下自然量不到
1052
+ # live proc。祖先目錄有活 process 時保守判定「還活著」,不殺,避免把正常
1053
+ # cd 進子目錄的 session 誤判 ENDED(見
1054
+ # test_hook_sessions_keeps_live_session_when_cwd_moved_to_subdir)。
1055
+ continue
1011
1056
  for s in rows:
1012
1057
  s.status = Status.ENDED
1013
1058
  continue
@@ -125,7 +125,9 @@ class RingApp(App[None]):
125
125
  # 通知點過來時指向的 session:那一列要持續醒目標記,直到它離開 WAITING(你回應了)或不在場。
126
126
  self._focused_sid: str | None = None
127
127
  cfg = get_config()
128
- self._alerts = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
128
+ self._alerts = WaitingAlertScheduler(
129
+ cfg.notify_repeat_seconds, cfg.notify_repeat_max, cooldown_seconds=cfg.waiting_cooldown_seconds
130
+ )
129
131
  self.title = "RiNG 🎤"
130
132
  # 記下自己的 controlling tty,供 _poll_focus_request activate 視窗用。
131
133
  self._own_tty: str = self._detect_own_tty()
@@ -55,6 +55,11 @@ class WaitingAlertScheduler:
55
55
 
56
56
  第一輪只 prime,不通知既有 waiting;之後新轉 waiting 立即通知,持續 waiting 則依
57
57
  ``repeat_seconds`` 做最多 ``repeat_max`` 次提醒(0 = 不限)。
58
+
59
+ ``cooldown_seconds``:session 離開 WAITING 後若很快又轉回(例如背景 subagent 的權限
60
+ 請求在 working/waiting 間快速翻轉),距上次通知未滿冷卻期就不當「新轉入」立即通知——
61
+ 否則翻轉一次響一次。冷卻期只抑制「重新轉入」這一次的通知,state 仍照常建立,冷卻期滿
62
+ 後的 repeat 邏輯不受影響。0 = 關閉(回到「離開就丟狀態、再轉入必響」的舊行為)。
58
63
  """
59
64
 
60
65
  def __init__(
@@ -63,11 +68,16 @@ class WaitingAlertScheduler:
63
68
  repeat_max: int = 0,
64
69
  *,
65
70
  now: Callable[[], float] = time.time,
71
+ cooldown_seconds: int = 0,
66
72
  ) -> None:
67
73
  self._repeat_seconds = repeat_seconds
68
74
  self._repeat_max = max(0, repeat_max)
69
75
  self._now = now
76
+ self._cooldown_seconds = max(0, cooldown_seconds)
70
77
  self._states: dict[str, _AlertState] = {}
78
+ # sid → 上次通知時間;離開 WAITING 後 self._states 會丟掉該 sid,但這裡保留給
79
+ # 冷卻判斷用。只增不減地跟著 feed() 清理過期條目,避免無界成長。
80
+ self._recently_alerted: dict[str, float] = {}
71
81
  self._primed = False
72
82
 
73
83
  def feed(self, sessions: list[Session]) -> list[Session]:
@@ -76,6 +86,7 @@ class WaitingAlertScheduler:
76
86
 
77
87
  if not self._primed:
78
88
  self._states = {sid: _AlertState(first_seen=now, last_alert=now) for sid in current}
89
+ self._recently_alerted = {sid: now for sid in current}
79
90
  self._primed = True
80
91
  return []
81
92
 
@@ -84,19 +95,35 @@ class WaitingAlertScheduler:
84
95
  for sid, session in current.items():
85
96
  state = self._states.get(sid)
86
97
  if state is None:
87
- next_states[sid] = _AlertState(first_seen=now, last_alert=now)
88
- due.append(session)
98
+ last_alert = self._recently_alerted.get(sid)
99
+ if self._cooldown_seconds > 0 and last_alert is not None and now - last_alert < self._cooldown_seconds:
100
+ # 冷卻中:不通知,但仍建立 state 追蹤,冷卻期滿後 repeat 邏輯照舊接手。
101
+ next_states[sid] = _AlertState(first_seen=now, last_alert=last_alert)
102
+ else:
103
+ next_states[sid] = _AlertState(first_seen=now, last_alert=now)
104
+ self._recently_alerted[sid] = now
105
+ due.append(session)
89
106
  continue
90
107
 
91
108
  if self._should_repeat(state, now):
92
109
  state.last_alert = now
93
110
  state.repeats_sent += 1
94
111
  due.append(session)
112
+ self._recently_alerted[sid] = now
95
113
  next_states[sid] = state
96
114
 
97
115
  self._states = next_states
116
+ self._prune_recently_alerted(now)
98
117
  return due
99
118
 
119
+ def _prune_recently_alerted(self, now: float) -> None:
120
+ """清掉超過冷卻期的 recently-alerted 條目,避免長跑 process 無界成長。"""
121
+ if not self._cooldown_seconds:
122
+ self._recently_alerted.clear()
123
+ return
124
+ cutoff = now - self._cooldown_seconds
125
+ self._recently_alerted = {sid: t for sid, t in self._recently_alerted.items() if t >= cutoff}
126
+
100
127
  def _should_repeat(self, state: _AlertState, now: float) -> bool:
101
128
  if not self._repeat_seconds:
102
129
  return False
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