ring-cli 0.15.0__tar.gz → 0.16.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 (69) hide show
  1. {ring_cli-0.15.0 → ring_cli-0.16.0}/PKG-INFO +2 -2
  2. {ring_cli-0.15.0 → ring_cli-0.16.0}/README.en.md +1 -1
  3. {ring_cli-0.15.0 → ring_cli-0.16.0}/pyproject.toml +23 -5
  4. {ring_cli-0.15.0 → ring_cli-0.16.0}/pyproject.toml.orig +40 -6
  5. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/cli.py +2 -3
  6. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/doctor.py +15 -0
  7. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/config.py +1 -1
  8. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/__init__.py +15 -4
  9. ring_cli-0.16.0/src/ring/focus/kitty.py +196 -0
  10. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/neovim.py +1 -2
  11. ring_cli-0.16.0/src/ring/focus/tmux.py +74 -0
  12. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/hook.py +3 -3
  13. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/hook_protocol.py +1 -4
  14. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/ipc.py +8 -16
  15. ring_cli-0.16.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  16. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/locale/en/LC_MESSAGES/ring.po +27 -6
  17. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/locale/ring.pot +181 -150
  18. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/__init__.py +5 -7
  19. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/notify_send.py +2 -3
  20. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/ntfy.py +2 -3
  21. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/osascript_notifier.py +3 -3
  22. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/terminal_notifier.py +2 -3
  23. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/webhook.py +2 -3
  24. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify_queue.py +5 -9
  25. ring_cli-0.16.0/src/ring/pathutil.py +49 -0
  26. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/permission.py +125 -15
  27. ring_cli-0.16.0/src/ring/ps_parse.py +164 -0
  28. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/registry.py +18 -367
  29. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/__init__.py +8 -8
  30. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/local_llm.py +5 -4
  31. ring_cli-0.16.0/src/ring/subproc.py +9 -0
  32. ring_cli-0.16.0/src/ring/tmux_scan.py +186 -0
  33. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/tui.py +45 -19
  34. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/watcher.py +1 -1
  35. ring_cli-0.15.0/src/ring/focus/tmux.py +0 -38
  36. ring_cli-0.15.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  37. {ring_cli-0.15.0 → ring_cli-0.16.0}/LICENSE +0 -0
  38. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/__init__.py +0 -0
  39. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/__main__.py +0 -0
  40. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/__init__.py +0 -0
  41. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/_args.py +0 -0
  42. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/completion.py +0 -0
  43. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/digest.py +0 -0
  44. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/focus.py +0 -0
  45. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/gc.py +0 -0
  46. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/hook.py +0 -0
  47. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/quiet.py +0 -0
  48. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/stats.py +0 -0
  49. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/applescript.py +0 -0
  50. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/base.py +0 -0
  51. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/iterm2.py +0 -0
  52. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/linux_wm.py +0 -0
  53. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/terminal.py +0 -0
  54. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/trace.py +0 -0
  55. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/gc.py +0 -0
  56. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/i18n.py +0 -0
  57. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/labels.py +0 -0
  58. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/base.py +0 -0
  59. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/command.py +0 -0
  60. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/osascript.py +0 -0
  61. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/payload_log.py +0 -0
  62. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/plugins.py +0 -0
  63. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/question_detect.py +0 -0
  64. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/base.py +0 -0
  65. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/claude_code.py +0 -0
  66. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/codex.py +0 -0
  67. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/hook_registry.py +0 -0
  68. {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/stats.py +0 -0
  69. {ring_cli-0.15.0 → ring_cli-0.16.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.15.0
3
+ Version: 0.16.0
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
@@ -50,7 +50,7 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
50
50
  - **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
51
51
  detection for interactive Ollama and llama.cpp CLIs.
52
52
  - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
53
- - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
53
+ - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, kitty, Neovim terminals, or Linux X11 windows from the TUI.
54
54
  - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
55
55
  - **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
56
56
 
@@ -24,7 +24,7 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
24
24
  - **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
25
25
  detection for interactive Ollama and llama.cpp CLIs.
26
26
  - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
27
- - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
27
+ - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, kitty, Neovim terminals, or Linux X11 windows from the TUI.
28
28
  - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
29
29
  - **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ring-cli"
3
- version = "0.15.0"
3
+ version = "0.16.0"
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.15.0"
5
+ version = "0.16.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"
@@ -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:
@@ -7,6 +7,7 @@ import sys
7
7
 
8
8
  from ring.commands._args import strip_lang
9
9
  from ring.config import CONFIG_PATH, get_config
10
+ from ring.focus.kitty import sockets as kitty_sockets
10
11
  from ring.gc import DEFAULT_OLDER_THAN_SECONDS
11
12
  from ring.gc import collect_candidates as gc_collect_candidates
12
13
  from ring.i18n import gettext as _
@@ -110,6 +111,20 @@ def run_doctor(args: list[str]) -> int:
110
111
  elif name_lower == "tmux":
111
112
  avail = shutil.which("tmux") is not None
112
113
  avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
114
+ elif name_lower == "kitty":
115
+ if shutil.which("kitty") is None:
116
+ avail_str = _("不可用(kitty 不在 PATH)")
117
+ elif not kitty_sockets():
118
+ avail_str = _("不可用(kitty remote control 沒開:kitty.conf 要設 allow_remote_control + listen_on)")
119
+ else:
120
+ avail_str = _("可用")
121
+ elif name_lower == "linux-wm":
122
+ if not sys.platform.startswith("linux"):
123
+ avail_str = _("不可用(非 Linux)")
124
+ elif shutil.which("wmctrl") is None:
125
+ avail_str = _("不可用(wmctrl 不在 PATH)")
126
+ else:
127
+ avail_str = _("可用")
113
128
  elif shutil.which("osascript") is None:
114
129
  avail_str = _("不可用(osascript 不在 PATH)")
115
130
  else:
@@ -34,7 +34,7 @@
34
34
  notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
35
35
  notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
36
36
  notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
37
- focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
37
+ focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "kitty", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
38
38
  plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*)
39
39
  debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload
40
40
  # (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl);
@@ -3,7 +3,7 @@
3
3
  每個終端是一個 ``Focuser``(見 ``base.Focuser``):core 不認識任何具體終端,
4
4
  只依序問每個 focuser「這個 session 歸不歸你管」。要支援新終端=寫一個模組、
5
5
  ``register_focuser()`` 註冊,core 零改動。內建:Neovim terminal / tmux / iTerm2 /
6
- Terminal.app / Linux X11 視窗(wmctrl,best-effort fallback)。
6
+ Terminal.app / kitty(remote control socket)/ Linux X11 視窗(wmctrl,best-effort fallback)。
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  from ring.config import get_config
12
12
  from ring.focus.base import Focuser
13
13
  from ring.focus.iterm2 import focuser as _iterm2
14
+ from ring.focus.kitty import focuser as _kitty
14
15
  from ring.focus.linux_wm import focuser as _linux_wm
15
16
  from ring.focus.neovim import focuser as _neovim
16
17
  from ring.focus.terminal import focuser as _terminal
@@ -20,12 +21,13 @@ from ring.i18n import gettext as _
20
21
  from ring.registry import Session, Status
21
22
 
22
23
  # 內建 focuser。順序可由 config 的 `focusers` 覆寫。Neovim 先切內層 terminal buffer,
23
- # 再由 tmux / macOS app / linux-wm 把外層帶到前景。
24
+ # 再由 tmux / macOS app / kitty / linux-wm 把外層帶到前景。
24
25
  _BUILTIN: dict[str, Focuser] = {
25
26
  "Neovim": _neovim,
26
27
  "tmux": _tmux,
27
28
  "iTerm2": _iterm2,
28
29
  "Terminal": _terminal,
30
+ "kitty": _kitty,
29
31
  "linux-wm": _linux_wm,
30
32
  }
31
33
 
@@ -99,8 +101,17 @@ def jump(session: Session) -> tuple[bool, str]:
99
101
  if failures:
100
102
  return _done(False, "; ".join(failures))
101
103
  if session.tty:
102
- return _done(False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)"))
103
- return _done(False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)"))
104
+ return _done(
105
+ False,
106
+ _("找不到這個終端分頁(可能已關閉;刷新後若仍在,跑 `ring doctor` 檢查 focuser 與 hook 狀態)"),
107
+ )
108
+ return _done(
109
+ False,
110
+ _(
111
+ "沒有 focuser 接得住(跑 `ring doctor` 看哪些 focuser 可用;"
112
+ "裝 hook、或一個專案只開一個 session 才測得到 tty)"
113
+ ),
114
+ )
104
115
 
105
116
 
106
117
  __all__ = ["Focuser", "focusers", "jump", "register_focuser"]
@@ -0,0 +1,196 @@
1
+ """kitty 終端 focuser:透過 remote control socket 精準跳到分頁。
2
+
3
+ 定位:kitty 官方沒有 tmux 那種「切換 client」指令,也沒有 iTerm2 / Terminal.app 的 AppleScript
4
+ 可查詢;能精準定位到分頁的唯一途徑是它的 remote control 協定——連上 unix socket、列出視窗、
5
+ 比對 tty,再叫它 focus-window。
6
+
7
+ 機制與限制(誠實標明):
8
+ - **kitty 預設關閉 remote control**,要在 kitty.conf 設 ``allow_remote_control`` 為
9
+ ``yes`` / ``socket`` / ``socket-only``,並設 ``listen_on``(例:
10
+ ``listen_on unix:/tmp/kitty-{kitty_pid}``)才會建立 socket;沒設就會安靜地跳過(回 ``None``),
11
+ 不會顯示錯誤。
12
+ - 只掃 ``/tmp`` 底下的 ``kitty-*`` socket(外加 ``$KITTY_LISTEN_ON`` 這條環境變數,但 RiNG
13
+ 通常跑在 kitty 之外,這個變數多半是空的);``listen_on`` 指到別的目錄就配不到。
14
+ - 逐個 socket 問一輪:多個 kitty 實例的 socket 互不可見,沒有統一查詢點。
15
+ - 用 ``window["pid"]``/``window["foreground_processes"][]["pid"]`` 反查 tty 跟 session 比對,
16
+ 找到後呼叫 ``focus-window``;macOS 上固定補一次 ``osascript activate``(視窗縮到 Dock 時
17
+ ``focus-window`` 只負責取消縮小、不負責把整個 app 帶到最前,已實測)。
18
+ - **Linux 端未實測**:``focus-window`` 底層走同一套 remote-control 協定,理論上等同
19
+ ``wmctrl -i -a`` 的效果,但這只是推論。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ import stat
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from ring.osascript import osascript
34
+ from ring.registry import Session
35
+
36
+ _SOCKET_DIRS: tuple[Path, ...] = (Path("/tmp"),) # 之後要支援自訂 listen_on 目錄就加在這裡
37
+ _MAX_SOCKETS = 8
38
+
39
+
40
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str] | None:
41
+ """跑一個唯讀/聚焦指令,成功回 ``CompletedProcess``(不論 returncode),失敗回 ``None``。"""
42
+ try:
43
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=3)
44
+ except (OSError, subprocess.SubprocessError):
45
+ return None
46
+
47
+
48
+ def _is_socket(path: Path) -> bool:
49
+ try:
50
+ return stat.S_ISSOCK(path.lstat().st_mode)
51
+ except OSError:
52
+ return False
53
+
54
+
55
+ def sockets() -> list[str]:
56
+ """候選 kitty remote-control socket 路徑,依 mtime 新到舊排序,上限 ``_MAX_SOCKETS``。
57
+
58
+ 候選來源:``$KITTY_LISTEN_ON``(去掉 ``unix:`` 前綴、需為絕對路徑)+ ``/tmp/kitty-*`` glob。
59
+ 一律用 ``stat.S_ISSOCK`` 過濾——``/tmp`` 下可能有 ``kitty-iterm-migration``、
60
+ ``kitty-swift-module-cache`` 這種同前綴的普通目錄,不濾會誤連。呼叫時才做 I/O,
61
+ 模組 import 時不碰檔案系統。
62
+ """
63
+ candidates: list[Path] = []
64
+ listen_on = os.environ.get("KITTY_LISTEN_ON", "")
65
+ if listen_on.startswith("unix:"):
66
+ listen_on = listen_on.removeprefix("unix:")
67
+ if listen_on and Path(listen_on).is_absolute():
68
+ candidates.append(Path(listen_on))
69
+ for socket_dir in _SOCKET_DIRS:
70
+ try:
71
+ candidates.extend(sorted(socket_dir.glob("kitty-*")))
72
+ except OSError:
73
+ continue
74
+
75
+ seen: set[Path] = set()
76
+ found: list[tuple[float, Path]] = []
77
+ for path in candidates:
78
+ if path in seen or not _is_socket(path):
79
+ continue
80
+ seen.add(path)
81
+ try:
82
+ mtime = path.stat().st_mtime
83
+ except OSError:
84
+ continue
85
+ found.append((mtime, path))
86
+ found.sort(key=lambda item: item[0], reverse=True)
87
+ return [str(path) for _mtime, path in found[:_MAX_SOCKETS]]
88
+
89
+
90
+ def _pid_tty_map() -> dict[int, str]:
91
+ """一次性 ``ps -eo pid=,tty=`` 快照:pid → ``/dev/ttyXXX``(``?``/``??`` 視為無 tty)。"""
92
+ result = _run(["ps", "-eo", "pid=,tty="])
93
+ table: dict[int, str] = {}
94
+ if result is None or result.returncode != 0:
95
+ return table
96
+ for line in result.stdout.splitlines():
97
+ parts = line.split()
98
+ if len(parts) != 2:
99
+ continue
100
+ try:
101
+ pid = int(parts[0])
102
+ except ValueError:
103
+ continue
104
+ raw_tty = parts[1]
105
+ if not raw_tty or raw_tty in {"?", "??"}:
106
+ continue
107
+ table[pid] = raw_tty if raw_tty.startswith("/dev/") else f"/dev/{raw_tty}"
108
+ return table
109
+
110
+
111
+ def _window_matches_tty(window: dict[str, Any], tty: str, pid_tty: dict[int, str]) -> bool:
112
+ pids: list[Any] = [window.get("pid")]
113
+ foreground = window.get("foreground_processes")
114
+ if isinstance(foreground, list):
115
+ for proc in foreground:
116
+ if isinstance(proc, dict):
117
+ pids.append(proc.get("pid"))
118
+ return any(isinstance(pid, int) and pid_tty.get(pid) == tty for pid in pids)
119
+
120
+
121
+ def _match_window(os_windows: Any, tty: str, pid_tty: dict[int, str]) -> int | None:
122
+ """走訪 ``kitty @ ls`` 的 JSON(OS window → tabs[] → windows[]),回第一個比對到 tty 的 window id。"""
123
+ if not isinstance(os_windows, list):
124
+ return None
125
+ for os_window in os_windows:
126
+ if not isinstance(os_window, dict):
127
+ continue
128
+ tabs = os_window.get("tabs")
129
+ if not isinstance(tabs, list):
130
+ continue
131
+ for tab in tabs:
132
+ if not isinstance(tab, dict):
133
+ continue
134
+ windows = tab.get("windows")
135
+ if not isinstance(windows, list):
136
+ continue
137
+ for window in windows:
138
+ if not isinstance(window, dict):
139
+ continue
140
+ if _window_matches_tty(window, tty, pid_tty):
141
+ win_id = window.get("id")
142
+ if isinstance(win_id, int):
143
+ return win_id
144
+ return None
145
+
146
+
147
+ def resolve_window(tty: str) -> tuple[str, int] | None:
148
+ """tty → ``(socket 路徑, kitty window id)``;沒裝 kitty/沒 socket/配不到 → ``None``。
149
+
150
+ 這段定位邏輯同時給 ``focus``(聚焦分頁)與 ``permission``(抓畫面/送鍵)兩邊共用,
151
+ kitty ``ls`` 的 JSON 形狀知識只此一份,兩邊不重寫第二份。
152
+ """
153
+ if not tty or shutil.which("kitty") is None:
154
+ return None
155
+ socks = sockets()
156
+ if not socks:
157
+ return None
158
+
159
+ pid_tty = _pid_tty_map()
160
+
161
+ for sock in socks:
162
+ result = _run(["kitty", "@", "--to", f"unix:{sock}", "ls"])
163
+ if result is None or result.returncode != 0:
164
+ continue
165
+ try:
166
+ os_windows = json.loads(result.stdout)
167
+ except json.JSONDecodeError:
168
+ continue
169
+ win_id = _match_window(os_windows, tty, pid_tty)
170
+ if win_id is None:
171
+ continue
172
+ return sock, win_id
173
+ return None
174
+
175
+
176
+ class KittyFocuser:
177
+ name = "kitty"
178
+
179
+ def try_focus(self, session: Session) -> tuple[bool, str] | None:
180
+ tty = session.tty
181
+ if not tty:
182
+ return None
183
+ resolved = resolve_window(tty)
184
+ if resolved is None:
185
+ return None
186
+ sock, win_id = resolved
187
+
188
+ focus_result = _run(["kitty", "@", "--to", f"unix:{sock}", "focus-window", "--match", f"id:{win_id}"])
189
+ if focus_result is None or focus_result.returncode != 0:
190
+ return False, f"kitty focus-window failed for id:{win_id}"
191
+ if sys.platform == "darwin" and shutil.which("osascript"):
192
+ osascript('tell application "kitty" to activate')
193
+ return True, f"{self.name} {win_id}"
194
+
195
+
196
+ focuser = KittyFocuser()
@@ -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
 
@@ -0,0 +1,74 @@
1
+ """tmux focuser:同一個 tmux server 直接 switch-client 切到那個 pane。
2
+
3
+ 成功後不中止 dispatcher(``continue_after_success``):tmux 只能切 pane,換不了外層終端
4
+ 視窗本身有沒有浮到前景,所以讓 iTerm2 / Terminal.app / kitty / linux-wm 等外層 focuser
5
+ 接著跑,才有機會把視窗帶到前景(設計依據見 ``focus/base.py:8-9``)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+
13
+ from ring.registry import Session
14
+
15
+
16
+ def _client_tty(tmux_session: str, target: str) -> str:
17
+ """查這個 tmux session 目前的 client tty,回填給外層 focuser 配對用。
18
+
19
+ 多個 client attach 同一個 session 時取第一個非空值;查不到(沒有 client、指令失敗、
20
+ 逾時)一律回空字串,呼叫端不得把這種情況當成 switch-client 失敗。
21
+ """
22
+ for cmd in (
23
+ ["tmux", "list-clients", "-t", tmux_session, "-F", "#{client_tty}"],
24
+ ["tmux", "display-message", "-p", "-t", target, "#{client_tty}"],
25
+ ):
26
+ try:
27
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
28
+ except (OSError, subprocess.SubprocessError):
29
+ continue
30
+ if result.returncode != 0:
31
+ continue
32
+ for line in result.stdout.splitlines():
33
+ line = line.strip()
34
+ if line:
35
+ return line
36
+ return ""
37
+
38
+
39
+ class TmuxFocuser:
40
+ name = "tmux"
41
+ continue_after_success = True
42
+
43
+ def try_focus(self, session: Session) -> tuple[bool, str] | None:
44
+ target = session.tmux_target
45
+ if not target or not shutil.which("tmux"):
46
+ return None
47
+ tmux_session = target.split(":", 1)[0]
48
+ window = target.split(".", 1)[0]
49
+ ok = False
50
+ last_err = ""
51
+ for cmd in (
52
+ ["tmux", "switch-client", "-t", tmux_session],
53
+ ["tmux", "select-window", "-t", window],
54
+ ["tmux", "select-pane", "-t", target],
55
+ ):
56
+ try:
57
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
58
+ except (OSError, subprocess.SubprocessError) as exc:
59
+ return False, str(exc)
60
+ if result.returncode == 0:
61
+ ok = True
62
+ else:
63
+ last_err = result.stderr.strip()
64
+ if not ok:
65
+ return False, last_err or "tmux switch failed"
66
+ # switch-client 之後,接上這個 tmux session 的 client 的 tty 就是那個終端視窗的 tty。
67
+ # 回填給後面的外層 focuser 配對用(同 neovim.py:176-179 的做法)。
68
+ client_tty = _client_tty(tmux_session, target)
69
+ if client_tty.startswith("/dev/"):
70
+ session.tty = client_tty
71
+ return True, f"tmux {target}"
72
+
73
+
74
+ focuser = TmuxFocuser()
@@ -120,7 +120,7 @@ def _ps_row(pid: int) -> tuple[int, str] | None:
120
120
  def _session_tty(process_names: tuple[str, ...]) -> str:
121
121
  """hook 是 agent CLI 的後代——往上找到 provider process,回它的控制終端 tty。
122
122
 
123
- 這是「session → 哪個終端」最精準的對應(不必靠 cwd 猜),給 iTerm2 跳轉用。
123
+ 這是「session → 哪個終端」最精準的對應(不必靠 cwd 猜),給非-tmux 終端 focuser(iTerm2 等)跳轉用。
124
124
  """
125
125
  if not process_names:
126
126
  return ""
@@ -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