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.
- {ring_cli-0.14.2 → ring_cli-0.15.1}/PKG-INFO +3 -2
- {ring_cli-0.14.2 → ring_cli-0.15.1}/README.en.md +2 -1
- {ring_cli-0.14.2 → ring_cli-0.15.1}/pyproject.toml +23 -5
- {ring_cli-0.14.2 → ring_cli-0.15.1}/pyproject.toml.orig +40 -6
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/cli.py +2 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/doctor.py +5 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/neovim.py +1 -2
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/hook.py +2 -2
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/hook_protocol.py +1 -4
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/ipc.py +8 -16
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/__init__.py +5 -7
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/notify_send.py +2 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/ntfy.py +2 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/osascript_notifier.py +3 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/terminal_notifier.py +2 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/webhook.py +2 -3
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify_queue.py +5 -9
- ring_cli-0.15.1/src/ring/pathutil.py +49 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/permission.py +22 -16
- ring_cli-0.15.1/src/ring/ps_parse.py +164 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/registry.py +29 -346
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/__init__.py +8 -8
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/local_llm.py +97 -17
- ring_cli-0.15.1/src/ring/subproc.py +9 -0
- ring_cli-0.15.1/src/ring/tmux_scan.py +186 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/tui.py +50 -26
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/watcher.py +1 -1
- {ring_cli-0.14.2 → ring_cli-0.15.1}/LICENSE +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/__main__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/quiet.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/config.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/focus/trace.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/gc.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/i18n.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/labels.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/notify/command.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/osascript.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/payload_log.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/plugins.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/question_detect.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.1}/src/ring/stats.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 = [
|
|
85
|
-
|
|
86
|
-
|
|
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 = [
|
|
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
|
-
|
|
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] =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
308
|
-
Backspace、``MISFIRE
|
|
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.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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))
|