ring-cli 0.2.0__tar.gz → 0.3.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.3.0}/PKG-INFO +24 -1
  2. {ring_cli-0.2.0 → ring_cli-0.3.0}/README.en.md +23 -0
  3. {ring_cli-0.2.0 → ring_cli-0.3.0}/pyproject.toml +1 -1
  4. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/cli.py +46 -202
  5. ring_cli-0.3.0/src/ring/commands/__init__.py +1 -0
  6. ring_cli-0.3.0/src/ring/commands/_args.py +20 -0
  7. ring_cli-0.3.0/src/ring/commands/doctor.py +122 -0
  8. ring_cli-0.3.0/src/ring/commands/focus.py +23 -0
  9. ring_cli-0.3.0/src/ring/commands/gc.py +46 -0
  10. ring_cli-0.3.0/src/ring/commands/hook.py +29 -0
  11. ring_cli-0.3.0/src/ring/gc.py +152 -0
  12. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/registry.py +3 -1
  13. {ring_cli-0.2.0 → ring_cli-0.3.0}/LICENSE +0 -0
  14. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/__init__.py +0 -0
  15. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/__main__.py +0 -0
  16. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/config.py +0 -0
  17. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/__init__.py +0 -0
  18. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/applescript.py +0 -0
  19. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/base.py +0 -0
  20. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/iterm2.py +0 -0
  21. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/terminal.py +0 -0
  22. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/tmux.py +0 -0
  23. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/hook.py +0 -0
  24. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/hook_protocol.py +0 -0
  25. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/i18n.py +0 -0
  26. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/ipc.py +0 -0
  27. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/labels.py +0 -0
  28. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  29. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  30. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/ring.pot +0 -0
  31. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/__init__.py +0 -0
  32. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/base.py +0 -0
  33. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/command.py +0 -0
  34. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/notify_send.py +0 -0
  35. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/osascript_notifier.py +0 -0
  36. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/terminal_notifier.py +0 -0
  37. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/osascript.py +0 -0
  38. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/__init__.py +0 -0
  39. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/base.py +0 -0
  40. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/claude_code.py +0 -0
  41. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/codex.py +0 -0
  42. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/hook_registry.py +0 -0
  43. {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/tui.py +0 -0
  44. {ring_cli-0.2.0 → ring_cli-0.3.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.3.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
 
@@ -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
 
@@ -145,6 +152,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
145
152
  Notification sound, repeat timing, and backend selection are configurable; notification backends
146
153
  are also pluggable via the `Notifier` extension point.
147
154
 
155
+ ### Cleaning RiNG State Files (`ring gc`)
156
+
157
+ When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
158
+ final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
159
+ the board by default, and you can remove them with `ring gc`.
160
+
161
+ ```sh
162
+ ring gc --dry-run # preview what would be deleted
163
+ ring gc # delete ended registry files older than 7 days, plus expired IPC files
164
+ ring gc --older-than 1d # use a 1-day threshold
165
+ ring gc --all-ended # delete every registry file currently classified as ended
166
+ ```
167
+
168
+ `ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
169
+ Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
170
+
148
171
  ## Session Sources
149
172
 
150
173
  RiNG collects sessions from registered sources. Built-ins:
@@ -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
 
@@ -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
 
@@ -120,6 +127,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
120
127
  Notification sound, repeat timing, and backend selection are configurable; notification backends
121
128
  are also pluggable via the `Notifier` extension point.
122
129
 
130
+ ### Cleaning RiNG State Files (`ring gc`)
131
+
132
+ When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
133
+ final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
134
+ the board by default, and you can remove them with `ring gc`.
135
+
136
+ ```sh
137
+ ring gc --dry-run # preview what would be deleted
138
+ ring gc # delete ended registry files older than 7 days, plus expired IPC files
139
+ ring gc --older-than 1d # use a 1-day threshold
140
+ ring gc --all-ended # delete every registry file currently classified as ended
141
+ ```
142
+
143
+ `ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
144
+ Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
145
+
123
146
  ## Session Sources
124
147
 
125
148
  RiNG collects sessions from registered sources. Built-ins:
@@ -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.3.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,136 +323,13 @@ 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
334
  from ring.notify import notify_waiting
478
335
  from ring.watcher import WaitingAlertScheduler
@@ -494,7 +351,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
494
351
  except Exception:
495
352
  pass
496
353
  print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
497
- print("\n" + footer_text)
354
+ print(f"\n{footer_text}")
498
355
  sys.stdout.flush()
499
356
  frames += 1
500
357
  if count and frames >= count:
@@ -517,7 +374,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
517
374
  except Exception:
518
375
  pass
519
376
  body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
520
- live.update(Group(body, Text("\n" + footer_text, style=_MUTED)), refresh=True)
377
+ live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
521
378
  frames += 1
522
379
  if count and frames >= count:
523
380
  return 0
@@ -548,7 +405,8 @@ commands:
548
405
  config get KEY 讀單一設定的目前值
549
406
  config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
550
407
  focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
551
- doctor 顯示環境診斷(唯讀)——hook 安裝狀態、通知後端、focuser 可用性
408
+ gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
409
+ doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
552
410
  """
553
411
  )
554
412
 
@@ -585,12 +443,23 @@ def _subcommand_help(name: str) -> str:
585
443
  """usage: ring focus SESSION_ID
586
444
 
587
445
  聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。
446
+ """
447
+ ),
448
+ "gc": _(
449
+ """usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]
450
+
451
+ 清理 RiNG 自己的 stale 狀態檔;不碰 Claude Code / Codex 的 transcript 或 state。
452
+
453
+ options:
454
+ --dry-run 只預覽,不刪檔
455
+ --older-than DURATION 清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)
456
+ --all-ended 清理所有目前判定已離場的 registry
588
457
  """
589
458
  ),
590
459
  "doctor": _(
591
460
  """usage: ring doctor
592
461
 
593
- 唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性與設定檔位置。
462
+ 唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性、維護提示與設定檔位置。
594
463
  不寫任何檔案、不安裝、不發通知;固定回傳 0。
595
464
  """
596
465
  ),
@@ -603,53 +472,28 @@ def main(argv: list[str] | None = None) -> int:
603
472
  cfg = get_config()
604
473
  set_lang(_peek_lang(raw) or cfg.lang) # 在 import ring.tui 前設好,Footer 按鍵說明也跟著語言
605
474
 
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:]
475
+ if (
476
+ raw
477
+ and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "gc", "doctor"}
478
+ and any(arg in {"-h", "--help"} for arg in raw[1:])
608
479
  ):
609
480
  print(_subcommand_help(raw[0]), end="")
610
481
  return 0
611
482
 
612
483
  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)
484
+ return run_hook_command(raw[1:])
625
485
  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)
486
+ return run_install_hooks(raw[1:])
629
487
  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)
488
+ return run_remove_hooks(raw[1:])
633
489
  if raw and raw[0] == "config":
634
490
  return run_config(raw[1:])
491
+ if raw and raw[0] == "gc":
492
+ return run_gc(raw[1:])
635
493
  if raw and raw[0] == "doctor":
636
494
  return run_doctor(raw[1:])
637
495
  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
496
+ return run_focus(raw[1:])
653
497
 
654
498
  parser = argparse.ArgumentParser(
655
499
  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
@@ -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(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes