ring-cli 0.2.0__tar.gz → 0.4.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 (44) hide show
  1. {ring_cli-0.2.0 → ring_cli-0.4.0}/PKG-INFO +31 -5
  2. {ring_cli-0.2.0 → ring_cli-0.4.0}/README.en.md +30 -4
  3. {ring_cli-0.2.0 → ring_cli-0.4.0}/pyproject.toml +1 -1
  4. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/cli.py +48 -222
  5. ring_cli-0.4.0/src/ring/commands/__init__.py +1 -0
  6. ring_cli-0.4.0/src/ring/commands/_args.py +20 -0
  7. ring_cli-0.4.0/src/ring/commands/doctor.py +122 -0
  8. ring_cli-0.4.0/src/ring/commands/focus.py +23 -0
  9. ring_cli-0.4.0/src/ring/commands/gc.py +46 -0
  10. ring_cli-0.4.0/src/ring/commands/hook.py +29 -0
  11. ring_cli-0.4.0/src/ring/gc.py +152 -0
  12. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/hook.py +41 -4
  13. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/registry.py +4 -2
  14. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/tui.py +2 -7
  15. {ring_cli-0.2.0 → ring_cli-0.4.0}/LICENSE +0 -0
  16. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/__init__.py +0 -0
  17. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/__main__.py +0 -0
  18. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/config.py +0 -0
  19. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/__init__.py +0 -0
  20. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/applescript.py +0 -0
  21. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/base.py +0 -0
  22. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/iterm2.py +0 -0
  23. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/terminal.py +0 -0
  24. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/tmux.py +0 -0
  25. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/hook_protocol.py +0 -0
  26. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/i18n.py +0 -0
  27. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/ipc.py +0 -0
  28. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/labels.py +0 -0
  29. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  30. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  31. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/ring.pot +0 -0
  32. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/__init__.py +0 -0
  33. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/base.py +0 -0
  34. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/command.py +0 -0
  35. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/notify_send.py +0 -0
  36. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/osascript_notifier.py +0 -0
  37. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/terminal_notifier.py +0 -0
  38. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/osascript.py +0 -0
  39. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/__init__.py +0 -0
  40. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/base.py +0 -0
  41. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/claude_code.py +0 -0
  42. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/codex.py +0 -0
  43. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/hook_registry.py +0 -0
  44. {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -27,6 +27,10 @@ Description-Content-Type: text/markdown
27
27
 
28
28
  [台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
29
29
 
30
+ [![PyPI](https://img.shields.io/pypi/v/ring-cli?label=PyPI)](https://pypi.org/project/ring-cli/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/ring-cli)](https://pypi.org/project/ring-cli/)
32
+ [![License](https://img.shields.io/pypi/l/ring-cli)](LICENSE)
33
+
30
34
  > **R**ealtime **I**nstance **N**otification **G**rid
31
35
  > — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
32
36
 
@@ -54,7 +58,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
54
58
  - **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
55
59
  - **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
56
60
  - **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, or Terminal.app.
57
- - **System notifications**: hook mode can beep and notify when a session starts waiting; with `terminal-notifier`, clicking the notification jumps back.
61
+ - **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
58
62
  - **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
59
63
  - **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
60
64
 
@@ -93,6 +97,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
93
97
  ring install-hooks # merges into Claude Code / Codex hook settings
94
98
  ring install-hooks --dry-run # preview without writing
95
99
  ring doctor # inspect hooks, notification backends, focusers, and config
100
+ ring gc --dry-run # preview RiNG-owned stale state cleanup
96
101
  ```
97
102
 
98
103
  Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
@@ -112,6 +117,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
112
117
  | `ring config` | Show config path and effective settings |
113
118
  | `ring config set KEY VALUE` | Write one config value |
114
119
  | `ring doctor` | Read-only environment diagnosis |
120
+ | `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
121
+ | `ring gc` | Clean RiNG-owned stale state files |
115
122
 
116
123
  ## Watch Mode
117
124
 
@@ -132,8 +139,10 @@ one live Codex session per cwd can jump correctly; multiple live Codex sessions
132
139
 
133
140
  ### Notifications
134
141
 
135
- Default behavior: with hooks installed, when a session changes to 🔴 waiting, RiNG beeps and sends
136
- a system notification. If it keeps waiting, RiNG reminds you again at 30s / 120s / 300s by default.
142
+ Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
143
+ and sends a system notification right then **no RiNG board needs to be open**, so it rings you even
144
+ after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
145
+ also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
137
146
 
138
147
  For clickable notifications on macOS, install `terminal-notifier`:
139
148
 
@@ -145,6 +154,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
145
154
  Notification sound, repeat timing, and backend selection are configurable; notification backends
146
155
  are also pluggable via the `Notifier` extension point.
147
156
 
157
+ ### Cleaning RiNG State Files (`ring gc`)
158
+
159
+ When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
160
+ final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
161
+ the board by default, and you can remove them with `ring gc`.
162
+
163
+ ```sh
164
+ ring gc --dry-run # preview what would be deleted
165
+ ring gc # delete ended registry files older than 7 days, plus expired IPC files
166
+ ring gc --older-than 1d # use a 1-day threshold
167
+ ring gc --all-ended # delete every registry file currently classified as ended
168
+ ```
169
+
170
+ `ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
171
+ Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
172
+
148
173
  ## Session Sources
149
174
 
150
175
  RiNG collects sessions from registered sources. Built-ins:
@@ -243,7 +268,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
243
268
 
244
269
  ### agent-hooks
245
270
 
246
- Notification details: `--watch` and the TUI both send notifications. The default backend is `auto`:
271
+ Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
272
+ the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
247
273
  RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
248
274
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
249
275
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  [台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
4
4
 
5
+ [![PyPI](https://img.shields.io/pypi/v/ring-cli?label=PyPI)](https://pypi.org/project/ring-cli/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/ring-cli)](https://pypi.org/project/ring-cli/)
7
+ [![License](https://img.shields.io/pypi/l/ring-cli)](LICENSE)
8
+
5
9
  > **R**ealtime **I**nstance **N**otification **G**rid
6
10
  > — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
7
11
 
@@ -29,7 +33,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
29
33
  - **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
30
34
  - **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
31
35
  - **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, or Terminal.app.
32
- - **System notifications**: hook mode can beep and notify when a session starts waiting; with `terminal-notifier`, clicking the notification jumps back.
36
+ - **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
33
37
  - **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
34
38
  - **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
35
39
 
@@ -68,6 +72,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
68
72
  ring install-hooks # merges into Claude Code / Codex hook settings
69
73
  ring install-hooks --dry-run # preview without writing
70
74
  ring doctor # inspect hooks, notification backends, focusers, and config
75
+ ring gc --dry-run # preview RiNG-owned stale state cleanup
71
76
  ```
72
77
 
73
78
  Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
@@ -87,6 +92,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
87
92
  | `ring config` | Show config path and effective settings |
88
93
  | `ring config set KEY VALUE` | Write one config value |
89
94
  | `ring doctor` | Read-only environment diagnosis |
95
+ | `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
96
+ | `ring gc` | Clean RiNG-owned stale state files |
90
97
 
91
98
  ## Watch Mode
92
99
 
@@ -107,8 +114,10 @@ one live Codex session per cwd can jump correctly; multiple live Codex sessions
107
114
 
108
115
  ### Notifications
109
116
 
110
- Default behavior: with hooks installed, when a session changes to 🔴 waiting, RiNG beeps and sends
111
- a system notification. If it keeps waiting, RiNG reminds you again at 30s / 120s / 300s by default.
117
+ Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
118
+ and sends a system notification right then **no RiNG board needs to be open**, so it rings you even
119
+ after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
120
+ also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
112
121
 
113
122
  For clickable notifications on macOS, install `terminal-notifier`:
114
123
 
@@ -120,6 +129,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
120
129
  Notification sound, repeat timing, and backend selection are configurable; notification backends
121
130
  are also pluggable via the `Notifier` extension point.
122
131
 
132
+ ### Cleaning RiNG State Files (`ring gc`)
133
+
134
+ When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
135
+ final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
136
+ the board by default, and you can remove them with `ring gc`.
137
+
138
+ ```sh
139
+ ring gc --dry-run # preview what would be deleted
140
+ ring gc # delete ended registry files older than 7 days, plus expired IPC files
141
+ ring gc --older-than 1d # use a 1-day threshold
142
+ ring gc --all-ended # delete every registry file currently classified as ended
143
+ ```
144
+
145
+ `ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
146
+ Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
147
+
123
148
  ## Session Sources
124
149
 
125
150
  RiNG collects sessions from registered sources. Built-ins:
@@ -218,7 +243,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
218
243
 
219
244
  ### agent-hooks
220
245
 
221
- Notification details: `--watch` and the TUI both send notifications. The default backend is `auto`:
246
+ Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
247
+ the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
222
248
  RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
223
249
  on Linux. If none are available, RiNG keeps the board running and skips notifications.
224
250
 
@@ -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.2.0"
5
+ version = "0.4.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"
@@ -10,13 +10,18 @@
10
10
  from __future__ import annotations
11
11
 
12
12
  import argparse
13
- import shutil
14
13
  import sys
15
14
  import time
15
+ from dataclasses import fields
16
16
  from importlib.util import find_spec
17
17
  from typing import Any
18
18
 
19
19
  from ring import __version__
20
+ from ring.commands._args import strip_lang as _strip_lang
21
+ from ring.commands.doctor import run_doctor
22
+ from ring.commands.focus import run_focus
23
+ from ring.commands.gc import run_gc
24
+ from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks
20
25
  from ring.config import CONFIG_PATH as CONFIG_PATH
21
26
  from ring.config import Config as Config
22
27
  from ring.config import ConfigError as ConfigError
@@ -26,7 +31,7 @@ from ring.i18n import gettext as _
26
31
  from ring.i18n import ngettext, set_lang
27
32
  from ring.labels import load_labels
28
33
  from ring.registry import Session, Status, running_agent_pids
29
- from ring.sources import discover_sessions, sources
34
+ from ring.sources import discover_sessions
30
35
 
31
36
  try:
32
37
  from rich.box import SIMPLE_HEAD
@@ -74,14 +79,14 @@ def _middle_truncate(text: str, max_len: int) -> str:
74
79
  tmux 座標(如 ``main:1.0``)很短,自然走這條,不被動到。
75
80
 
76
81
  2. 否則以 ``/`` 為界,保留最後一層目錄完整:
77
- ``tail = "/" + text.rsplit("/", 1)[-1]``,
82
+ ``tail = f"/{text.rsplit('/', 1)[-1]}"``,
78
83
  ``head_budget = max_len - 1 - len(tail)``(1 = ``…`` 的長度)。
79
- 若 ``head_budget >= 1``:回傳 ``text[:head_budget] + "…" + tail``。
84
+ 若 ``head_budget >= 1``:回傳 ``f"{text[:head_budget]}{tail}"``。
80
85
 
81
86
  3. ``head_budget < 1``(最後一層目錄名本身就超長,或 text 無 ``/``):
82
87
  退化為純字元中段省略——
83
88
  ``keep = max_len - 1``;``front = (keep + 1) // 2``;``back = keep // 2``;
84
- 回傳 ``text[:front] + "" + text[-back:]``(總長 == max_len)。
89
+ 回傳 ``f"{text[:front]}{text[-back:]}"``(總長 == max_len)。
85
90
 
86
91
  4. ``max_len <= 1`` 邊界:直接回傳 ``text[:max_len]``,避免負數切片。
87
92
 
@@ -92,17 +97,17 @@ def _middle_truncate(text: str, max_len: int) -> str:
92
97
  if len(text) <= max_len:
93
98
  return text
94
99
  # 以 / 為界,保留最後一層
95
- tail = "/" + text.rsplit("/", 1)[-1]
100
+ tail = f"/{text.rsplit('/', 1)[-1]}"
96
101
  head_budget = max_len - 1 - len(tail) # 1 for "…"
97
102
  if head_budget >= 1:
98
- return text[:head_budget] + "…" + tail
103
+ return f"{text[:head_budget]}{tail}"
99
104
  # 病態長尾段 fallback:純字元中段省略
100
105
  keep = max_len - 1
101
106
  front = (keep + 1) // 2
102
107
  back = keep // 2
103
108
  if back == 0:
104
109
  return text[:max_len]
105
- return text[:front] + "" + text[-back:]
110
+ return f"{text[:front]}{text[-back:]}"
106
111
 
107
112
 
108
113
  def board(show_all: bool) -> list[Session]:
@@ -158,7 +163,7 @@ def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool
158
163
  if show_legend:
159
164
  blocks.append(_rich_legend())
160
165
  if not sessions:
161
- blocks.append(Text(" " + _("(場館目前沒人上台)"), style=f"{_MUTED} italic"))
166
+ blocks.append(Text(f" {_('(場館目前沒人上台)')}", style=f"{_MUTED} italic"))
162
167
  return Group(*blocks)
163
168
 
164
169
  table = Table(box=SIMPLE_HEAD, header_style="bold", pad_edge=False, expand=False)
@@ -196,7 +201,7 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
196
201
  items = " ".join(f"{st.marker} {status_label(st)}" for st in Status)
197
202
  lines += ["", f" {_('圖例')} {items}"]
198
203
  if not sessions:
199
- lines += ["", " " + _("(場館目前沒人上台)")]
204
+ lines += ["", f" {_('(場館目前沒人上台)')}"]
200
205
  return "\n".join(lines)
201
206
 
202
207
  labels = load_labels()
@@ -219,16 +224,12 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
219
224
  w_ago = max(len(c_idle), 3, *(len(r[4]) for r in rows))
220
225
  w_loc = max(len(c_loc), *(len(r[5]) for r in rows))
221
226
  tool_h = f"{c_tool:<{w_tool}} " if show_tool else ""
222
- header = (
223
- f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} "
224
- f"{c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
225
- )
227
+ header = f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} {c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
226
228
  lines += ["", header]
227
229
  for marker, tool, project, prog, ago, loc, action in rows:
228
230
  tool_c = f"{tool:<{w_tool}} " if show_tool else ""
229
231
  lines.append(
230
- f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} "
231
- f"{ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
232
+ f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} {ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
232
233
  )
233
234
  return "\n".join(lines)
234
235
 
@@ -247,7 +248,7 @@ def _format_config_value(value: object) -> str:
247
248
  if value is None:
248
249
  return "—"
249
250
  if isinstance(value, tuple):
250
- return "[" + ", ".join(str(v) for v in value) + "]" if value else _("(內建預設)")
251
+ return f"[{', '.join(str(v) for v in value)}]" if value else _("(內建預設)")
251
252
  if isinstance(value, dict):
252
253
  return ", ".join(f"{k}={v}" for k, v in value.items())
253
254
  return str(value)
@@ -259,8 +260,6 @@ def print_config() -> None:
259
260
  欄位直接從 ``Config`` dataclass 列舉,所以新增設定不必再動這裡。值跟內建預設
260
261
  不同的會標一個箭頭,讓你一眼看出「我改過哪些」。
261
262
  """
262
- from dataclasses import fields
263
-
264
263
  cfg = get_config()
265
264
  defaults = Config()
266
265
  exists = CONFIG_PATH.exists()
@@ -278,13 +277,11 @@ def print_config() -> None:
278
277
  print(f" {f.name:<{width}} {_format_config_value(value)}{mark}")
279
278
  print()
280
279
  hint = _("用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 docstring。")
281
- print(" " + hint)
280
+ print(f" {hint}")
282
281
 
283
282
 
284
283
  def _config_get_value(key: str) -> object:
285
284
  """讀單一設定的目前生效值(支援 colors.<name> 點記法)。未知鍵丟 ConfigError。"""
286
- from dataclasses import fields
287
-
288
285
  cfg = get_config()
289
286
  if "." in key:
290
287
  table, sub = key.split(".", 1)
@@ -296,23 +293,6 @@ def _config_get_value(key: str) -> object:
296
293
  raise ConfigError(_("未知的鍵:{key}", key=key))
297
294
 
298
295
 
299
- def _strip_lang(args: list[str]) -> list[str]:
300
- """濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留 config 自己的位置參數。"""
301
- out: list[str] = []
302
- skip = False
303
- for i, a in enumerate(args):
304
- if skip:
305
- skip = False
306
- continue
307
- if a == "--lang":
308
- skip = i + 1 < len(args) # 連同它的值一起跳過
309
- continue
310
- if a.startswith("--lang="):
311
- continue
312
- out.append(a)
313
- return out
314
-
315
-
316
296
  def run_config(args: list[str]) -> int:
317
297
  """``ring config`` 進入點:無參數→列表;``get KEY``→讀;``set KEY VALUE``→寫。"""
318
298
  args = _strip_lang(args)
@@ -343,158 +323,25 @@ def run_config(args: list[str]) -> int:
343
323
  print(f"⚠️ {e}", file=sys.stderr)
344
324
  return 1
345
325
  print(_("✅ 已設定 {key} = {value}({path})", key=key, value=_format_config_value(coerced), path=CONFIG_PATH))
346
- print(" " + _("註:set 會重寫整個設定檔,原有註解不會保留。"))
326
+ print(f" {_('註:set 會重寫整個設定檔,原有註解不會保留。')}")
347
327
  return 0
348
328
 
349
329
  print(_("未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)", action=action), file=sys.stderr)
350
330
  return 2
351
331
 
352
332
 
353
- def run_doctor(args: list[str]) -> int:
354
- """``ring doctor`` 進入點:唯讀環境診斷,印出五節報告,固定回 0。args 非空回 2。"""
355
- args = _strip_lang(args)
356
- if args:
357
- print(_("用法:ring doctor"), file=sys.stderr)
358
- return 2
359
-
360
- from ring.focus import focusers
361
- from ring.hook import hook_status
362
- from ring.notify import _select_notifier, notifiers
363
- from ring.osascript import osascript
364
-
365
- cfg = get_config()
366
-
367
- print(_("RiNG 環境診斷"))
368
- print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
369
- print()
370
-
371
- # ── (a) Session 來源 ────────────────────────────────────────────────────
372
- print(_("Session 來源"))
373
- src_list = sources()
374
- if src_list:
375
- width_src = max(len(s.name) for s in src_list)
376
- else:
377
- width_src = 10
378
- for src in src_list:
379
- try:
380
- found = src.discover()
381
- n = len(found)
382
- status_str = _("活著")
383
- count_str = _("偵測到 {n} 個 session", n=n)
384
- print(f" {src.name:<{width_src}} {status_str} {count_str}")
385
- except Exception:
386
- print(f" {src.name:<{width_src}} {_('偵測失敗')}")
387
- print()
388
-
389
- # ── (b) Hook 安裝 ────────────────────────────────────────────────────────
390
- print(_("Hook 安裝"))
391
- statuses = hook_status()
392
- provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
393
- if statuses:
394
- width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses)
395
- else:
396
- width_hook = 10
397
- for hs in statuses:
398
- label = provider_labels.get(hs.provider, hs.provider)
399
- if not hs.applicable:
400
- msg = _("未使用 Codex(zero-config)")
401
- elif hs.installed:
402
- msg = _("已安裝")
403
- else:
404
- msg = _("未安裝(執行 ring install-hooks)")
405
- print(f" {label:<{width_hook}} {msg}")
406
- print()
407
-
408
- # ── (c) 通知後端 ─────────────────────────────────────────────────────────
409
- print(_("通知後端"))
410
- print(f" {_('目前設定')}:{cfg.notify_backend}")
411
- notifier_list = notifiers()
412
- if notifier_list:
413
- width_n = max(len(nt.name) for nt in notifier_list)
414
- else:
415
- width_n = 10
416
- for nt in notifier_list:
417
- avail_str = _("可用") if nt.available() else _("不可用")
418
- print(f" {nt.name:<{width_n}} {avail_str}")
419
- selected = _select_notifier(cfg.notify_backend)
420
- if selected is not None:
421
- print(f" {_('auto 實際選中')}:{selected.name}")
422
- if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
423
- print(
424
- " "
425
- + _(
426
- "macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。"
427
- )
428
- )
429
- else:
430
- # 附原因
431
- if cfg.notify_backend == "none":
432
- reason = _("backend=none")
433
- elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
434
- reason = _("agent-hooks 已接手")
435
- else:
436
- reason = _("全部不可用")
437
- print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
438
- print()
439
-
440
- # ── (d) 聚焦終端(focuser)───────────────────────────────────────────────
441
- print(_("聚焦終端(focuser)"))
442
- focuser_list = focusers()
443
- if focuser_list:
444
- width_f = max(len(f.name) for f in focuser_list)
445
- else:
446
- width_f = 10
447
- for f in focuser_list:
448
- name_lower = f.name.lower()
449
- if name_lower == "tmux":
450
- avail = shutil.which("tmux") is not None
451
- avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
452
- else:
453
- # iTerm2 / Terminal:先確認 osascript 在,再問 app 是否跑著
454
- if shutil.which("osascript") is None:
455
- avail_str = _("不可用(osascript 不在 PATH)")
456
- else:
457
- app_name = f.name # "iTerm2" or "Terminal"
458
- try:
459
- rc, out, _err = osascript(f'application "{app_name}" is running')
460
- avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
461
- except Exception:
462
- avail_str = _("不可用(app 沒在跑)")
463
- print(f" {f.name:<{width_f}} {avail_str}")
464
- print()
465
-
466
- # ── (e) 設定檔 ───────────────────────────────────────────────────────────
467
- print(_("設定檔"))
468
- exists = CONFIG_PATH.exists()
469
- print(f" {_('路徑')}:{CONFIG_PATH}")
470
- print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
471
- print(f" {_('完整生效值請看 `ring config`。')}")
472
-
473
- return 0
474
-
475
-
476
333
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
477
- from ring.notify import notify_waiting
478
- from ring.watcher import WaitingAlertScheduler
479
-
480
- cfg = get_config()
334
+ # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
335
+ # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
481
336
  frames = 0
482
337
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
483
338
  if not HAVE_RICH:
484
- scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
485
339
  try:
486
340
  while True:
487
341
  sys.stdout.write("\033[2J\033[H")
488
342
  sessions = board(show_all)
489
- alerts = scheduler.feed(sessions)
490
- try:
491
- hint = notify_waiting(alerts)
492
- if hint:
493
- print(hint)
494
- except Exception:
495
- pass
496
343
  print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
497
- print("\n" + footer_text)
344
+ print(f"\n{footer_text}")
498
345
  sys.stdout.flush()
499
346
  frames += 1
500
347
  if count and frames >= count:
@@ -504,20 +351,12 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
504
351
  return 0
505
352
 
506
353
  console = Console()
507
- scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
508
354
  try:
509
355
  with Live(console=console, screen=True, auto_refresh=False) as live:
510
356
  while True:
511
357
  sessions = board(show_all)
512
- alerts = scheduler.feed(sessions)
513
- try:
514
- hint = notify_waiting(alerts)
515
- if hint:
516
- print(hint)
517
- except Exception:
518
- pass
519
358
  body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
520
- live.update(Group(body, Text("\n" + footer_text, style=_MUTED)), refresh=True)
359
+ live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
521
360
  frames += 1
522
361
  if count and frames >= count:
523
362
  return 0
@@ -548,7 +387,8 @@ commands:
548
387
  config get KEY 讀單一設定的目前值
549
388
  config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
550
389
  focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
551
- doctor 顯示環境診斷(唯讀)——hook 安裝狀態、通知後端、focuser 可用性
390
+ gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
391
+ doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
552
392
  """
553
393
  )
554
394
 
@@ -585,12 +425,23 @@ def _subcommand_help(name: str) -> str:
585
425
  """usage: ring focus SESSION_ID
586
426
 
587
427
  聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。
428
+ """
429
+ ),
430
+ "gc": _(
431
+ """usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]
432
+
433
+ 清理 RiNG 自己的 stale 狀態檔;不碰 Claude Code / Codex 的 transcript 或 state。
434
+
435
+ options:
436
+ --dry-run 只預覽,不刪檔
437
+ --older-than DURATION 清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)
438
+ --all-ended 清理所有目前判定已離場的 registry
588
439
  """
589
440
  ),
590
441
  "doctor": _(
591
442
  """usage: ring doctor
592
443
 
593
- 唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性與設定檔位置。
444
+ 唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性、維護提示與設定檔位置。
594
445
  不寫任何檔案、不安裝、不發通知;固定回傳 0。
595
446
  """
596
447
  ),
@@ -603,53 +454,28 @@ def main(argv: list[str] | None = None) -> int:
603
454
  cfg = get_config()
604
455
  set_lang(_peek_lang(raw) or cfg.lang) # 在 import ring.tui 前設好,Footer 按鍵說明也跟著語言
605
456
 
606
- if raw and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "doctor"} and any(
607
- arg in {"-h", "--help"} for arg in raw[1:]
457
+ if (
458
+ raw
459
+ and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "gc", "doctor"}
460
+ and any(arg in {"-h", "--help"} for arg in raw[1:])
608
461
  ):
609
462
  print(_subcommand_help(raw[0]), end="")
610
463
  return 0
611
464
 
612
465
  if raw and raw[0] == "hook":
613
- from ring.hook import run_hook
614
-
615
- provider = "claude-code"
616
- hook_args = raw[1:]
617
- if hook_args:
618
- if hook_args[0] == "--provider" and len(hook_args) >= 2:
619
- provider = hook_args[1]
620
- elif hook_args[0].startswith("--provider="):
621
- provider = hook_args[0].split("=", 1)[1]
622
- elif not hook_args[0].startswith("-"):
623
- provider = hook_args[0]
624
- return run_hook(provider=provider)
466
+ return run_hook_command(raw[1:])
625
467
  if raw and raw[0] == "install-hooks":
626
- from ring.hook import install_hooks
627
-
628
- return install_hooks(dry_run="--dry-run" in raw)
468
+ return run_install_hooks(raw[1:])
629
469
  if raw and raw[0] == "remove-hooks":
630
- from ring.hook import uninstall_hooks
631
-
632
- return uninstall_hooks(dry_run="--dry-run" in raw)
470
+ return run_remove_hooks(raw[1:])
633
471
  if raw and raw[0] == "config":
634
472
  return run_config(raw[1:])
473
+ if raw and raw[0] == "gc":
474
+ return run_gc(raw[1:])
635
475
  if raw and raw[0] == "doctor":
636
476
  return run_doctor(raw[1:])
637
477
  if raw and raw[0] == "focus" and len(raw) >= 2:
638
- from ring.focus import jump as focus_jump
639
- from ring.ipc import read_tui_presence, write_focus_request
640
- from ring.sources import get_by_id
641
-
642
- session = get_by_id(raw[1])
643
- if session is None:
644
- return 0
645
- presence = read_tui_presence()
646
- if presence is not None:
647
- # TUI 在跑:寫 focus-request,讓 TUI 自己移游標並 activate 視窗。
648
- write_focus_request(raw[1])
649
- else:
650
- # headless(沒有 TUI 在跑):退化回現行行為——直接跳到 claude 所在終端。
651
- focus_jump(session)
652
- return 0
478
+ return run_focus(raw[1:])
653
479
 
654
480
  parser = argparse.ArgumentParser(
655
481
  prog="ring",
@@ -0,0 +1 @@
1
+ """CLI 子命令 handlers。"""
@@ -0,0 +1,20 @@
1
+ """CLI argument helpers shared by subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def strip_lang(args: list[str]) -> list[str]:
7
+ """濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留子命令自己的參數。"""
8
+ out: list[str] = []
9
+ skip = False
10
+ for i, a in enumerate(args):
11
+ if skip:
12
+ skip = False
13
+ continue
14
+ if a == "--lang":
15
+ skip = i + 1 < len(args)
16
+ continue
17
+ if a.startswith("--lang="):
18
+ continue
19
+ out.append(a)
20
+ return out
@@ -0,0 +1,122 @@
1
+ """``ring doctor`` command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+
8
+ from ring.commands._args import strip_lang
9
+ from ring.config import CONFIG_PATH, get_config
10
+ from ring.gc import DEFAULT_OLDER_THAN_SECONDS
11
+ from ring.gc import collect_candidates as gc_collect_candidates
12
+ from ring.i18n import gettext as _
13
+ from ring.sources import sources
14
+
15
+
16
+ def run_doctor(args: list[str]) -> int:
17
+ """唯讀環境診斷,印出各節報告,固定回 0。args 非空回 2。"""
18
+ args = strip_lang(args)
19
+ if args:
20
+ print(_("用法:ring doctor"), file=sys.stderr)
21
+ return 2
22
+
23
+ from ring.focus import focusers
24
+ from ring.hook import hook_status
25
+ from ring.notify import _select_notifier, notifiers
26
+ from ring.osascript import osascript
27
+
28
+ cfg = get_config()
29
+
30
+ print(_("RiNG 環境診斷"))
31
+ print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
32
+ print()
33
+
34
+ print(_("Session 來源"))
35
+ src_list = sources()
36
+ width_src = max(len(s.name) for s in src_list) if src_list else 10
37
+ for src in src_list:
38
+ try:
39
+ found = src.discover()
40
+ n = len(found)
41
+ status_str = _("活著")
42
+ count_str = _("偵測到 {n} 個 session", n=n)
43
+ print(f" {src.name:<{width_src}} {status_str} {count_str}")
44
+ except Exception:
45
+ print(f" {src.name:<{width_src}} {_('偵測失敗')}")
46
+ print()
47
+
48
+ print(_("Hook 安裝"))
49
+ statuses = hook_status()
50
+ provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
51
+ width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses) if statuses else 10
52
+ for hs in statuses:
53
+ label = provider_labels.get(hs.provider, hs.provider)
54
+ if not hs.applicable:
55
+ msg = _("未使用 Codex(zero-config)")
56
+ elif hs.installed:
57
+ msg = _("已安裝")
58
+ else:
59
+ msg = _("未安裝(執行 ring install-hooks)")
60
+ print(f" {label:<{width_hook}} {msg}")
61
+ print()
62
+
63
+ print(_("通知後端"))
64
+ print(f" {_('目前設定')}:{cfg.notify_backend}")
65
+ notifier_list = notifiers()
66
+ width_n = max(len(nt.name) for nt in notifier_list) if notifier_list else 10
67
+ for nt in notifier_list:
68
+ avail_str = _("可用") if nt.available() else _("不可用")
69
+ print(f" {nt.name:<{width_n}} {avail_str}")
70
+ selected = _select_notifier(cfg.notify_backend)
71
+ if selected is not None:
72
+ print(f" {_('auto 實際選中')}:{selected.name}")
73
+ if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
74
+ print(f" {_('macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。')}")
75
+ else:
76
+ if cfg.notify_backend == "none":
77
+ reason = _("backend=none")
78
+ elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
79
+ reason = _("agent-hooks 已接手")
80
+ else:
81
+ reason = _("全部不可用")
82
+ print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
83
+ print()
84
+
85
+ print(_("聚焦終端(focuser)"))
86
+ focuser_list = focusers()
87
+ width_f = max(len(f.name) for f in focuser_list) if focuser_list else 10
88
+ for f in focuser_list:
89
+ name_lower = f.name.lower()
90
+ if name_lower == "tmux":
91
+ avail = shutil.which("tmux") is not None
92
+ avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
93
+ elif shutil.which("osascript") is None:
94
+ avail_str = _("不可用(osascript 不在 PATH)")
95
+ else:
96
+ app_name = f.name
97
+ try:
98
+ rc, out, _err = osascript(f'application "{app_name}" is running')
99
+ avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
100
+ except Exception:
101
+ avail_str = _("不可用(app 沒在跑)")
102
+ print(f" {f.name:<{width_f}} {avail_str}")
103
+ print()
104
+
105
+ print(_("維護"))
106
+ try:
107
+ candidates = gc_collect_candidates(older_than=DEFAULT_OLDER_THAN_SECONDS)
108
+ if candidates:
109
+ print(f" {_('可清理')}:{_('{n} 個 RiNG stale 狀態檔(執行 ring gc --dry-run 預覽)', n=len(candidates))}")
110
+ else:
111
+ print(f" {_('可清理')}:{_('沒有 RiNG stale 狀態檔')}")
112
+ except Exception:
113
+ print(f" {_('可清理')}:{_('偵測失敗')}")
114
+ print()
115
+
116
+ print(_("設定檔"))
117
+ exists = CONFIG_PATH.exists()
118
+ print(f" {_('路徑')}:{CONFIG_PATH}")
119
+ print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
120
+ print(f" {_('完整生效值請看 `ring config`。')}")
121
+
122
+ return 0
@@ -0,0 +1,23 @@
1
+ """``ring focus`` command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def run_focus(args: list[str]) -> int:
7
+ if len(args) < 1:
8
+ return 0
9
+
10
+ from ring.focus import jump as focus_jump
11
+ from ring.ipc import read_tui_presence, write_focus_request
12
+ from ring.sources import get_by_id
13
+
14
+ session_id = args[0]
15
+ session = get_by_id(session_id)
16
+ if session is None:
17
+ return 0
18
+ presence = read_tui_presence()
19
+ if presence is not None:
20
+ write_focus_request(session_id)
21
+ else:
22
+ focus_jump(session)
23
+ return 0
@@ -0,0 +1,46 @@
1
+ """``ring gc`` command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from ring.commands._args import strip_lang
9
+ from ring.gc import DEFAULT_OLDER_THAN_SECONDS, parse_duration
10
+ from ring.gc import run_gc as gc_run
11
+ from ring.i18n import gettext as _
12
+
13
+
14
+ def run_gc(args: list[str]) -> int:
15
+ """清理 RiNG 自己的 stale 狀態檔。"""
16
+ args = strip_lang(args)
17
+
18
+ parser = argparse.ArgumentParser(prog="ring gc", description=_("清理 RiNG 自己的 stale 狀態檔。"))
19
+ parser.add_argument("--dry-run", action="store_true", help=_("只預覽,不刪檔"))
20
+ parser.add_argument(
21
+ "--older-than",
22
+ default="7d",
23
+ metavar="DURATION",
24
+ help=_("清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)"),
25
+ )
26
+ parser.add_argument("--all-ended", action="store_true", help=_("清理所有目前判定已離場的 registry"))
27
+ try:
28
+ ns = parser.parse_args(args)
29
+ older_than = parse_duration(ns.older_than) if ns.older_than else DEFAULT_OLDER_THAN_SECONDS
30
+ except SystemExit as e:
31
+ return e.code if isinstance(e.code, int) else 2
32
+ except ValueError:
33
+ print(_("無效的 --older-than:{value}", value=ns.older_than), file=sys.stderr)
34
+ return 2
35
+
36
+ result = gc_run(older_than=older_than, all_ended=ns.all_ended, dry_run=ns.dry_run)
37
+ action = _("將刪除") if result.dry_run else _("已刪除")
38
+ count = len(result.candidates) if result.dry_run else len(result.deleted)
39
+ print(_("RiNG GC:{action} {count} 個檔案", action=action, count=count))
40
+ shown = result.candidates if result.dry_run else result.deleted
41
+ for candidate in shown:
42
+ prefix = " - " if result.dry_run else " ✓ "
43
+ print(f"{prefix}{candidate.path} ({candidate.reason})")
44
+ for candidate, error in result.errors:
45
+ print(f" ! {candidate.path} ({error})", file=sys.stderr)
46
+ return 1 if result.errors else 0
@@ -0,0 +1,29 @@
1
+ """Hook-related CLI command handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def run_hook_command(args: list[str]) -> int:
7
+ from ring.hook import run_hook
8
+
9
+ provider = "claude-code"
10
+ if args:
11
+ if args[0] == "--provider" and len(args) >= 2:
12
+ provider = args[1]
13
+ elif args[0].startswith("--provider="):
14
+ provider = args[0].split("=", 1)[1]
15
+ elif not args[0].startswith("-"):
16
+ provider = args[0]
17
+ return run_hook(provider=provider)
18
+
19
+
20
+ def run_install_hooks(args: list[str]) -> int:
21
+ from ring.hook import install_hooks
22
+
23
+ return install_hooks(dry_run="--dry-run" in args)
24
+
25
+
26
+ def run_remove_hooks(args: list[str]) -> int:
27
+ from ring.hook import uninstall_hooks
28
+
29
+ return uninstall_hooks(dry_run="--dry-run" in args)
@@ -0,0 +1,152 @@
1
+ """RiNG 自有狀態檔清理。
2
+
3
+ 只清 RiNG 寫在 ``~/.config/ring/`` 底下的檔案;不碰 Claude Code / Codex 的
4
+ transcript、SQLite state 或任何 provider 原始資料。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from ring.ipc import _FOCUS_REQUEST_PATH, _PRESENCE_PATH, _PRESENCE_TTL, _REQUEST_TTL
15
+ from ring.registry import (
16
+ _SESSION_START_SOURCES,
17
+ RING_REGISTRY,
18
+ Status,
19
+ _hook_sessions,
20
+ collect_provider_procs,
21
+ )
22
+
23
+ DEFAULT_OLDER_THAN_SECONDS = 7 * 24 * 60 * 60
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class GcCandidate:
28
+ path: Path
29
+ reason: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class GcResult:
34
+ candidates: list[GcCandidate]
35
+ deleted: list[GcCandidate]
36
+ errors: list[tuple[GcCandidate, str]]
37
+ dry_run: bool
38
+
39
+
40
+ def parse_duration(raw: str) -> float:
41
+ """解析簡單 duration:``30s``、``10m``、``2h``、``7d``,無單位視為秒。"""
42
+ text = raw.strip().lower()
43
+ if not text:
44
+ raise ValueError("empty duration")
45
+ unit = text[-1]
46
+ if unit in {"s", "m", "h", "d"}:
47
+ number = text[:-1]
48
+ factor = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
49
+ else:
50
+ number = text
51
+ factor = 1
52
+ seconds = float(number) * factor
53
+ if seconds < 0:
54
+ raise ValueError("negative duration")
55
+ return seconds
56
+
57
+
58
+ def collect_candidates(*, older_than: float, all_ended: bool = False, now: float | None = None) -> list[GcCandidate]:
59
+ """收集可清理的 RiNG 自有狀態檔。"""
60
+ current = time.time() if now is None else now
61
+ candidates: dict[Path, GcCandidate] = {}
62
+
63
+ for candidate in _registry_candidates(older_than=older_than, all_ended=all_ended, now=current):
64
+ candidates[candidate.path] = candidate
65
+ for candidate in _ipc_candidates(now=current):
66
+ candidates[candidate.path] = candidate
67
+
68
+ return sorted(candidates.values(), key=lambda c: str(c.path))
69
+
70
+
71
+ def run_gc(
72
+ *, older_than: float = DEFAULT_OLDER_THAN_SECONDS, all_ended: bool = False, dry_run: bool = False
73
+ ) -> GcResult:
74
+ candidates = collect_candidates(older_than=older_than, all_ended=all_ended)
75
+ deleted: list[GcCandidate] = []
76
+ errors: list[tuple[GcCandidate, str]] = []
77
+ if dry_run:
78
+ return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=True)
79
+
80
+ for candidate in candidates:
81
+ try:
82
+ candidate.path.unlink(missing_ok=True)
83
+ deleted.append(candidate)
84
+ except OSError as e:
85
+ errors.append((candidate, str(e)))
86
+ return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=False)
87
+
88
+
89
+ def _registry_candidates(*, older_than: float, all_ended: bool, now: float) -> list[GcCandidate]:
90
+ if not RING_REGISTRY.is_dir():
91
+ return []
92
+
93
+ candidates: list[GcCandidate] = []
94
+ sessions = _hook_sessions(
95
+ procs_by_provider=collect_provider_procs(),
96
+ purge_session_start_phantoms=False,
97
+ )
98
+ ended = {s.session_id: s for s in sessions if s.status is Status.ENDED}
99
+
100
+ for path in RING_REGISTRY.glob("*.json"):
101
+ try:
102
+ data = json.loads(path.read_text(encoding="utf-8"))
103
+ except (OSError, json.JSONDecodeError):
104
+ candidates.append(GcCandidate(path, "registry JSON 無法讀取"))
105
+ continue
106
+
107
+ provider = str(data.get("provider", "claude-code") or "claude-code")
108
+ if provider in _SESSION_START_SOURCES:
109
+ candidates.append(GcCandidate(path, "舊版 SessionStart 幽靈檔"))
110
+ continue
111
+
112
+ sid = str(data.get("session_id", ""))
113
+ session = ended.get(sid)
114
+ if session is None:
115
+ continue
116
+
117
+ last_active = _last_active(data)
118
+ if all_ended or now - last_active >= older_than:
119
+ candidates.append(GcCandidate(path, "已離場 registry"))
120
+
121
+ return candidates
122
+
123
+
124
+ def _ipc_candidates(*, now: float) -> list[GcCandidate]:
125
+ candidates: list[GcCandidate] = []
126
+ candidates.extend(_stale_json_file(_FOCUS_REQUEST_PATH, ttl=_REQUEST_TTL, now=now, label="focus-request"))
127
+ candidates.extend(_stale_json_file(_PRESENCE_PATH, ttl=_PRESENCE_TTL, now=now, label="tui-presence"))
128
+ return candidates
129
+
130
+
131
+ def _stale_json_file(path: Path, *, ttl: float, now: float, label: str) -> list[GcCandidate]:
132
+ try:
133
+ raw = path.read_text(encoding="utf-8")
134
+ except OSError:
135
+ return []
136
+ try:
137
+ data = json.loads(raw)
138
+ ts = float(data["ts"])
139
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
140
+ return [GcCandidate(path, f"{label} JSON 無法讀取")]
141
+ if now - ts > ttl:
142
+ return [GcCandidate(path, f"{label} 已過期")]
143
+ return []
144
+
145
+
146
+ def _last_active(data: object) -> float:
147
+ if not isinstance(data, dict):
148
+ return 0.0
149
+ try:
150
+ return float(data.get("last_active", 0.0))
151
+ except (TypeError, ValueError):
152
+ return 0.0
@@ -33,6 +33,7 @@ from ring.i18n import gettext as _
33
33
  from ring.i18n import set_lang
