ring-cli 0.11.1__tar.gz → 0.11.3__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 (57) hide show
  1. {ring_cli-0.11.1 → ring_cli-0.11.3}/PKG-INFO +4 -1
  2. {ring_cli-0.11.1 → ring_cli-0.11.3}/README.en.md +3 -0
  3. {ring_cli-0.11.1 → ring_cli-0.11.3}/pyproject.toml +1 -1
  4. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/registry.py +313 -66
  5. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/__init__.py +8 -1
  6. ring_cli-0.11.3/src/ring/sources/claude_code.py +52 -0
  7. ring_cli-0.11.3/src/ring/sources/codex.py +21 -0
  8. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/tui.py +29 -0
  9. ring_cli-0.11.1/src/ring/sources/claude_code.py +0 -23
  10. ring_cli-0.11.1/src/ring/sources/codex.py +0 -16
  11. {ring_cli-0.11.1 → ring_cli-0.11.3}/LICENSE +0 -0
  12. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/__init__.py +0 -0
  13. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/__main__.py +0 -0
  14. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/cli.py +0 -0
  15. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/__init__.py +0 -0
  16. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/_args.py +0 -0
  17. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/completion.py +0 -0
  18. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/digest.py +0 -0
  19. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/doctor.py +0 -0
  20. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/focus.py +0 -0
  21. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/gc.py +0 -0
  22. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/hook.py +0 -0
  23. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/stats.py +0 -0
  24. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/config.py +0 -0
  25. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/__init__.py +0 -0
  26. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/applescript.py +0 -0
  27. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/base.py +0 -0
  28. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/iterm2.py +0 -0
  29. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/linux_wm.py +0 -0
  30. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/neovim.py +0 -0
  31. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/terminal.py +0 -0
  32. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/tmux.py +0 -0
  33. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/gc.py +0 -0
  34. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/hook.py +0 -0
  35. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/hook_protocol.py +0 -0
  36. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/i18n.py +0 -0
  37. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/ipc.py +0 -0
  38. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/labels.py +0 -0
  39. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  40. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  41. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/ring.pot +0 -0
  42. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/__init__.py +0 -0
  43. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/base.py +0 -0
  44. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/command.py +0 -0
  45. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/notify_send.py +0 -0
  46. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/ntfy.py +0 -0
  47. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/osascript_notifier.py +0 -0
  48. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/terminal_notifier.py +0 -0
  49. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/webhook.py +0 -0
  50. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/osascript.py +0 -0
  51. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/permission.py +0 -0
  52. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/plugins.py +0 -0
  53. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/base.py +0 -0
  54. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/hook_registry.py +0 -0
  55. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/stats.py +0 -0
  56. {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/transcript.py +0 -0
  57. {ring_cli-0.11.1 → ring_cli-0.11.3}/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.3
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.3"
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"
@@ -70,7 +70,7 @@ HOOK_HEARTBEAT_STALE_GRACE_SECONDS = 60.0
70
70
  # 要支援新工具的存活偵測=註冊一個偵測器,_hook_sessions / sources 零改動。
71
71
  # 同義 provider 名先正規化(例如 "claude" → "claude-code")。
72
72
  _PROVIDER_ALIASES: dict[str, str] = {"claude": "claude-code"}
73
- _PROVIDER_PROCS: dict[str, Callable[[], list[tuple[str, str]]]] = {}
73
+ _PROVIDER_PROCS: dict[str, Callable[[], list[tuple[str, str]] | None]] = {}
74
74
 
75
75
 
76
76
  def _canonical_provider(provider: str) -> str:
@@ -253,7 +253,7 @@ def prune_hidden_sessions(
253
253
  return stale
254
254
 
255
255
 
256
- def register_provider_procs(provider: str, detector: Callable[[], list[tuple[str, str]]]) -> None:
256
+ def register_provider_procs(provider: str, detector: Callable[[], list[tuple[str, str]] | None]) -> None:
257
257
  """註冊某 provider 的 live-process 偵測器(回傳 ``[(cwd, tty), …]``)。
258
258
 
259
259
  有偵測器的 provider 才會在 ``_hook_sessions`` 走 process-based 存活清理;沒註冊的
@@ -262,8 +262,12 @@ def register_provider_procs(provider: str, detector: Callable[[], list[tuple[str
262
262
  _PROVIDER_PROCS[_canonical_provider(provider)] = detector
263
263
 
264
264
 
265
- def collect_provider_procs() -> dict[str, list[tuple[str, str]]]:
266
- """所有已註冊 provider 的當下 live procs,鍵為標準 provider 名。"""
265
+ def collect_provider_procs() -> dict[str, list[tuple[str, str]] | None]:
266
+ """所有已註冊 provider 的當下 live procs,鍵為標準 provider 名。
267
+
268
+ 值為 ``None`` 代表該 provider 這輪偵測失敗(未知),呼叫端(``_hook_sessions``)
269
+ 必須把它與「真的偵測到零個 live process」分開處理,不能兩者都判離場。
270
+ """
267
271
  return {provider: detector() for provider, detector in _PROVIDER_PROCS.items()}
268
272
 
269
273
 
@@ -520,12 +524,33 @@ def _tmux_process_tree_targets(sessions: list[Session]) -> dict[str, str]:
520
524
  _pids_cache: tuple[float, list[int]] = (-1.0, [])
521
525
  _codex_pids_cache: tuple[float, list[int]] = (-1.0, [])
522
526
  _ps_claude_snapshot_cache: tuple[float, str] = (-1.0, "")
527
+ _ps_codex_snapshot_cache: tuple[float, str] = (-1.0, "")
523
528
  _bg_agent_session_ids_cache: tuple[float, frozenset[str]] = (-1.0, frozenset())
524
529
 
525
530
  # args 內任一出現即可判定「這是 claude 安裝二進位在跑」的路徑標記。ps comm 對
526
531
  # daemon-exec 的二進位常被截斷(如 `/Users/weilee/.l`),單看 comm 不可靠。
527
532
  _CLAUDE_PATH_MARKERS = ("ClaudeCode.app", "claude/versions/", "/.claude/")
528
533
 
534
+ # Claude Code 每次 Bash 工具呼叫都會 spawn 一個短命(0-10 秒)的 shell 承載
535
+ # `source ~/.claude/shell-snapshots/snapshot-*.sh` 之類的初始化腳本,cwd 就是
536
+ # 專案目錄——外觀酷似真正的 claude session。這個 shell 的 comm 是完整路徑
537
+ # (例如 `/bin/zsh`),但 args 裡含 `/.claude/` 子字串,會命中上面的
538
+ # `_CLAUDE_PATH_MARKERS`,被誤判為 claude session process,導致 board 上 session
539
+ # 數在每次任何 session 跑指令時 flap(synthetic row 出現又消失 / live 名額被灌水)。
540
+ # 真實樣本見 2026-07-13/14 現場取證(proc_logger.log)。這裡的守門:comm basename
541
+ # 一旦是已知 shell 名稱,一律不是 claude session——真正的 claude session comm
542
+ # 只會是 `claude` 或被截斷的安裝路徑片段(如 `/Users/weilee/.l`),從不會是
543
+ # shell 執行檔本身。
544
+ _SHELL_COMM_BASENAMES = frozenset({"sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish"})
545
+
546
+
547
+ def _is_shell_comm(comm: str) -> bool:
548
+ """comm basename 是否為常見 shell(含 login shell 的 ``-`` 前綴,如 ``-zsh``)。"""
549
+ base = os.path.basename(comm.strip())
550
+ if base.startswith("-"):
551
+ base = base[1:]
552
+ return base in _SHELL_COMM_BASENAMES
553
+
529
554
 
530
555
  def _is_claude_session_line(comm: str, args: str) -> bool:
531
556
  """判定一行 ``ps`` 輸出是否為 claude session process(承載者或子行程皆算)。
@@ -538,9 +563,15 @@ def _is_claude_session_line(comm: str, args: str) -> bool:
538
563
  ``less claude`` 這類完全無關但恰好帶 ``claude`` 字面的 process 會被誤收;
539
564
  真正被截斷 comm 的 claude session(daemon 承載者與其子行程)必然帶
540
565
  ``--session-id``,所以這個限定不會犧牲 fallback 能力。
566
+
567
+ comm basename 為 shell(見 ``_is_shell_comm``)時提前回傳 ``False``:Bash 工具
568
+ 呼叫 spawn 的 shell wrapper args 常含 ``/.claude/``(source shell-snapshots 腳本),
569
+ 會誤觸下面的 path marker 分支,必須在那之前擋下。
541
570
  """
542
571
  if os.path.basename(comm.strip()) == "claude":
543
572
  return True
573
+ if _is_shell_comm(comm):
574
+ return False
544
575
  if any(marker in args for marker in _CLAUDE_PATH_MARKERS):
545
576
  return True
546
577
  tokens = args.split()
@@ -549,44 +580,76 @@ def _is_claude_session_line(comm: str, args: str) -> bool:
549
580
  return any(os.path.basename(tok) == "claude" for tok in tokens)
550
581
 
551
582
 
552
- def _ps_claude_snapshot() -> str:
553
- """``ps -Ao pid,comm,args`` 的短快取原始輸出,供多個 claude proc 判定函式共用。"""
583
+ def _ps_claude_snapshot() -> str | None:
584
+ """``ps -Ao pid,comm,args`` 的短快取原始輸出,供多個 claude proc 判定函式共用。
585
+
586
+ 回傳 ``None`` 代表這次 ``ps`` 呼叫失敗(逾時/例外)——這是「不知道」,不是
587
+ 「系統上沒有任何 process」,呼叫端必須分開處理,不能把 ``None`` 當空字串解析出
588
+ 零筆存活 process。失敗一律不進快取,好讓下一輪立刻重試,不會被短 TTL 快取卡住。
589
+ """
554
590
  global _ps_claude_snapshot_cache
555
591
  now = time.monotonic()
556
592
  if 0.0 <= now - _ps_claude_snapshot_cache[0] <= _SUBPROCESS_CACHE_TTL:
557
593
  return _ps_claude_snapshot_cache[1]
558
594
  try:
559
- out = subprocess.run(["ps", "-Ao", "pid,comm,args"], capture_output=True, text=True, timeout=3).stdout
595
+ result = subprocess.run(["ps", "-Ao", "pid,tty,comm,args"], capture_output=True, text=True, timeout=3)
560
596
  except (OSError, subprocess.SubprocessError):
561
- out = ""
562
- _ps_claude_snapshot_cache = (now, out)
563
- return out
597
+ return None
598
+ if result.returncode != 0:
599
+ # 非零 exit 跟逾時/例外一樣是「這次沒問到」,不是「問到了、答案是沒有 process」。
600
+ return None
601
+ _ps_claude_snapshot_cache = (now, result.stdout)
602
+ return result.stdout
564
603
 
565
604
 
566
- def _parse_ps_claude_lines(out: str) -> list[tuple[int, str, bool]]:
567
- """把 ``ps`` 輸出解析成 claude session 行:``(pid, args, is_bg_pty_host)``。
605
+ def _normalize_tty(raw: str) -> str:
606
+ """把 ``ps`` 的 tty 欄位正規化成 iTerm2 認得的 ``/dev/ttysNNN``;查無 tty 回空字串。"""
607
+ tty = raw.strip()
608
+ if not tty or tty in ("??", "?"):
609
+ return ""
610
+ return tty if tty.startswith("/dev/") else f"/dev/{tty}"
611
+
612
+
613
+ def _parse_ps_claude_lines(out: str) -> list[tuple[int, str, str, bool]]:
614
+ """把 ``ps`` 輸出解析成 claude session 行:``(pid, tty, args, is_bg_pty_host)``。
615
+
616
+ tty 欄位(``ps -Ao pid,tty,comm,args`` 的第二欄)已正規化,供 ``_claude_procs``
617
+ 直接查表用,不必再對每個 pid 各開一次 ``ps -o tty= -p PID``。
568
618
 
569
619
  不論是否為背景 process(daemon / bg-spare / bg-pty-host)都收進來——背景判定
570
620
  交給呼叫端各自決定要不要濾除;``_hook_sessions`` 的活性判定與
571
621
  ``running_claude_pids`` 對「該濾誰」的答案不同,不能在這裡先幫忙決定。
572
622
  """
573
- entries: list[tuple[int, str, bool]] = []
623
+ entries: list[tuple[int, str, str, bool]] = []
574
624
  for line in out.splitlines()[1:]:
575
- parts = line.split(None, 2)
576
- if len(parts) < 2:
625
+ parts = line.split(None, 3)
626
+ if len(parts) < 3:
577
627
  continue
578
- comm = parts[1].strip()
579
- args = parts[2] if len(parts) == 3 else ""
628
+ tty = _normalize_tty(parts[1])
629
+ comm = parts[2].strip()
630
+ args = parts[3] if len(parts) == 4 else ""
580
631
  if not _is_claude_session_line(comm, args):
581
632
  continue
582
633
  try:
583
634
  pid = int(parts[0])
584
635
  except ValueError:
585
636
  continue
586
- entries.append((pid, args, "--bg-pty-host" in args.split()))
637
+ entries.append((pid, tty, args, "--bg-pty-host" in args.split()))
587
638
  return entries
588
639
 
589
640
 
641
+ def _claude_tty_map() -> dict[int, str] | None:
642
+ """pid → tty,來自 ``_ps_claude_snapshot()`` 共用的快照,不再多開一次 ``ps``。
643
+
644
+ 回傳 ``None`` 代表這輪 ``ps`` 掃描失敗(未知);成功但某 pid 沒出現在快照裡
645
+ (已死)時,該 pid 直接不在回傳 dict 內——呼叫端用 ``.get(pid, "")`` 取值即可。
646
+ """
647
+ snapshot = _ps_claude_snapshot()
648
+ if snapshot is None:
649
+ return None
650
+ return {pid: tty for pid, tty, _args, _is_bg_host in _parse_ps_claude_lines(snapshot)}
651
+
652
+
590
653
  def _arg_session_id(args: str) -> str | None:
591
654
  """解析 args 裡 ``--session-id`` 後面的那個 token;沒有就回 ``None``。"""
592
655
  tokens = args.split()
@@ -596,24 +659,30 @@ def _arg_session_id(args: str) -> str | None:
596
659
  return None
597
660
 
598
661
 
599
- def running_claude_pids() -> list[int]:
662
+ def running_claude_pids() -> list[int] | None:
600
663
  """目前活著、使用者可聚焦的 claude CLI pid(daemon / bg-spare / bg 暖機承載者濾除)。
601
664
 
602
665
  承載者(``--bg-pty-host`` + ``--session-id``)與其子行程常成對出現、共用同一個
603
666
  session-id:兩者都算「真 session」不濾除,但只留一個 pid,偏好子行程——子行程
604
667
  的 cwd(lsof 量得到)誠實,承載者的 cwd 常是 daemon 自己的 cwd,非專案目錄。
605
668
  只有承載者、沒有子行程時(fallback)仍保留承載者這個 pid,好過整個 session 消失。
669
+
670
+ 回傳 ``None`` 代表這輪 ``ps`` 掃描失敗(未知),不是「沒有任何 claude process」;
671
+ 呼叫端不得把 ``None`` 當空清單使用來判定 session 離場。失敗不快取,下一輪重試。
606
672
  """
607
673
  global _pids_cache
608
674
  now = time.monotonic()
609
675
  if 0.0 <= now - _pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
610
676
  return _pids_cache[1]
611
- entries = _parse_ps_claude_lines(_ps_claude_snapshot())
677
+ snapshot = _ps_claude_snapshot()
678
+ if snapshot is None:
679
+ return None
680
+ entries = _parse_ps_claude_lines(snapshot)
612
681
 
613
682
  pids: list[int] = []
614
683
  sid_index: dict[str, int] = {} # session-id → 該 pid 在 pids 裡的位置,供子行程晚到時換掉
615
684
  sid_is_bg_host: dict[str, bool] = {}
616
- for pid, args, is_bg_host in entries:
685
+ for pid, _tty, args, is_bg_host in entries:
617
686
  if _is_claude_background_process(args):
618
687
  continue
619
688
  session_id = _arg_session_id(args)
@@ -633,20 +702,53 @@ def running_claude_pids() -> list[int]:
633
702
  return pids
634
703
 
635
704
 
636
- def background_agent_session_ids() -> set[str]:
705
+ def running_foreground_claude_pids() -> list[int] | None:
706
+ """目前仍有可聚焦終端的 Claude session pid。
707
+
708
+ ``running_claude_pids`` 也包含 agents mode 的背景 session,因為 scan 需要靠它們
709
+ 找到對應 transcript;但 hook registry 不能拿背景 agent 的 cwd/數量替同專案的
710
+ 舊前景 row 證明存活,否則一個 agent 就可能讓 crash 數小時的 session 繼續顯示。
711
+
712
+ 回傳 ``None`` 代表這輪掃描失敗(未知),呼叫端不得當空清單處理。
713
+ """
714
+ bg_ids = background_agent_session_ids()
715
+ if bg_ids is None:
716
+ return None
717
+ base_pids = running_claude_pids()
718
+ if base_pids is None:
719
+ return None
720
+ if not bg_ids:
721
+ return base_pids
722
+ snapshot = _ps_claude_snapshot()
723
+ if snapshot is None:
724
+ return None
725
+ args_by_pid = {pid: args for pid, _tty, args, _is_bg_host in _parse_ps_claude_lines(snapshot)}
726
+ return [
727
+ pid
728
+ for pid in base_pids
729
+ if (session_id := _arg_session_id(args_by_pid.get(pid, ""))) is None or session_id not in bg_ids
730
+ ]
731
+
732
+
733
+ def background_agent_session_ids() -> set[str] | None:
637
734
  """所有背景 agent(``--bg-pty-host`` 承載且已載入真 session)的 session-id 集合。
638
735
 
639
736
  給 ``discover_sessions()`` 對應貼 ``kind="agent"`` 標籤用。與 ``running_claude_pids``
640
737
  共用同一份 ``ps`` 快照(``_ps_claude_snapshot``),不額外多打一次 ``ps``。
738
+
739
+ 回傳 ``None`` 代表這輪掃描失敗(未知),不是「沒有背景 agent」。
641
740
  """
642
741
  global _bg_agent_session_ids_cache
643
742
  now = time.monotonic()
644
743
  if 0.0 <= now - _bg_agent_session_ids_cache[0] <= _SUBPROCESS_CACHE_TTL:
645
744
  return set(_bg_agent_session_ids_cache[1])
646
- entries = _parse_ps_claude_lines(_ps_claude_snapshot())
745
+ snapshot = _ps_claude_snapshot()
746
+ if snapshot is None:
747
+ return None
748
+ entries = _parse_ps_claude_lines(snapshot)
647
749
  ids = frozenset(
648
750
  session_id
649
- for _pid, args, is_bg_host in entries
751
+ for _pid, _tty, args, is_bg_host in entries
650
752
  if is_bg_host and (session_id := _arg_session_id(args)) is not None
651
753
  )
652
754
  _bg_agent_session_ids_cache = (now, ids)
@@ -680,46 +782,135 @@ def _is_claude_background_process(args: str) -> bool:
680
782
  return False
681
783
 
682
784
 
683
- def running_codex_pids() -> list[int]:
785
+ def _ps_codex_snapshot() -> str | None:
786
+ """``ps -Ao pid=,tty=,comm=,args=`` 的短快取原始輸出,供 codex pid/tty 共用。
787
+
788
+ 含 tty 欄,讓 ``_codex_tty_map`` 能從同一份快照查表,不必再對每個 pid 各開一次
789
+ ``ps -o tty= -p PID``。回傳 ``None`` 代表這次 ``ps`` 呼叫失敗(逾時/例外/非零
790
+ exit)——是「不知道」,不是「沒有任何 process」;失敗不快取,下一輪重試。
791
+ """
792
+ global _ps_codex_snapshot_cache
793
+ now = time.monotonic()
794
+ if 0.0 <= now - _ps_codex_snapshot_cache[0] <= _SUBPROCESS_CACHE_TTL:
795
+ return _ps_codex_snapshot_cache[1]
796
+ try:
797
+ result = subprocess.run(["ps", "-Ao", "pid=,tty=,comm=,args="], capture_output=True, text=True, timeout=3)
798
+ except (OSError, subprocess.SubprocessError):
799
+ return None
800
+ if result.returncode != 0:
801
+ # 非零 exit 跟逾時/例外一樣是「這次沒問到」,不是「問到了、答案是沒有 process」。
802
+ return None
803
+ _ps_codex_snapshot_cache = (now, result.stdout)
804
+ return result.stdout
805
+
806
+
807
+ def _parse_ps_codex_lines(out: str) -> list[tuple[int, str, str]]:
808
+ """把 ``_ps_codex_snapshot()`` 的輸出解析成 ``(pid, tty, args)``(僅 comm 為 codex 的行)。"""
809
+ entries: list[tuple[int, str, str]] = []
810
+ for line in out.splitlines():
811
+ parts = line.split(None, 3)
812
+ if len(parts) < 3 or os.path.basename(parts[2].strip()) != "codex":
813
+ continue
814
+ tty = _normalize_tty(parts[1])
815
+ args = parts[3] if len(parts) == 4 else ""
816
+ try:
817
+ pid = int(parts[0])
818
+ except ValueError:
819
+ continue
820
+ entries.append((pid, tty, args))
821
+ return entries
822
+
823
+
824
+ def running_codex_pids() -> list[int] | None:
825
+ """目前活著的 codex CLI pid。回傳 ``None`` 代表這輪 ``ps`` 掃描失敗(未知),
826
+ 不是「沒有任何 codex process」;失敗不快取,下一輪重試。
827
+ """
684
828
  global _codex_pids_cache
685
829
  now = time.monotonic()
686
830
  if 0.0 <= now - _codex_pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
687
831
  return _codex_pids_cache[1]
688
- try:
689
- out = subprocess.run(["ps", "-Ao", "pid,comm"], capture_output=True, text=True, timeout=3).stdout
690
- except (OSError, subprocess.SubprocessError):
691
- out = ""
832
+ snapshot = _ps_codex_snapshot()
833
+ if snapshot is None:
834
+ return None
692
835
  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":
696
- try:
697
- pids.append(int(parts[0]))
698
- except ValueError:
699
- pass
836
+ for pid, _tty, args in _parse_ps_codex_lines(snapshot):
837
+ if _is_codex_internal_process(args):
838
+ continue
839
+ pids.append(pid)
700
840
  _codex_pids_cache = (now, pids)
701
841
  return pids
702
842
 
703
843
 
844
+ def _codex_tty_map() -> dict[int, str] | None:
845
+ """pid → tty,來自 ``_ps_codex_snapshot()`` 共用的快照,不再多開一次 ``ps``。
846
+
847
+ 回傳 ``None`` 代表這輪掃描失敗(未知);成功但某 pid 沒出現在快照裡(已死)
848
+ 時,該 pid 直接不在回傳 dict 內——呼叫端用 ``.get(pid, "")`` 取值即可。
849
+ """
850
+ snapshot = _ps_codex_snapshot()
851
+ if snapshot is None:
852
+ return None
853
+ return {pid: tty for pid, tty, _args in _parse_ps_codex_lines(snapshot)}
854
+
855
+
856
+ def _is_codex_internal_process(args: str) -> bool:
857
+ """Codex app 為工具呼叫啟動的同名內部 process,不是獨立互動 session。"""
858
+ tokens = args.split()
859
+ if not tokens:
860
+ return False
861
+ try:
862
+ command_index = next(i for i, token in enumerate(tokens) if os.path.basename(token) == "codex")
863
+ except StopIteration:
864
+ return False
865
+ return command_index + 1 < len(tokens) and tokens[command_index + 1] in {"app-server", "sandbox"}
866
+
867
+
704
868
  def running_agent_pids() -> list[int]:
705
- """所有內建來源看得到的 live agent CLI 行程。"""
706
- return [*running_claude_pids(), *running_codex_pids()]
869
+ """所有內建來源看得到的 live agent CLI 行程(顯示用途的彙總計數)。
870
+
871
+ 這裡刻意攤平 ``None``(掃描失敗/未知)成空清單——本函式只餵給 header 計數等
872
+ 顯示用途,不是存活判定;真正的 ENDED 判定路徑(``_hook_sessions``)用的是
873
+ 未攤平的 ``running_claude_pids`` / ``running_codex_pids`` 原始回傳值。
874
+ """
875
+ return [*(running_claude_pids() or []), *(running_codex_pids() or [])]
707
876
 
708
877
 
709
- def _pid_cwd(pid: int) -> str:
878
+ def _pids_cwd(pids: list[int]) -> dict[int, str] | None:
879
+ """批次查多個 pid 的 cwd:一次 ``lsof -a -p pid1,pid2,... -d cwd -Fn``(N 次 → 1 次)。
880
+
881
+ PoC 已驗證(本機 lsof 4.91/macOS):``-p`` 接受逗號分隔的多 pid;輸出以
882
+ ``p<pid>`` 分段、其後 ``n<path>`` 行給該 pid 的 cwd。**其中一個 pid 已死時,
883
+ lsof 對整批呼叫仍回傳 exit code 1**(連全部都死也是 1),但存活 pid 的區段照樣
884
+ 完整輸出——所以本函式刻意不看 ``returncode``,只要 subprocess 本身沒丟例外就
885
+ 解析 stdout。
886
+
887
+ 回傳 ``None`` 代表這次 lsof **呼叫本身**失敗(逾時/例外)——是「這輪不知道任何
888
+ pid 的 cwd」(未知),呼叫端不得把它當「都沒有 cwd」用來判定 session 離場。
889
+ 批次呼叫成功但某個 pid 沒出現在輸出裡=那個 pid 剛死/查無 cwd,是真資訊,不是
890
+ 未知,直接不進回傳的 dict(呼叫端用 ``.get(pid, "")`` 取值,缺項自然視為無 cwd)。
891
+ """
892
+ if not pids:
893
+ return {}
710
894
  try:
711
895
  out = subprocess.run(
712
- ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
896
+ ["lsof", "-a", "-p", ",".join(str(pid) for pid in pids), "-d", "cwd", "-Fn"],
713
897
  capture_output=True,
714
898
  text=True,
715
899
  timeout=3,
716
900
  ).stdout
717
901
  except (OSError, subprocess.SubprocessError):
718
- return ""
902
+ return None
903
+ result: dict[int, str] = {}
904
+ current_pid: int | None = None
719
905
  for line in out.splitlines():
720
- if line.startswith("n"):
721
- return line[1:]
722
- return ""
906
+ if line.startswith("p"):
907
+ try:
908
+ current_pid = int(line[1:])
909
+ except ValueError:
910
+ current_pid = None
911
+ elif line.startswith("n") and current_pid is not None:
912
+ result.setdefault(current_pid, line[1:])
913
+ return result
723
914
 
724
915
 
725
916
  def _real(path: str) -> str:
@@ -763,40 +954,70 @@ def _has_ancestor_live_process(row_cwd: str, live_cwds: list[str]) -> bool:
763
954
 
764
955
 
765
956
  def _pid_tty(pid: int) -> str:
766
- """claude process 的控制終端,正規化成 iTerm2 認得的 "/dev/ttysNNN"。"""
957
+ """claude process 的控制終端,正規化成 iTerm2 認得的 "/dev/ttysNNN"。
958
+
959
+ 單 pid、按需查詢用(例如 hook 事件當下要跳轉終端);``discover_sessions()``
960
+ 每輪刷新的熱路徑改走 ``_claude_tty_map`` / ``_codex_tty_map``,不逐 pid 開 ``ps``。
961
+ """
767
962
  try:
768
- tty = subprocess.run(
769
- ["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=3
770
- ).stdout.strip()
963
+ tty = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=3).stdout
771
964
  except (OSError, subprocess.SubprocessError):
772
965
  return ""
773
- if not tty or tty in ("??", "?"):
774
- return ""
775
- return tty if tty.startswith("/dev/") else f"/dev/{tty}"
966
+ return _normalize_tty(tty)
776
967
 
777
968
 
778
- def _claude_procs() -> list[tuple[str, str]]:
779
- """每個還活著的 claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
969
+ def _claude_procs() -> list[tuple[str, str]] | None:
970
+ """每個可聚焦的前景 Claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
780
971
 
781
- claude 不會把 session 的 jsonl 一直開著(lsof 看不到),但給得到 cwd 與 tty。
972
+ 背景 agent 不在這裡按 cwd 配對;它們由 source 依 process args 的 session id 精準
973
+ 認領,避免背景 process 的啟動 cwd 讓同專案舊 transcript 誤判成仍在場。
782
974
  同一個 cwd 可能同時開好幾個 session,所以之後在 cwd 群組裡只有 mtime 最新的
783
975
  這幾個算活著,其餘同專案的舊 session=已離場。
976
+
977
+ cwd/tty 各只批次查一次(``_pids_cwd`` 一次 lsof、``_claude_tty_map`` 沿用共用
978
+ ps 快照),不再逐 pid 各開一次 lsof + 一次 ps。
979
+
980
+ 回傳 ``None`` 代表這輪掃描失敗(未知)——``ps``(pid 清單/tty)或 lsof(cwd)
981
+ 任一整批失敗都算,呼叫端(尤其是 ``_hook_sessions`` 的存活判定)不得把它當
982
+ 「沒有任何 claude process」處理。批次呼叫成功但個別 pid 沒查到 cwd/tty(剛死)
983
+ 不算未知,那個 pid 直接不貢獻一列,語意與逐 pid 版本一致。
784
984
  """
985
+ pids = running_foreground_claude_pids()
986
+ if pids is None:
987
+ return None
988
+ cwd_by_pid = _pids_cwd(pids)
989
+ if cwd_by_pid is None:
990
+ return None
991
+ tty_by_pid = _claude_tty_map()
992
+ if tty_by_pid is None:
993
+ return None
785
994
  procs: list[tuple[str, str]] = []
786
- for pid in running_claude_pids():
787
- cwd = _pid_cwd(pid)
995
+ for pid in pids:
996
+ cwd = cwd_by_pid.get(pid, "")
788
997
  if cwd:
789
- procs.append((cwd, _pid_tty(pid)))
998
+ procs.append((cwd, tty_by_pid.get(pid, "")))
790
999
  return procs
791
1000
 
792
1001
 
793
- def _codex_procs() -> list[tuple[str, str]]:
794
- """每個還活著的 Codex CLI:(cwd, tty)。"""
1002
+ def _codex_procs() -> list[tuple[str, str]] | None:
1003
+ """每個還活著的 Codex CLI:(cwd, tty)。回傳 ``None`` 代表這輪掃描失敗(未知)。
1004
+
1005
+ cwd/tty 各只批次查一次,理由與 ``_claude_procs`` 相同。
1006
+ """
1007
+ pids = running_codex_pids()
1008
+ if pids is None:
1009
+ return None
1010
+ cwd_by_pid = _pids_cwd(pids)
1011
+ if cwd_by_pid is None:
1012
+ return None
1013
+ tty_by_pid = _codex_tty_map()
1014
+ if tty_by_pid is None:
1015
+ return None
795
1016
  procs: list[tuple[str, str]] = []
796
- for pid in running_codex_pids():
797
- cwd = _pid_cwd(pid)
1017
+ for pid in pids:
1018
+ cwd = cwd_by_pid.get(pid, "")
798
1019
  if cwd:
799
- procs.append((cwd, _pid_tty(pid)))
1020
+ procs.append((cwd, tty_by_pid.get(pid, "")))
800
1021
  return procs
801
1022
 
802
1023
 
@@ -1056,7 +1277,7 @@ def _synthetic_sessions(procs: list[tuple[str, str]], existing: list[Session]) -
1056
1277
  out: list[Session] = []
1057
1278
  seen: set[str] = set()
1058
1279
  for cwd, tty in procs:
1059
- if not cwd: # _pid_cwd 失敗的情況,沒 cwd 撐不起一列
1280
+ if not cwd: # _pids_cwd 查無此 pid cwd 的情況,沒 cwd 撐不起一列
1060
1281
  continue
1061
1282
  rkey = _real(cwd)
1062
1283
  if rkey in existing_cwds: # 已經有 row 了(hook 或 scan 覆蓋)
@@ -1085,7 +1306,7 @@ def _synthetic_sessions(procs: list[tuple[str, str]], existing: list[Session]) -
1085
1306
  def _hook_sessions(
1086
1307
  procs: list[tuple[str, str]] | None = None,
1087
1308
  *,
1088
- procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
1309
+ procs_by_provider: dict[str, list[tuple[str, str]] | None] | None = None,
1089
1310
  purge_session_start_phantoms: bool = True,
1090
1311
  ) -> list[Session]:
1091
1312
  if not RING_REGISTRY.is_dir():
@@ -1139,12 +1360,27 @@ def _hook_sessions(
1139
1360
  # 3. 該 cwd 只有「單一」hook row 時,無論 tty 是否對得上都不靠 tty 殺——hook 寫進來
1140
1361
  # 的 tty 不一定可靠(終端 tty 會被作業系統重配,甚至跨 session 錯置),拿它隱藏
1141
1362
  # 唯一活著的 session 會讓整列憑空消失。
1363
+ # 4. 「這個 provider 這輪 ps/lsof 掃描失敗」(值為 ``None``)是「未知」,不是
1364
+ # 「真的偵測到零個 live process」——未知時完全不動這個 provider 底下任何一筆
1365
+ # row 的狀態,保留既有狀態,避免單次系統瞬間卡頓(ps timeout)把整版 session
1366
+ # 誤判 ENDED(見 ring-vanishing-sessions 診斷)。
1142
1367
  if out:
1143
1368
  proc_counts: dict[tuple[str, str], int] = {}
1144
1369
  proc_ttys: dict[tuple[str, str], set[str]] = {}
1145
1370
  proc_cwds_by_provider: dict[str, list[str]] = {}
1371
+ unknown_providers: set[str] = set()
1146
1372
  for pk in _PROVIDER_PROCS:
1147
- provider_procs = procs_by_provider.get(pk, []) if procs_by_provider is not None else (procs or [])
1373
+ if procs_by_provider is not None:
1374
+ provider_procs = procs_by_provider.get(pk, [])
1375
+ else:
1376
+ # 沒有帶 procs_by_provider 時,直接沿用 procs 這個 sentinel-None(單純
1377
+ # 代表「呼叫端沒給」,不是「這輪掃描失敗」),維持既有「當空清單」語意。
1378
+ provider_procs = procs if procs is not None else []
1379
+ if provider_procs is None:
1380
+ # 這輪掃描失敗(ps/lsof timeout 或例外)——標成未知,底下的存活判定
1381
+ # 對這個 provider 一律跳過,不把任何 row 判 ENDED。
1382
+ unknown_providers.add(pk)
1383
+ continue
1148
1384
  for cwd, tty in provider_procs:
1149
1385
  real_cwd = _real(cwd)
1150
1386
  key = (pk, real_cwd)
@@ -1153,17 +1389,28 @@ def _hook_sessions(
1153
1389
  proc_ttys.setdefault(key, set()).add(tty)
1154
1390
  proc_cwds_by_provider.setdefault(pk, []).append(real_cwd)
1155
1391
 
1392
+ # 背景 agent 的 process 沒有可聚焦終端,不能拿來替同 cwd 的舊前景 hook row
1393
+ # 證明存活;它只精準認領 args 裡明載的 session id。Claude scan 仍使用包含背景
1394
+ # agent 的 session-id 配對,因此沒有 hook row 的 agent 也不會從看板消失。
1395
+ # 這次呼叫失敗(``None``=未知)時保守當成「沒有已知背景 agent」——claude-code
1396
+ # 這個 provider 本身這輪多半也偵測失敗,會被上面的 unknown_providers 整批保護,
1397
+ # 這裡的 fallback 只是避免拿 None 做 in 運算炸掉。
1398
+ background_ids = background_agent_session_ids() or set()
1156
1399
  rows_by_key: dict[tuple[str, str], list[Session]] = {}
1157
1400
  for s in out:
1158
1401
  pk = _canonical_provider(s.provider)
1159
1402
  if pk not in _PROVIDER_PROCS:
1160
1403
  continue # 沒有 proc 偵測器 → 無法驗活性 → fail-open,交給 SessionEnd
1404
+ if pk == "claude-code" and s.session_id in background_ids:
1405
+ continue
1161
1406
  rows_by_key.setdefault((pk, _real(s.cwd)), []).append(s)
1162
1407
 
1163
1408
  for key, rows in rows_by_key.items():
1409
+ pk, row_cwd = key
1410
+ if pk in unknown_providers:
1411
+ continue # 這輪掃描失敗,未知不等於離場,保留既有狀態
1164
1412
  live_n = proc_counts.get(key, 0)
1165
1413
  if live_n == 0:
1166
- pk, row_cwd = key
1167
1414
  if _has_ancestor_live_process(row_cwd, proc_cwds_by_provider.get(pk, [])):
1168
1415
  # hook payload 的 cwd 落在使用者 cd 進去的子目錄,但 claude process 實際
1169
1416
  # cwd(lsof 量到的)仍停在啟動目錄——兩者都正規化過,子目錄底下自然量不到
@@ -56,10 +56,17 @@ def discover_sessions() -> list[Session]:
56
56
  found.append(s)
57
57
  # 否則:仍在隱藏保留期內、沒有新活動 → 不收進看板。
58
58
 
59
- bg_agent_ids = registry.background_agent_session_ids()
59
+ # None=這輪掃描失敗(未知);保守當成「沒有已知背景 agent」,不影響上面已經
60
+ # 由 hook/scan 各自來源判定好的 status,只影響 kind="agent" 標籤與 IDLE→ENDED 降級。
61
+ bg_agent_ids = registry.background_agent_session_ids() or set()
60
62
  for s in found:
61
63
  if s.session_id in bg_agent_ids:
62
64
  s.kind = "agent"
65
+ # 背景 agent 跑完後不會像前景 session 一樣停在終端等下一輪輸入;它只會由
66
+ # daemon 暫留 process。繼續顯示成「跑完停著」既無法跳轉也無事可處理,反而
67
+ # 把已完成工作混進在場清單。歸入 ENDED 後預設收起,`--all` / TUI 的 a 仍可查。
68
+ if s.status is Status.IDLE:
69
+ s.status = Status.ENDED
63
70
 
64
71
  bound_targets = registry._tmux_pane_targets()
65
72
  process_targets = registry._tmux_process_tree_targets(found)
@@ -0,0 +1,52 @@
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
+ if procs is None:
35
+ # 這輪 ps/lsof 掃描失敗(未知)。掃描結果未知時,scan 只能把每個 transcript
36
+ # 判成 ENDED;那批 ENDED row 會經由 _merge_duplicate_session 覆蓋掉 hook
37
+ # 已保護好的 WAITING row,session 就從看板上消失。未知不等於離場——這輪
38
+ # 不貢獻任何 row。
39
+ return []
40
+ agent_ids = registry.background_agent_session_ids() or set()
41
+ merged: dict[str, Session] = {}
42
+ scanned = registry._scan_sessions(procs)
43
+ _activate_background_agents(scanned, agent_ids)
44
+ for s in scanned:
45
+ merged.setdefault(s.session_id, s)
46
+ existing = list(merged.values())
47
+ for s in registry._synthetic_sessions(procs, existing):
48
+ merged.setdefault(s.session_id, s) # 合成列只填無 row 的 cwd
49
+ return list(merged.values())
50
+
51
+
52
+ source = ClaudeCodeSource()
@@ -0,0 +1,21 @@
1
+ """Codex CLI:讀 ``~/.codex/state_5.sqlite`` threads,並用 live ``codex`` process 配 tty。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ring.registry as registry
6
+ from ring.registry import Session
7
+
8
+
9
+ class CodexSource:
10
+ name = "codex"
11
+
12
+ def discover(self) -> list[Session]:
13
+ procs = registry._codex_procs()
14
+ if procs is None:
15
+ # 這輪 ps 掃描失敗(未知)。拿空清單去掃會把每個 thread 判成 ENDED,
16
+ # 覆蓋掉 hook 已保護的 row。未知不等於離場——這輪不貢獻任何 row。
17
+ return []
18
+ return registry._codex_threads(procs)
19
+
20
+
21
+ source = CodexSource()
@@ -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()
@@ -1,16 +0,0 @@
1
- """Codex CLI:讀 ``~/.codex/state_5.sqlite`` threads,並用 live ``codex`` process 配 tty。"""
2
-
3
- from __future__ import annotations
4
-
5
- import ring.registry as registry
6
- from ring.registry import Session
7
-
8
-
9
- class CodexSource:
10
- name = "codex"
11
-
12
- def discover(self) -> list[Session]:
13
- return registry._codex_threads(registry._codex_procs())
14
-
15
-
16
- source = CodexSource()
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