ring-cli 0.11.1__tar.gz → 0.11.2__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 (56) hide show
  1. {ring_cli-0.11.1 → ring_cli-0.11.2}/PKG-INFO +4 -1
  2. {ring_cli-0.11.1 → ring_cli-0.11.2}/README.en.md +3 -0
  3. {ring_cli-0.11.1 → ring_cli-0.11.2}/pyproject.toml +1 -1
  4. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/registry.py +47 -7
  5. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/sources/__init__.py +5 -0
  6. ring_cli-0.11.2/src/ring/sources/claude_code.py +46 -0
  7. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/tui.py +29 -0
  8. ring_cli-0.11.1/src/ring/sources/claude_code.py +0 -23
  9. {ring_cli-0.11.1 → ring_cli-0.11.2}/LICENSE +0 -0
  10. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/__init__.py +0 -0
  11. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/__main__.py +0 -0
  12. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/cli.py +0 -0
  13. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/__init__.py +0 -0
  14. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/_args.py +0 -0
  15. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/completion.py +0 -0
  16. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/digest.py +0 -0
  17. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/doctor.py +0 -0
  18. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/focus.py +0 -0
  19. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/gc.py +0 -0
  20. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/hook.py +0 -0
  21. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/commands/stats.py +0 -0
  22. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/config.py +0 -0
  23. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/__init__.py +0 -0
  24. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/applescript.py +0 -0
  25. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/base.py +0 -0
  26. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/iterm2.py +0 -0
  27. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/linux_wm.py +0 -0
  28. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/neovim.py +0 -0
  29. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/terminal.py +0 -0
  30. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/focus/tmux.py +0 -0
  31. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/gc.py +0 -0
  32. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/hook.py +0 -0
  33. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/hook_protocol.py +0 -0
  34. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/i18n.py +0 -0
  35. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/ipc.py +0 -0
  36. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/labels.py +0 -0
  37. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  38. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  39. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/locale/ring.pot +0 -0
  40. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/__init__.py +0 -0
  41. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/base.py +0 -0
  42. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/command.py +0 -0
  43. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/notify_send.py +0 -0
  44. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/ntfy.py +0 -0
  45. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/osascript_notifier.py +0 -0
  46. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/terminal_notifier.py +0 -0
  47. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/notify/webhook.py +0 -0
  48. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/osascript.py +0 -0
  49. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/permission.py +0 -0
  50. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/plugins.py +0 -0
  51. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/sources/base.py +0 -0
  52. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/sources/codex.py +0 -0
  53. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/sources/hook_registry.py +0 -0
  54. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/stats.py +0 -0
  55. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/transcript.py +0 -0
  56. {ring_cli-0.11.1 → ring_cli-0.11.2}/src/ring/watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.11.1
3
+ Version: 0.11.2
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
@@ -157,6 +157,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
157
157
  Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
158
158
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
159
159
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
160
+ Claude Code background agents carry a `⚙` badge. They have no terminal to jump to, so selecting one shows a `claude --resume` hint; completed agents are folded into ended sessions by default and remain available via `a`.
160
161
  - Otherwise: Rich polling; without Rich, plain text.
161
162
 
162
163
  ### Jump To A Session
@@ -181,6 +182,8 @@ ones with a "from the … agent" header), and lists the numbered options verbati
181
182
  After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
182
183
  unchanged (it may have been answered while you were deciding), only then sends that single
183
184
  digit, and re-checks that the dialog actually disappeared.
185
+ Once the reply is verified, the TUI immediately clears that waiting revision. Only a newer hook
186
+ event, such as another permission request, can mark the session as waiting again.
184
187
 
185
188
  - **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
186
189
  `tmux send-keys`.
@@ -132,6 +132,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
132
132
  Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
133
133
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
134
134
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
135
+ Claude Code background agents carry a `⚙` badge. They have no terminal to jump to, so selecting one shows a `claude --resume` hint; completed agents are folded into ended sessions by default and remain available via `a`.
135
136
  - Otherwise: Rich polling; without Rich, plain text.
136
137
 
137
138
  ### Jump To A Session
@@ -156,6 +157,8 @@ ones with a "from the … agent" header), and lists the numbered options verbati
156
157
  After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
157
158
  unchanged (it may have been answered while you were deciding), only then sends that single
158
159
  digit, and re-checks that the dialog actually disappeared.