34
34
  from ring.registry import (
35
35
  RING_REGISTRY,
36
+ Session,
36
37
  Status,
37
38
  _extract_todo,
38
39
  _latest_action,
@@ -127,11 +128,13 @@ def _session_tty(process_names: tuple[str, ...]) -> str:
127
128
 
128
129
 
129
130
  def run_hook(provider: str = "claude-code") -> int:
130
- """讀一次 stdin → 寫 RiNG registry 狀態(看板)→(可選)把決策委派給 agent-hooks。
131
+ """讀一次 stdin → 寫 RiNG registry 狀態(看板)→ 轉等你時就地發通知 →(可選)委派 agent-hooks。
131
132
 
132
- 委派只在 ``notify_backend == "agent-hooks"`` 且 PATH 上有 ``agent-hooks`` 時發生:
133
- 把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、收按鈕、把決策
134
- 寫到 stdout 回給 Claude。其餘情況 RiNG 只記狀態、exit 0(你在終端自己回答)。
133
+ 🔴 等你的事件,會在當下直接發系統通知(見 ``_ring_waiting_now``)——不必開著看板,
134
+ 關掉終端也照樣 ring 你。委派只在 ``notify_backend == "agent-hooks"`` PATH 上有
135
+ ``agent-hooks`` 時發生:把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、
136
+ 收按鈕、把決策寫到 stdout 回給 Claude(這條路下 ``_ring_waiting_now`` 自動短路、不重複發)。
137
+ 其餘情況 RiNG 記狀態 + 發通知後 exit 0(你在終端自己回答)。
135
138
  """
136
139
  try:
137
140
  raw = sys.stdin.read()
@@ -191,6 +194,40 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
191
194
  tmp.write_text(json.dumps(payload))
192
195
  tmp.replace(path) # atomic
193
196
 
197
+ if event.status is Status.WAITING:
198
+ _ring_waiting_now(event, payload, last_action)
199
+
200
+
201
+ def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
202
+ """session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。
203
+
204
+ WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」
205
+ 的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
206
+ 你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
207
+ (agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。
208
+ 失敗安靜吞掉,絕不擋住 session。
209
+ """
210
+ try:
211
+ from ring.notify import notify_waiting
212
+
213
+ notify_waiting(
214
+ [
215
+ Session(
216
+ session_id=event.session_id,
217
+ cwd=event.cwd,
218
+ status=Status.WAITING,
219
+ last_active=float(payload.get("last_active", time.time())),
220
+ last_action=last_action,
221
+ source="hook",
222
+ tty=payload.get("tty"),
223
+ provider=event.provider,
224
+ origin_cwd=event.cwd,
225
+ )
226
+ ]
227
+ )
228
+ except Exception:
229
+ pass
230
+
194
231
 
195
232
  def _delegate_to_agent_hooks(raw: str, selected_provider: str) -> int:
196
233
  """notify_backend=="agent-hooks" 且 agent-hooks 在 PATH → 透傳 payload 給它出決策。
@@ -590,7 +590,7 @@ def _codex_threads(procs: list[tuple[str, str]]) -> list[Session]:
590
590
  continue
591
591
  updated_ms = int(row["updated_at_ms"] or 0)
592
592
  last_active = updated_ms / 1000 if updated_ms else float(row["updated_at"] or 0)
593
- if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(cwd, 0) == 0:
593
+ if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(_real(cwd), 0) == 0:
594
594
  continue
595
595
  rollout_path = Path(str(row["rollout_path"] or ""))
596
596
  records = _tail_records(rollout_path) if rollout_path else []
@@ -771,6 +771,7 @@ def _hook_sessions(
771
771
  procs: list[tuple[str, str]] | None = None,
772
772
  *,
773
773
  procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
774
+ purge_session_start_phantoms: bool = True,
774
775
  ) -> list[Session]:
775
776
  if not RING_REGISTRY.is_dir():
776
777
  return []
@@ -786,7 +787,8 @@ def _hook_sessions(
786
787
  if provider in _SESSION_START_SOURCES:
787
788
  # 舊版 bug 把 SessionStart 的 source(startup/resume/clear/compact)誤當
788
789
  # provider,留下無 tty、跳不過去、又永不離場的幽靈列。清掉這種腐壞檔,自我修復。
789
- f.unlink(missing_ok=True)
790
+ if purge_session_start_phantoms:
791
+ f.unlink(missing_ok=True)
790
792
  continue
791
793
  out.append(
792
794
  Session(
@@ -36,7 +36,6 @@ from ring.i18n import gettext as _
36
36
  from ring.i18n import set_lang
37
37
  from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence
38
38
  from ring.labels import get_label, load_labels, set_label
39
- from ring.notify import notify_waiting
40
39
  from ring.registry import Session, Status, running_agent_pids
41
40
  from ring.watcher import WaitingAlertScheduler
42
41
 
@@ -280,14 +279,10 @@ class RingApp(App[None]):
280
279
  cur = next((s for s in self._sessions if s.session_id == self._focused_sid), None)
281
280
  if cur is None or cur.status is not Status.WAITING:
282
281
  self._focused_sid = None
282
+ # 系統通知(toast)改由 ``ring hook`` 在事件當下發出(見 hook._ring_waiting_now);
283
+ # 這裡只留 TUI 自己的 in-app 響鈴 / 訊息列與醒目標記,不重複發系統通知。
283
284
  alerts = self._alerts.feed(self._sessions)
284
285
  self._ring_on_waiting_alerts(alerts)
285
- try:
286
- hint = notify_waiting(alerts)
287
- if hint:
288
- self.notify(hint, timeout=10)
289
- except Exception:
290
- pass
291
286
  self.sub_title = _header(len(self._sessions), len(running_agent_pids()))
292
287
  labels = load_labels()
293
288
  table.clear()
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