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.
- {ring_cli-0.9.0 → ring_cli-0.10.0}/PKG-INFO +10 -3
- {ring_cli-0.9.0 → ring_cli-0.10.0}/README.en.md +9 -2
- {ring_cli-0.9.0 → ring_cli-0.10.0}/pyproject.toml +1 -1
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/config.py +5 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/hook.py +38 -6
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/registry.py +48 -3
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/tui.py +3 -1
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/watcher.py +29 -2
- {ring_cli-0.9.0 → ring_cli-0.10.0}/LICENSE +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/cli.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/gc.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/labels.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.9.0 → ring_cli-0.10.0}/src/ring/stats.py +0 -0
- {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.
|
|
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 `
|
|
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 `
|
|
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.
|
|
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
|
-
|
|
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
|
|
238
|
+
if should_ring:
|
|
225
239
|
_ring_waiting_now(event, payload, last_action)
|
|
226
240
|
|
|
227
241
|
|
|
228
|
-
def
|
|
229
|
-
"""讀 registry
|
|
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
|
|
246
|
+
return data if isinstance(data, dict) else {}
|
|
233
247
|
except Exception:
|
|
234
|
-
return
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|