160
+ Once the reply is verified, the TUI immediately clears that waiting revision. Only a newer hook
161
+ event, such as another permission request, can mark the session as waiting again.
159
162
 
160
163
  - **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
161
164
  `tmux send-keys`.
@@ -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.11.1"
5
+ version = "0.11.2"
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"
@@ -633,6 +633,24 @@ def running_claude_pids() -> list[int]:
633
633
  return pids
634
634
 
635
635
 
636
+ def running_foreground_claude_pids() -> list[int]:
637
+ """目前仍有可聚焦終端的 Claude session pid。
638
+
639
+ ``running_claude_pids`` 也包含 agents mode 的背景 session,因為 scan 需要靠它們
640
+ 找到對應 transcript;但 hook registry 不能拿背景 agent 的 cwd/數量替同專案的
641
+ 舊前景 row 證明存活,否則一個 agent 就可能讓 crash 數小時的 session 繼續顯示。
642
+ """
643
+ bg_ids = background_agent_session_ids()
644
+ if not bg_ids:
645
+ return running_claude_pids()
646
+ args_by_pid = {pid: args for pid, args, _is_bg_host in _parse_ps_claude_lines(_ps_claude_snapshot())}
647
+ return [
648
+ pid
649
+ for pid in running_claude_pids()
650
+ if (session_id := _arg_session_id(args_by_pid.get(pid, ""))) is None or session_id not in bg_ids
651
+ ]
652
+
653
+
636
654
  def background_agent_session_ids() -> set[str]:
637
655
  """所有背景 agent(``--bg-pty-host`` 承載且已載入真 session)的 session-id 集合。
638
656
 
@@ -686,13 +704,16 @@ def running_codex_pids() -> list[int]:
686
704
  if 0.0 <= now - _codex_pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
687
705
  return _codex_pids_cache[1]
688
706
  try:
689
- out = subprocess.run(["ps", "-Ao", "pid,comm"], capture_output=True, text=True, timeout=3).stdout
707
+ out = subprocess.run(["ps", "-Ao", "pid=,comm=,args="], capture_output=True, text=True, timeout=3).stdout
690
708
  except (OSError, subprocess.SubprocessError):
691
709
  out = ""
692
710
  pids: list[int] = []
693
- for line in out.splitlines()[1:]:
694
- parts = line.split(None, 1)
695
- if len(parts) == 2 and os.path.basename(parts[1].strip()) == "codex":
711
+ for line in out.splitlines():
712
+ parts = line.split(None, 2)
713
+ if len(parts) >= 2 and os.path.basename(parts[1].strip()) == "codex":
714
+ args = parts[2] if len(parts) == 3 else ""
715
+ if _is_codex_internal_process(args):
716
+ continue
696
717
  try:
697
718
  pids.append(int(parts[0]))
698
719
  except ValueError:
@@ -701,6 +722,18 @@ def running_codex_pids() -> list[int]:
701
722
  return pids
702
723
 
703
724
 
725
+ def _is_codex_internal_process(args: str) -> bool:
726
+ """Codex app 為工具呼叫啟動的同名內部 process,不是獨立互動 session。"""
727
+ tokens = args.split()
728
+ if not tokens:
729
+ return False
730
+ try:
731
+ command_index = next(i for i, token in enumerate(tokens) if os.path.basename(token) == "codex")
732
+ except StopIteration:
733
+ return False
734
+ return command_index + 1 < len(tokens) and tokens[command_index + 1] in {"app-server", "sandbox"}
735
+
736
+
704
737
  def running_agent_pids() -> list[int]:
705
738
  """所有內建來源看得到的 live agent CLI 行程。"""
706
739
  return [*running_claude_pids(), *running_codex_pids()]
@@ -776,14 +809,15 @@ def _pid_tty(pid: int) -> str:
776
809
 
777
810
 
778
811
  def _claude_procs() -> list[tuple[str, str]]:
779
- """每個還活著的 claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
812
+ """每個可聚焦的前景 Claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
780
813
 
781
- claude 不會把 session jsonl 一直開著(lsof 看不到),但給得到 cwd tty。
814
+ 背景 agent 不在這裡按 cwd 配對;它們由 source process args 的 session id 精準
815
+ 認領,避免背景 process 的啟動 cwd 讓同專案舊 transcript 誤判成仍在場。
782
816
  同一個 cwd 可能同時開好幾個 session,所以之後在 cwd 群組裡只有 mtime 最新的
783
817
  這幾個算活著,其餘同專案的舊 session=已離場。
784
818
  """
