ring-cli 0.14.2__tar.gz → 0.15.1__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 (66) hide show
  1. {ring_cli-0.14.2 → ring_cli-0.15.1}/PKG-INFO +3 -2
  2. {ring_cli-0.14.2 → ring_cli-0.15.1}/README.en.md +2 -1
  3. {ring_cli-0.14.2 → ring_cli-0.15.1}/pyproject.toml +23 -5
  4. {ring_cli-0.14.2 → ring_cli-0.15.1}/pyproject.toml.orig +40 -6
  5. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/cli.py +2 -3
  6. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/doctor.py +5 -0
  7. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/neovim.py +1 -2
  8. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/hook.py +2 -2
  9. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/hook_protocol.py +1 -4
  10. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/ipc.py +8 -16
  11. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/__init__.py +5 -7
  12. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/notify_send.py +2 -3
  13. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/ntfy.py +2 -3
  14. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/osascript_notifier.py +3 -3
  15. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/terminal_notifier.py +2 -3
  16. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/webhook.py +2 -3
  17. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify_queue.py +5 -9
  18. ring_cli-0.15.1/src/ring/pathutil.py +49 -0
  19. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/permission.py +22 -16
  20. ring_cli-0.15.1/src/ring/ps_parse.py +164 -0
  21. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/registry.py +29 -346
  22. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/__init__.py +8 -8
  23. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/local_llm.py +97 -17
  24. ring_cli-0.15.1/src/ring/subproc.py +9 -0
  25. ring_cli-0.15.1/src/ring/tmux_scan.py +186 -0
  26. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/tui.py +50 -26
  27. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/watcher.py +1 -1
  28. {ring_cli-0.14.2 → ring_cli-0.15.1}/LICENSE +0 -0
  29. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/__init__.py +0 -0
  30. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/__main__.py +0 -0
  31. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/__init__.py +0 -0
  32. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/_args.py +0 -0
  33. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/completion.py +0 -0
  34. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/digest.py +0 -0
  35. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/focus.py +0 -0
  36. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/gc.py +0 -0
  37. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/hook.py +0 -0
  38. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/quiet.py +0 -0
  39. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/stats.py +0 -0
  40. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/config.py +0 -0
  41. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/__init__.py +0 -0
  42. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/applescript.py +0 -0
  43. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/base.py +0 -0
  44. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/iterm2.py +0 -0
  45. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/linux_wm.py +0 -0
  46. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/terminal.py +0 -0
  47. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/tmux.py +0 -0
  48. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/trace.py +0 -0
  49. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/gc.py +0 -0
  50. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/i18n.py +0 -0
  51. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/labels.py +0 -0
  52. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  53. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  54. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/ring.pot +0 -0
  55. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/base.py +0 -0
  56. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/command.py +0 -0
  57. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/osascript.py +0 -0
  58. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/payload_log.py +0 -0
  59. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/plugins.py +0 -0
  60. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/question_detect.py +0 -0
  61. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/base.py +0 -0
  62. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/claude_code.py +0 -0
  63. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/codex.py +0 -0
  64. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/hook_registry.py +0 -0
  65. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/stats.py +0 -0
  66. {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.14.2
3
+ Version: 0.15.1
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
@@ -47,7 +47,8 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
47
47
 
48
48
  ## What It Does
49
49
 
50
- - **One board for session state**: built-in sources for Claude Code, Codex, Ollama, and llama.cpp.
50
+ - **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
51
+ detection for interactive Ollama and llama.cpp CLIs.
51
52
  - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
52
53
  - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
53
54
  - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
@@ -21,7 +21,8 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
21
21
 
22
22
  ## What It Does
23
23
 
24
- - **One board for session state**: built-in sources for Claude Code, Codex, Ollama, and llama.cpp.
24
+ - **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
25
+ detection for interactive Ollama and llama.cpp CLIs.
25
26
  - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
26
27
  - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
27
28
  - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ring-cli"
3
- version = "0.14.2"
3
+ version = "0.15.1"
4
4
  description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
5
5
  readme = "README.en.md"
6
6
  requires-python = ">=3.13"
@@ -106,13 +106,23 @@ select = [
106
106
  "I",
107
107
  "UP",
108
108
  "RUF",
109
+ "B",
110
+ "C4",
111
+ "SIM",
112
+ "PTH",
113
+ "ARG",
114
+ "TRY",
109
115
  ]
110
116
  ignore = [
111
117
  "RUF001",
112
118
  "RUF002",
113
119
  "RUF003",
120
+ "TRY003",
114
121
  ]
115
122
 
123
+ [tool.ruff.lint.per-file-ignores]
124
+ "tests/**" = ["ARG"]
125
+
116
126
  [tool.codespell]
117
127
  skip = ".git*,*.lock"
118
128
  check-hidden = true
@@ -152,16 +162,20 @@ sequence = [
152
162
  "cover",
153
163
  ]
154
164
 
165
+ [tool.poe.tasks.hooks]
166
+ help = "Run every pre-commit hook against all files"
167
+ cmd = "prek run --all-files"
168
+
169
+ [tool.poe.tasks.hooks.env]
170
+ SKIP = "no-commit-to-branch"
171
+
155
172
  [tool.poe.tasks.ci]
156
173
  help = "Run all tasks in CI"
157
174
  sequence = [
158
- { cmd = "prek run --all-files" },
175
+ "hooks",
159
176
  "cover",
160
177
  ]
161
178
 
162
- [tool.poe.tasks.ci.env]
163
- SKIP = "no-commit-to-branch"
164
-
165
179
  [tool.poe.tasks.setup-pre-commit]
166
180
  help = "Install pre-commit hooks"
167
181
  cmd = "prek install"
@@ -204,9 +218,13 @@ addopts = [
204
218
 
205
219
  [tool.coverage.run]
206
220
  source = ["ring"]
221
+ branch = true
207
222
 
208
223
  [tool.coverage.paths]
209
224
  source = [
210
225
  "src/ring",
211
226
  "*/ring",
212
227
  ]
228
+
229
+ [tool.coverage.report]
230
+ fail_under = 85
@@ -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.14.2"
5
+ version = "0.15.1"
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"
@@ -81,9 +81,33 @@ target-version = "py313"
81
81
  line-length = 120
82
82
 
83
83
  [tool.ruff.lint]
84
- select = ["E", "F", "W", "I", "UP", "RUF"]
85
- # 本專案刻意大量使用台灣漢語註解/字串,關掉「模糊 unicode 字元」誤報(全形標點等)。
86
- ignore = ["RUF001", "RUF002", "RUF003"]
84
+ select = [
85
+ "E",
86
+ "F",
87
+ "W",
88
+ "I",
89
+ "UP",
90
+ "RUF",
91
+ "B",
92
+ "C4",
93
+ "SIM",
94
+ "PTH",
95
+ "ARG",
96
+ "TRY",
97
+ ]
98
+ ignore = [
99
+ # 本專案刻意大量使用台灣漢語註解/字串,關掉「模糊 unicode 字元」誤報(全形標點等)。
100
+ "RUF001",
101
+ "RUF002",
102
+ "RUF003",
103
+ # raise-vanilla-args:ring 是 CLI,例外訊息就是給人看的字串,
104
+ # 為此替每個 raise 造一個自訂例外類別並不划算。
105
+ "TRY003",
106
+ ]
107
+
108
+ [tool.ruff.lint.per-file-ignores]
109
+ # monkeypatch 的替身要符合被替換函式的簽名,未使用的參數是刻意保留的。
110
+ "tests/**" = ["ARG"]
87
111
 
88
112
  [tool.codespell]
89
113
  skip = '.git*,*.lock'
@@ -107,9 +131,13 @@ cover.cmd = "pytest --cov=ring --cov-report=term-missing --cov-report=xml:covera
107
131
  all.help = "Run all tasks"
108
132
  all.sequence = ["format", "lint", "cover"]
109
133
 
134
+ hooks.help = "Run every pre-commit hook against all files"
135
+ hooks.cmd = "prek run --all-files"
136
+ # no-commit-to-branch 只對本機 commit 有意義;CI 是 detached HEAD,跑它只會誤擋。
137
+ hooks.env = { SKIP = "no-commit-to-branch" }
138
+
110
139
  ci.help = "Run all tasks in CI"
111
- ci.sequence = [{ cmd = "prek run --all-files" }, "cover"]
112
- ci.env = { SKIP = "no-commit-to-branch" }
140
+ ci.sequence = ["hooks", "cover"]
113
141
 
114
142
  setup-pre-commit.help = "Install pre-commit hooks"
115
143
  setup-pre-commit.cmd = "prek install"
@@ -143,6 +171,12 @@ addopts = ["-n", "auto", "--dist=loadfile"]
143
171
 
144
172
  [tool.coverage.run]
145
173
  source = ["ring"]
174
+ branch = true
146
175
 
147
176
  [tool.coverage.paths]
148
177
  source = ["src/ring", "*/ring"]
178
+
179
+ [tool.coverage.report]
180
+ # 實測(開 branch coverage、補完 focus/neovim 測試後)總覆蓋率 89%,
181
+ # 門檻壓在 85 留一點正常波動的空間。
182
+ fail_under = 85
@@ -10,6 +10,7 @@
10
10
  from __future__ import annotations
11
11
 
12
12
  import argparse
13
+ import contextlib
13
14
  import json
14
15
  import sys
15
16
  import time
@@ -408,10 +409,8 @@ def _watch_flush_if_due() -> None:
408
409
  ``test_run_hook_does_not_flush_while_quiet_active`` 同機制),這裡不需另外判斷。
409
410
  失敗安靜吞掉,不影響看板本身。
410
411
  """
411
- try:
412
+ with contextlib.suppress(Exception):
412
413
  flush_if_due()
413
- except Exception:
414
- pass
415
414
 
416
415
 
417
416
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
@@ -37,6 +37,11 @@ def run_doctor(args: list[str]) -> int:
37
37
  for src in src_list:
38
38
  try:
39
39
  found = src.discover()
40
+ diagnostic = getattr(src, "diagnostic_issue", None)
41
+ issue = diagnostic() if callable(diagnostic) else ""
42
+ if issue:
43
+ print(f" {src.name:<{width_src}} {_('偵測失敗')} ({issue})")
44
+ continue
40
45
  n = len(found)
41
46
  status_str = _("活著")
42
47
  count_str = _("偵測到 {n} 個 session", n=n)
@@ -9,7 +9,6 @@ iTerm2 / Terminal.app 把外層 pane 或視窗帶到前景。
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- import os
13
12
  import re
14
13
  import subprocess
15
14
  import sys
@@ -57,7 +56,7 @@ def _process_table() -> tuple[dict[int, int], dict[int, str]]:
57
56
  except ValueError:
58
57
  continue
59
58
  parents[pid] = ppid
60
- commands[pid] = os.path.basename(parts[2])
59
+ commands[pid] = Path(parts[2]).name
61
60
  return parents, commands
62
61
 
63
62
 
@@ -130,7 +130,7 @@ def _session_tty(process_names: tuple[str, ...]) -> str:
130
130
  if row is None:
131
131
  return ""
132
132
  ppid, comm = row
133
- if os.path.basename(comm.strip()) in process_names:
133
+ if Path(comm.strip()).name in process_names:
134
134
  return _pid_tty(pid)
135
135
  if ppid <= 1:
136
136
  return ""
@@ -496,7 +496,7 @@ def _is_ring_hook_command(cmd: str) -> bool:
496
496
  tokens = cmd.split()
497
497
  if len(tokens) < 2:
498
498
  return False
499
- return os.path.basename(tokens[0]) == "ring" and tokens[1] == "hook"
499
+ return Path(tokens[0]).name == "ring" and tokens[1] == "hook"
500
500
 
501
501
 
502
502
  # install-hooks 會就「使用者互動」事件警告:別的工具若也掛在這上面(彈自己的對話框 /
@@ -309,10 +309,7 @@ def _action_detail(data: Mapping[str, Any]) -> str:
309
309
  if not detail:
310
310
  detail = _first_str(data, "message", "prompt", "question")
311
311
 
312
- if tool and detail:
313
- out = f"{tool}: {detail}"
314
- else:
315
- out = tool or detail
312
+ out = f"{tool}: {detail}" if tool and detail else tool or detail
316
313
  out = " ".join(out.split()) # 多行指令壓成單行
317
314
  return out if len(out) <= _DETAIL_MAX else out[: _DETAIL_MAX - 1] + "…"
318
315
 
@@ -15,6 +15,7 @@
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import json
19
20
  import os
20
21
  import sys
@@ -95,18 +96,14 @@ def read_focus_request(
95
96
  ts = float(data["ts"])
96
97
  except Exception:
97
98
  # 解析失敗:刪掉爛檔
98
- try:
99
+ with contextlib.suppress(Exception):
99
100
  path.unlink()
100
- except Exception:
101
- pass
102
101
  return None
103
102
 
104
103
  age = time.time() - ts
105
104
  # 不論有效或過期,都刪掉(消費即焚;過期的也不留)
106
- try:
105
+ with contextlib.suppress(Exception):
107
106
  path.unlink()
108
- except Exception:
109
- pass
110
107
 
111
108
  if age > ttl:
112
109
  return None
@@ -121,11 +118,12 @@ def _pid_alive(pid: int) -> bool:
121
118
  """檢查 pid 是否仍存活(送 signal 0)。"""
122
119
  try:
123
120
  os.kill(pid, 0)
124
- return True
125
121
  except (ProcessLookupError, PermissionError):
126
122
  return False
127
123
  except Exception:
128
124
  return False
125
+ else:
126
+ return True
129
127
 
130
128
 
131
129
  def write_tui_presence(*, presence_path: Path | None = None) -> None:
@@ -192,17 +190,13 @@ def read_tui_presence(
192
190
 
193
191
  age = time.time() - ts
194
192
  if age > ttl:
195
- try:
193
+ with contextlib.suppress(Exception):
196
194
  path.unlink()
197
- except Exception:
198
- pass
199
195
  return None
200
196
 
201
197
  if not _pid_alive(pid):
202
- try:
198
+ with contextlib.suppress(Exception):
203
199
  path.unlink()
204
- except Exception:
205
- pass
206
200
  return None
207
201
 
208
202
  return _TuiPresence(tty=tty, pid=pid, ts=ts)
@@ -214,7 +208,5 @@ def clear_tui_presence(*, presence_path: Path | None = None) -> None:
214
208
  :param presence_path: 可注入路徑(測試用)。
215
209
  """
216
210
  path = presence_path or _PRESENCE_PATH
217
- try:
211
+ with contextlib.suppress(Exception):
218
212
  path.unlink()
219
- except Exception:
220
- pass
@@ -15,6 +15,7 @@
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import shutil
19
20
  import sys
20
21
  import time
@@ -164,10 +165,8 @@ def notify_summary(count: int, sample_session: Session) -> None:
164
165
  return
165
166
  text = ngettext("另有 {n} 個 session 在等你", "另有 {n} 個 session 在等你", count, n=count)
166
167
  summary_session = replace(sample_session, is_summary=True, waiting_detail=text)
167
- try:
168
+ with contextlib.suppress(Exception):
168
169
  notifier.send([summary_session])
169
- except Exception:
170
- pass
171
170
 
172
171
 
173
172
  def _send_also(sessions: list[Session], also: tuple[str, ...], primary: Notifier | None) -> None:
@@ -181,10 +180,8 @@ def _send_also(sessions: list[Session], also: tuple[str, ...], primary: Notifier
181
180
  continue
182
181
  for n in _NOTIFIERS:
183
182
  if n.name == name and n.available():
184
- try:
183
+ with contextlib.suppress(Exception):
185
184
  n.send(sessions)
186
- except Exception:
187
- pass
188
185
  break
189
186
 
190
187
 
@@ -199,9 +196,10 @@ def _maybe_show_install_hint() -> str | None:
199
196
  hint = _("💡 裝 terminal-notifier 可點擊通知直接跳回 session:brew install terminal-notifier")
200
197
  _HINT_MARKER.parent.mkdir(parents=True, exist_ok=True)
201
198
  _HINT_MARKER.touch()
202
- return hint
203
199
  except Exception:
204
200
  return None
201
+ else:
202
+ return hint
205
203
 
206
204
 
207
205
  __all__ = ["Notifier", "notifiers", "notify_summary", "notify_waiting", "register_notifier"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import subprocess
6
7
 
7
8
  from ring.notify.base import notify_message, notify_title
@@ -20,10 +21,8 @@ class NotifySendNotifier(CommandNotifier):
20
21
  for s in sessions:
21
22
  title = notify_title(s)
22
23
  message = notify_message(s)
23
- try:
24
+ with contextlib.suppress(Exception):
24
25
  subprocess.run(["notify-send", title, message], capture_output=True, timeout=10)
25
- except Exception:
26
- pass
27
26
 
28
27
 
29
28
  notifier = NotifySendNotifier()
@@ -11,6 +11,7 @@ timeout 短(3s)、失敗安靜吞掉,不擋 hook 主流程。
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import contextlib
14
15
  import json
15
16
  import urllib.request
16
17
  from urllib.parse import urlsplit, urlunsplit
@@ -57,10 +58,8 @@ class NtfyNotifier:
57
58
  headers={"Content-Type": "application/json"},
58
59
  method="POST",
59
60
  )
60
- try:
61
+ with contextlib.suppress(Exception):
61
62
  urllib.request.urlopen(req, timeout=3).close()
62
- except Exception:
63
- pass
64
63
 
65
64
 
66
65
  notifier = NtfyNotifier()
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
6
+
5
7
  from ring.config import get_config
6
8
  from ring.notify.base import notify_message, notify_title
7
9
  from ring.notify.command import CommandNotifier
@@ -22,10 +24,8 @@ class OsascriptNotifier(CommandNotifier):
22
24
  message = notify_message(s)
23
25
  title = notify_title(s)
24
26
  sound = f' sound name "{cfg.notify_sound_name}"' if cfg.notify_sound else ""
25
- try:
27
+ with contextlib.suppress(Exception):
26
28
  osascript(f'display notification "{message}" with title "{title}"{sound}')
27
- except Exception:
28
- pass
29
29
 
30
30
 
31
31
  notifier = OsascriptNotifier()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import shlex
6
7
  import shutil
7
8
  import subprocess
@@ -54,10 +55,8 @@ class TerminalNotifierNotifier(CommandNotifier):
54
55
  cmd.extend(["-sound", cfg.notify_sound_name or "default"])
55
56
  if cfg.notify_ignore_dnd:
56
57
  cmd.append("-ignoreDnD")
57
- try:
58
+ with contextlib.suppress(Exception):
58
59
  subprocess.run(cmd, capture_output=True, timeout=10)
59
- except Exception:
60
- pass
61
60
 
62
61
 
63
62
  notifier = TerminalNotifierNotifier()
@@ -12,6 +12,7 @@ timeout 短(3s)、失敗安靜吞掉,不擋 hook 主流程。
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import contextlib
15
16
  import json
16
17
  import urllib.request
17
18
 
@@ -54,10 +55,8 @@ class WebhookNotifier:
54
55
  headers={"Content-Type": "application/json"},
55
56
  method="POST",
56
57
  )
57
- try:
58
+ with contextlib.suppress(Exception):
58
59
  urllib.request.urlopen(req, timeout=3).close()
59
- except Exception:
60
- pass
61
60
 
62
61
 
63
62
  notifier = WebhookNotifier()
@@ -26,7 +26,7 @@ import fcntl
26
26
  import json
27
27
  import time
28
28
  from collections.abc import Iterator
29
- from contextlib import contextmanager
29
+ from contextlib import contextmanager, suppress
30
30
  from dataclasses import asdict
31
31
  from pathlib import Path
32
32
  from typing import Any
@@ -48,7 +48,7 @@ def _locked(path: Path) -> Iterator[None]:
48
48
  """跨 process 的 read-modify-write 臨界區(抄 ``registry._hidden_sessions_lock``)。"""
49
49
  path.parent.mkdir(parents=True, exist_ok=True)
50
50
  lock_path = path.with_name(path.name + ".lock")
51
- with open(lock_path, "w", encoding="utf-8") as fh:
51
+ with lock_path.open("w", encoding="utf-8") as fh:
52
52
  fcntl.flock(fh, fcntl.LOCK_EX)
53
53
  try:
54
54
  yield
@@ -98,7 +98,7 @@ def _session_from_dict(d: dict[str, Any]) -> Session | None:
98
98
  if isinstance(todo, list) and len(todo) == 2:
99
99
  data["todo"] = (todo[0], todo[1])
100
100
  # 未知欄位(例如舊版留下的)會讓 Session(**data) 直接炸;只保留 Session 認得的欄位。
101
- known = {f for f in Session.__dataclass_fields__}
101
+ known = set(Session.__dataclass_fields__)
102
102
  data = {k: v for k, v in data.items() if k in known}
103
103
  try:
104
104
  return Session(**data)
@@ -201,10 +201,8 @@ def set_quiet(until: float | None, *, quiet_path: Path | None = None) -> None:
201
201
  def clear_quiet(*, quiet_path: Path | None = None) -> None:
202
202
  """手動解除 quiet(``ring quiet off``)。"""
203
203
  path = quiet_path or _QUIET_PATH
204
- try:
204
+ with suppress(OSError):
205
205
  path.unlink()
206
- except OSError:
207
- pass
208
206
 
209
207
 
210
208
  def _read_quiet(*, quiet_path: Path | None = None, now: float) -> dict[str, Any] | None:
@@ -218,10 +216,8 @@ def _read_quiet(*, quiet_path: Path | None = None, now: float) -> dict[str, Any]
218
216
  return None
219
217
  until = raw.get("until")
220
218
  if until is not None and isinstance(until, (int, float)) and now >= float(until):
221
- try:
219
+ with suppress(OSError):
222
220
  path.unlink()
223
- except OSError:
224
- pass
225
221
  return None
226
222
  return raw
227
223
 
@@ -0,0 +1,49 @@
1
+ """cwd 比對用的路徑正規化helpers。
2
+
3
+ 「session 記的 cwd」與「live process 量到的 cwd」來源不同(前者是字面路徑,後者已解
4
+ 過 symlink),直接字串比對會誤判。這裡的函式只做比對用的正規化,不改動任何顯示值。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ def _real(path: str) -> str:
13
+ """正規化路徑供「session cwd ↔ live process cwd」比對。
14
+
15
+ lsof 回報的是解析過 symlink 的真實路徑,但 hook / JSONL / sqlite 記的常是字面
16
+ 路徑;兩者直接字串比對,遇到 symlink 專案路徑會對不上,導致活著的 session 被誤判
17
+ 離場(counts 為 0)或被補成重複列。各自 realpath 後再比對即可避免。只用於比對鍵,
18
+ 不改動 ``Session.cwd`` 的顯示值。
19
+ """
20
+ if not path:
21
+ return path
22
+ try:
23
+ return os.path.realpath(path)
24
+ except OSError:
25
+ return path
26
+
27
+
28
+ def _is_ancestor_dir(ancestor: str, path: str) -> bool:
29
+ """``ancestor`` 是否為 ``path`` 本身或其祖先目錄(兩者皆須已用 ``_real`` 正規化)。
30
+
31
+ 純字串 ``startswith`` 裸比對會誤判 ``/foo`` 命中 ``/foobar``;用尾斜線組出前綴
32
+ (或直接相等)才能正確界定「目錄」邊界。
33
+ """
34
+ if not ancestor or not path:
35
+ return False
36
+ if ancestor == path:
37
+ return True
38
+ prefix = ancestor if ancestor.endswith(os.sep) else ancestor + os.sep
39
+ return path.startswith(prefix)
40
+
41
+
42
+ def _has_ancestor_live_process(row_cwd: str, live_cwds: list[str]) -> bool:
43
+ """``live_cwds`` 裡是否有任一筆是 ``row_cwd`` 本身或其祖先目錄。
44
+
45
+ 用於:使用者在 session 裡 cd 進子目錄後,hook payload 記的 cwd 變成子目錄,但
46
+ claude process 實際 cwd(lsof 量到的)仍停在啟動目錄——子目錄底下量不到 live
47
+ process,不代表 session 已離場,只是 process 沒跟著 cd。
48
+ """
49
+ return any(_is_ancestor_dir(live_cwd, row_cwd) for live_cwd in live_cwds)
@@ -42,8 +42,9 @@ _SEPARATOR_RE = re.compile(r"^\s*─{10,}\s*$")
42
42
  # 聊天輸入框的提示列:「❯ <內容>」。誤送時數字會出現在這裡。
43
43
  _INPUT_LINE_RE = re.compile(r"^\s*❯\s*(.+?)\s*$")
44
44
 
45
- # 送出數字後等 UI 反應的秒數,之後再抓一次畫面驗證。
45
+ # 送出數字後驗證 UI 反應的最長秒數;期間短輪詢,對話框一消失就提早完成。
46
46
  _VERIFY_DELAY = 0.4
47
+ _VERIFY_POLL_INTERVAL = 0.05
47
48
 
48
49
 
49
50
  @dataclass(frozen=True)
@@ -304,8 +305,9 @@ def send_permission_reply(
304
305
  1. 再 capture 一次:對話框不在 → ``NO_DIALOG``;跟 ``expected`` 不同 → ``CHANGED``。
305
306
  兩者都**不送鍵**(使用者在浮層裡想的期間,對話框可能已被本人回掉或換內容)。
306
307
  2. 送單一數字(不帶 Enter)。
307
- 3. 等 ``delay`` 秒再 capture 驗證:對話框消失 → ``OK``;數字落進輸入框 → 補
308
- Backspace、``MISFIRE``;同一個對話框還在 → ``STILL_PRESENT``。
308
+ 3. 最多用 ``delay`` 秒短輪詢 capture:對話框一消失就提早回 ``OK``;數字落進
309
+ 輸入框 → 補 Backspace、``MISFIRE``;期限到仍是同一個對話框 →
310
+ ``STILL_PRESENT``。
309
311
  """
310
312
  if not any(number == n for n, _text in expected.options):
311
313
  return ReplyOutcome.CHANGED # 編號不在選項裡,視同對話框對不上,不送
@@ -319,16 +321,20 @@ def send_permission_reply(
319
321
  return ReplyOutcome.CHANGED
320
322
  if not backend.send_digit(str(number)):
321
323
  return ReplyOutcome.SEND_FAILED
322
- time.sleep(delay)
323
- after = backend.capture()
324
- if after is None:
325
- return ReplyOutcome.UNVERIFIED
326
- after_dialog = parse_permission_dialog(after)
327
- if after_dialog is None:
328
- if digit_in_input_line(after, str(number)):
329
- backend.send_backspace()
330
- return ReplyOutcome.MISFIRE
331
- return ReplyOutcome.OK
332
- if after_dialog == expected:
333
- return ReplyOutcome.STILL_PRESENT
334
- return ReplyOutcome.OK # 對話框換成下一個請求 → 原請求已被回覆
324
+ deadline = time.monotonic() + max(0.0, delay)
325
+ while True:
326
+ after = backend.capture()
327
+ if after is not None:
328
+ after_dialog = parse_permission_dialog(after)
329
+ if after_dialog is None:
330
+ if digit_in_input_line(after, str(number)):
331
+ backend.send_backspace()
332
+ return ReplyOutcome.MISFIRE
333
+ return ReplyOutcome.OK
334
+ if after_dialog != expected:
335
+ return ReplyOutcome.OK # 對話框換成下一個請求 → 原請求已被回覆
336
+
337
+ remaining = deadline - time.monotonic()
338
+ if remaining <= 0:
339
+ return ReplyOutcome.UNVERIFIED if after is None else ReplyOutcome.STILL_PRESENT
340
+ time.sleep(min(_VERIFY_POLL_INTERVAL, remaining))