785
819
  procs: list[tuple[str, str]] = []
786
- for pid in running_claude_pids():
820
+ for pid in running_foreground_claude_pids():
787
821
  cwd = _pid_cwd(pid)
788
822
  if cwd:
789
823
  procs.append((cwd, _pid_tty(pid)))
@@ -1153,11 +1187,17 @@ def _hook_sessions(
1153
1187
  proc_ttys.setdefault(key, set()).add(tty)
1154
1188
  proc_cwds_by_provider.setdefault(pk, []).append(real_cwd)
1155
1189
 
1190
+ # 背景 agent 的 process 沒有可聚焦終端,不能拿來替同 cwd 的舊前景 hook row
1191
+ # 證明存活;它只精準認領 args 裡明載的 session id。Claude scan 仍使用包含背景
1192
+ # agent 的 session-id 配對,因此沒有 hook row 的 agent 也不會從看板消失。
1193
+ background_ids = background_agent_session_ids()
1156
1194
  rows_by_key: dict[tuple[str, str], list[Session]] = {}
1157
1195
  for s in out:
1158
1196
  pk = _canonical_provider(s.provider)
1159
1197
  if pk not in _PROVIDER_PROCS:
1160
1198
  continue # 沒有 proc 偵測器 → 無法驗活性 → fail-open,交給 SessionEnd
1199
+ if pk == "claude-code" and s.session_id in background_ids:
1200
+ continue
1161
1201
  rows_by_key.setdefault((pk, _real(s.cwd)), []).append(s)
1162
1202
 
1163
1203
  for key, rows in rows_by_key.items():
@@ -60,6 +60,11 @@ def discover_sessions() -> list[Session]:
60
60
  for s in found:
61
61
  if s.session_id in bg_agent_ids:
62
62
  s.kind = "agent"
63
+ # 背景 agent 跑完後不會像前景 session 一樣停在終端等下一輪輸入;它只會由
64
+ # daemon 暫留 process。繼續顯示成「跑完停著」既無法跳轉也無事可處理,反而
65
+ # 把已完成工作混進在場清單。歸入 ENDED 後預設收起,`--all` / TUI 的 a 仍可查。
66
+ if s.status is Status.IDLE:
67
+ s.status = Status.ENDED
63
68
 
64
69
  bound_targets = registry._tmux_pane_targets()
65
70
  process_targets = registry._tmux_process_tree_targets(found)
@@ -0,0 +1,46 @@
1
+ """Claude Code:掃 ``~/.claude/projects`` 的 JSONL。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import ring.registry as registry
8
+ from ring.registry import Session
9
+
10
+
11
+ def _activate_background_agents(sessions: list[Session], agent_ids: set[str]) -> None:
12
+ """只依明確 session id 恢復仍有 process 的背景 agent,不用它們的 cwd 猜前景活性。"""
13
+ now = time.time()
14
+ for session in sessions:
15
+ if session.session_id not in agent_ids:
16
+ continue
17
+ idle = now - session.last_active
18
+ session.status = registry._scan_status(idle)
19
+ session.status = registry._apply_waiting(
20
+ session.status,
21
+ idle,
22
+ session._tail_kind,
23
+ registry.WAITING_WINDOW_SECONDS,
24
+ )
25
+
26
+
27
+ class ClaudeCodeSource:
28
+ name = "claude-code"
29
+
30
+ def discover(self) -> list[Session]:
31
+ # 背景 agent 的 process cwd 可能仍是 daemon/啟動目錄,不能拿來按 cwd 猜哪些
32
+ # 前景 transcript 活著。前景先只用可聚焦 process 判活,背景再按 session id 認領。
33
+ procs = registry._claude_procs()
34
+ agent_ids = registry.background_agent_session_ids()
35
+ merged: dict[str, Session] = {}
36
+ scanned = registry._scan_sessions(procs)
37
+ _activate_background_agents(scanned, agent_ids)
38
+ for s in scanned:
39
+ merged.setdefault(s.session_id, s)
40
+ existing = list(merged.values())
41
+ for s in registry._synthetic_sessions(procs, existing):
42
+ merged.setdefault(s.session_id, s) # 合成列只填無 row 的 cwd
43
+ return list(merged.values())
44
+
45
+
46
+ source = ClaudeCodeSource()
@@ -197,6 +197,9 @@ class RingApp(App[None]):
197
197
  self._own_tty: str = self._detect_own_tty()
198
198
  self._delete_armed_sid: str | None = None
199
199
  self._delete_armed_until: float = 0.0
200
+ # p 已確認終端對話框消失,但 provider 尚未送後續 hook 時,別讓同一筆舊
201
+ # PermissionRequest 又把列拉回 WAITING。value 是已回覆那筆 row 的 last_active revision。
202
+ self._permission_acks: dict[str, float] = {}
200
203
 
201
204
  @staticmethod
202
205
  def _detect_own_tty() -> str:
@@ -345,6 +348,7 @@ class RingApp(App[None]):
345
348
  # 退回去跳 session 自己的終端(scan 模式常沒 tty → 跳轉失敗)。
346
349
  write_tui_presence()
347
350
  self._sessions = board(self._show_all)
351
+ self._apply_permission_acks()
348
352
  table = self.query_one(DataTable)
349
353
  # cursor 快照要在「任何」clear 之前取:clear(columns=True) 會把 cursor_row reset 成 0。
350
354
  cursor = table.cursor_row
@@ -389,6 +393,30 @@ class RingApp(App[None]):
389
393
  self._update_detail()
390
394
  self._poll_focus_request()
391
395
 
396
+ def _apply_permission_acks(self) -> None:
397
+ """以終端回覆結果蓋過同一 revision 的 stale WAITING,直到 provider 送來新事件。"""
398
+ present_ids = {s.session_id for s in self._sessions}
399
+ for session_id in set(self._permission_acks) - present_ids:
400
+ del self._permission_acks[session_id]
401
+
402
+ for s in self._sessions:
403
+ acknowledged_revision = self._permission_acks.get(s.session_id)
404
+ if acknowledged_revision is None:
405
+ continue
406
+ if s.last_active > acknowledged_revision:
407
+ # 新 hook event(可能是下一個權限請求)已到,重新採信 provider 狀態。
408
+ del self._permission_acks[s.session_id]
409
+ continue
410
+ if s.status is Status.WAITING:
411
+ # 終端畫面已證實原對話框消失;provider 尚未回報後續狀態時先視為處理中。
412
+ s.status = Status.WORKING
413
+ s.waiting_kind = ""
414
+ s.waiting_detail = ""
415
+ else:
416
+ # provider 已用同 revision 清掉 WAITING,不再需要本地 acknowledgment。
417
+ del self._permission_acks[s.session_id]
418
+ self._sessions.sort(key=lambda s: (s.status.rank, s.idle_for))
419
+
392
420
  def _update_detail(self) -> None:
393
421
  """detail 列:選中的 session 在 🔴 等什麼(hook 有給具體內容才顯示)。
394
422
 
@@ -541,6 +569,7 @@ class RingApp(App[None]):
541
569
  if outcome is permission.ReplyOutcome.OK:
542
570
  if s.session_id == self._focused_sid:
543
571
  self._focused_sid = None # 已就地回覆,解除通知標記
572
+ self._permission_acks[s.session_id] = s.last_active
544
573
  self._toast(_("→ {project}:已回覆權限({option})", project=name, option=option), ok=True)
545
574
  self._reload()
546
575
  return
@@ -1,23 +0,0 @@
1
- """Claude Code:掃 ``~/.claude/projects`` 的 JSONL。"""
2
-
3
- from __future__ import annotations
4
-
5
- import ring.registry as registry
6
- from ring.registry import Session
7
-
8
-
9
- class ClaudeCodeSource:
10
- name = "claude-code"
11
-
12
- def discover(self) -> list[Session]:
13
- procs = registry._claude_procs()
14
- merged: dict[str, Session] = {}
15
- for s in registry._scan_sessions(procs):
16
- merged.setdefault(s.session_id, s)
17
- existing = list(merged.values())
18
- for s in registry._synthetic_sessions(procs, existing):
19
- merged.setdefault(s.session_id, s) # 合成列只填無 row 的 cwd
20
- return list(merged.values())
21
-
22
-
23
- source = ClaudeCodeSource()
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