ring-cli 0.11.2__tar.gz → 0.11.4__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.

Potentially problematic release.


This version of ring-cli might be problematic. Click here for more details.

Files changed (56) hide show
  1. {ring_cli-0.11.2 → ring_cli-0.11.4}/PKG-INFO +4 -2
  2. {ring_cli-0.11.2 → ring_cli-0.11.4}/README.en.md +3 -1
  3. {ring_cli-0.11.2 → ring_cli-0.11.4}/pyproject.toml +1 -1
  4. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/hook.py +3 -2
  5. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/hook_protocol.py +7 -0
  6. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/registry.py +279 -72
  7. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/__init__.py +3 -1
  8. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/claude_code.py +7 -1
  9. ring_cli-0.11.4/src/ring/sources/codex.py +21 -0
  10. ring_cli-0.11.2/src/ring/sources/codex.py +0 -16
  11. {ring_cli-0.11.2 → ring_cli-0.11.4}/LICENSE +0 -0
  12. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/__init__.py +0 -0
  13. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/__main__.py +0 -0
  14. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/cli.py +0 -0
  15. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/__init__.py +0 -0
  16. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/_args.py +0 -0
  17. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/completion.py +0 -0
  18. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/digest.py +0 -0
  19. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/doctor.py +0 -0
  20. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/focus.py +0 -0
  21. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/gc.py +0 -0
  22. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/hook.py +0 -0
  23. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/stats.py +0 -0
  24. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/config.py +0 -0
  25. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/__init__.py +0 -0
  26. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/applescript.py +0 -0
  27. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/base.py +0 -0
  28. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/iterm2.py +0 -0
  29. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/linux_wm.py +0 -0
  30. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/neovim.py +0 -0
  31. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/terminal.py +0 -0
  32. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/tmux.py +0 -0
  33. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/gc.py +0 -0
  34. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/i18n.py +0 -0
  35. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/ipc.py +0 -0
  36. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/labels.py +0 -0
  37. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  38. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  39. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/ring.pot +0 -0
  40. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/__init__.py +0 -0
  41. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/base.py +0 -0
  42. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/command.py +0 -0
  43. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/notify_send.py +0 -0
  44. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/ntfy.py +0 -0
  45. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/osascript_notifier.py +0 -0
  46. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/terminal_notifier.py +0 -0
  47. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/webhook.py +0 -0
  48. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/osascript.py +0 -0
  49. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/permission.py +0 -0
  50. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/plugins.py +0 -0
  51. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/base.py +0 -0
  52. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/hook_registry.py +0 -0
  53. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/stats.py +0 -0
  54. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/transcript.py +0 -0
  55. {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/tui.py +0 -0
  56. {ring_cli-0.11.2 → ring_cli-0.11.4}/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.2
3
+ Version: 0.11.4
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
@@ -319,7 +319,9 @@ Claude Code events:
319
319
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
320
320
  | `SessionEnd` | removed from the board |
321
321
 
322
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`.
322
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
323
+ also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
324
+ 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
323
325
 
324
326
  Verify that hooks are writing:
325
327
 
@@ -294,7 +294,9 @@ Claude Code events:
294
294
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
295
295
  | `SessionEnd` | removed from the board |
296
296
 
297
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`.
297
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
298
+ also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
299
+ 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
298
300
 
299
301
  Verify that hooks are writing:
300
302
 
@@ -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.2"
5
+ version = "0.11.4"
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"
@@ -44,8 +44,9 @@ from ring.transcript import _extract_todo, _latest_action, _tail_records
44
44
  _HOOK_EVENTS = list(HOOK_EVENTS)
45
45
 
46
46
  # Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。
47
- # 保守取有實證可用的:PermissionRequest(→ 🔴 等核可)、PreToolUse(→ 動作/清除)、
48
- # Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。
47
+ # 保守取有實證可用的:PermissionRequest(裸事件只代表權限準備判定;明確需互動才 → 🔴)、
48
+ # PreToolUse(→ 動作/清除)、Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件
49
+ # 有風險,故不照搬 Claude 全套。
49
50
  _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"]
50
51
 
51
52
  # hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會
@@ -105,6 +105,13 @@ class CommonHookAdapter:
105
105
  status = Status.WORKING
106
106
  elif explicit_requires_action is not None:
107
107
  status = Status.WAITING if explicit_requires_action else Status.IDLE
108
+ elif event == "PermissionRequest" and self.provider == "codex":
109
+ # Codex 會在權限「準備判定」時送 PermissionRequest;即使既有 policy
110
+ # 隨後自動放行、畫面從未停下來等人,也會經過這個 hook。裸事件因此只能
111
+ # 證明 agent 還在處理工具呼叫,不能當成使用者需要回應。若 payload 有
112
+ # requires_action / waiting_for 等明確訊號,已由上面的分支判成 WAITING;
113
+ # 明確的 requires_action=false 也在上面判成 IDLE,不會落到這裡的 WORKING。
114
+ status = Status.WORKING
108
115
  elif event == "Notification":
109
116
  status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE
110
117
  elif event == "PreToolUse":
@@ -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,38 +702,53 @@ def running_claude_pids() -> list[int]:
633
702
  return pids
634
703
 
635
704
 
636
- def running_foreground_claude_pids() -> list[int]:
705
+ def running_foreground_claude_pids() -> list[int] | None:
637
706
  """目前仍有可聚焦終端的 Claude session pid。
638
707
 
639
708
  ``running_claude_pids`` 也包含 agents mode 的背景 session,因為 scan 需要靠它們
640
709
  找到對應 transcript;但 hook registry 不能拿背景 agent 的 cwd/數量替同專案的
641
710
  舊前景 row 證明存活,否則一個 agent 就可能讓 crash 數小時的 session 繼續顯示。
711
+
712
+ 回傳 ``None`` 代表這輪掃描失敗(未知),呼叫端不得當空清單處理。
642
713
  """
643
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
644
720
  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())}
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)}
647
726
  return [
648
727
  pid
649
- for pid in running_claude_pids()
728
+ for pid in base_pids
650
729
  if (session_id := _arg_session_id(args_by_pid.get(pid, ""))) is None or session_id not in bg_ids
651
730
  ]
652
731
 
653
732
 
654
- def background_agent_session_ids() -> set[str]:
733
+ def background_agent_session_ids() -> set[str] | None:
655
734
  """所有背景 agent(``--bg-pty-host`` 承載且已載入真 session)的 session-id 集合。
656
735
 
657
736
  給 ``discover_sessions()`` 對應貼 ``kind="agent"`` 標籤用。與 ``running_claude_pids``
658
737
  共用同一份 ``ps`` 快照(``_ps_claude_snapshot``),不額外多打一次 ``ps``。
738
+
739
+ 回傳 ``None`` 代表這輪掃描失敗(未知),不是「沒有背景 agent」。
659
740
  """
660
741
  global _bg_agent_session_ids_cache
661
742
  now = time.monotonic()
662
743
  if 0.0 <= now - _bg_agent_session_ids_cache[0] <= _SUBPROCESS_CACHE_TTL:
663
744
  return set(_bg_agent_session_ids_cache[1])
664
- 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)
665
749
  ids = frozenset(
666
750
  session_id
667
- for _pid, args, is_bg_host in entries
751
+ for _pid, _tty, args, is_bg_host in entries
668
752
  if is_bg_host and (session_id := _arg_session_id(args)) is not None
669
753
  )
670
754
  _bg_agent_session_ids_cache = (now, ids)
@@ -698,30 +782,77 @@ def _is_claude_background_process(args: str) -> bool:
698
782
  return False
699
783
 
700
784
 
701
- 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
+ """
702
828
  global _codex_pids_cache
703
829
  now = time.monotonic()
704
830
  if 0.0 <= now - _codex_pids_cache[0] <= _SUBPROCESS_CACHE_TTL:
705
831
  return _codex_pids_cache[1]
706
- try:
707
- out = subprocess.run(["ps", "-Ao", "pid=,comm=,args="], capture_output=True, text=True, timeout=3).stdout
708
- except (OSError, subprocess.SubprocessError):
709
- out = ""
832
+ snapshot = _ps_codex_snapshot()
833
+ if snapshot is None:
834
+ return None
710
835
  pids: list[int] = []
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
717
- try:
718
- pids.append(int(parts[0]))
719
- except ValueError:
720
- 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)
721
840
  _codex_pids_cache = (now, pids)
722
841
  return pids
723
842
 
724
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
+
725
856
  def _is_codex_internal_process(args: str) -> bool:
726
857
  """Codex app 為工具呼叫啟動的同名內部 process,不是獨立互動 session。"""
727
858
  tokens = args.split()
@@ -735,24 +866,51 @@ def _is_codex_internal_process(args: str) -> bool:
735
866
 
736
867
 
737
868
  def running_agent_pids() -> list[int]:
738
- """所有內建來源看得到的 live agent CLI 行程。"""
739
- 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 [])]
876
+
740
877
 
878
+ def _pids_cwd(pids: list[int]) -> dict[int, str] | None:
879
+ """批次查多個 pid 的 cwd:一次 ``lsof -a -p pid1,pid2,... -d cwd -Fn``(N 次 → 1 次)。
741
880
 
742
- def _pid_cwd(pid: int) -> str:
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 {}
743
894
  try:
744
895
  out = subprocess.run(
745
- ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
896
+ ["lsof", "-a", "-p", ",".join(str(pid) for pid in pids), "-d", "cwd", "-Fn"],
746
897
  capture_output=True,
747
898
  text=True,
748
899
  timeout=3,
749
900
  ).stdout
750
901
  except (OSError, subprocess.SubprocessError):
751
- return ""
902
+ return None
903
+ result: dict[int, str] = {}
904
+ current_pid: int | None = None
752
905
  for line in out.splitlines():
753
- if line.startswith("n"):
754
- return line[1:]
755
- 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
756
914
 
757
915
 
758
916
  def _real(path: str) -> str:
@@ -796,41 +954,70 @@ def _has_ancestor_live_process(row_cwd: str, live_cwds: list[str]) -> bool:
796
954
 
797
955
 
798
956
  def _pid_tty(pid: int) -> str:
799
- """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
+ """
800
962
  try:
801
- tty = subprocess.run(
802
- ["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=3
803
- ).stdout.strip()
963
+ tty = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)], capture_output=True, text=True, timeout=3).stdout
804
964
  except (OSError, subprocess.SubprocessError):
805
965
  return ""
806
- if not tty or tty in ("??", "?"):
807
- return ""
808
- return tty if tty.startswith("/dev/") else f"/dev/{tty}"
966
+ return _normalize_tty(tty)
809
967
 
810
968
 
811
- def _claude_procs() -> list[tuple[str, str]]:
969
+ def _claude_procs() -> list[tuple[str, str]] | None:
812
970
  """每個可聚焦的前景 Claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
813
971
 
814
972
  背景 agent 不在這裡按 cwd 配對;它們由 source 依 process args 的 session id 精準
815
973
  認領,避免背景 process 的啟動 cwd 讓同專案舊 transcript 誤判成仍在場。
816
974
  同一個 cwd 可能同時開好幾個 session,所以之後在 cwd 群組裡只有 mtime 最新的
817
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 版本一致。
818
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
819
994
  procs: list[tuple[str, str]] = []
820
- for pid in running_foreground_claude_pids():
821
- cwd = _pid_cwd(pid)
995
+ for pid in pids:
996
+ cwd = cwd_by_pid.get(pid, "")
822
997
  if cwd:
823
- procs.append((cwd, _pid_tty(pid)))
998
+ procs.append((cwd, tty_by_pid.get(pid, "")))
824
999
  return procs
825
1000
 
826
1001
 
827
- def _codex_procs() -> list[tuple[str, str]]:
828
- """每個還活著的 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
829
1016
  procs: list[tuple[str, str]] = []
830
- for pid in running_codex_pids():
831
- cwd = _pid_cwd(pid)
1017
+ for pid in pids:
1018
+ cwd = cwd_by_pid.get(pid, "")
832
1019
  if cwd:
833
- procs.append((cwd, _pid_tty(pid)))
1020
+ procs.append((cwd, tty_by_pid.get(pid, "")))
834
1021
  return procs
835
1022
 
836
1023
 
@@ -1090,7 +1277,7 @@ def _synthetic_sessions(procs: list[tuple[str, str]], existing: list[Session]) -
1090
1277
  out: list[Session] = []
1091
1278
  seen: set[str] = set()
1092
1279
  for cwd, tty in procs:
1093
- if not cwd: # _pid_cwd 失敗的情況,沒 cwd 撐不起一列
1280
+ if not cwd: # _pids_cwd 查無此 pid cwd 的情況,沒 cwd 撐不起一列
1094
1281
  continue
1095
1282
  rkey = _real(cwd)
1096
1283
  if rkey in existing_cwds: # 已經有 row 了(hook 或 scan 覆蓋)
@@ -1119,7 +1306,7 @@ def _synthetic_sessions(procs: list[tuple[str, str]], existing: list[Session]) -
1119
1306
  def _hook_sessions(
1120
1307
  procs: list[tuple[str, str]] | None = None,
1121
1308
  *,
1122
- procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
1309
+ procs_by_provider: dict[str, list[tuple[str, str]] | None] | None = None,
1123
1310
  purge_session_start_phantoms: bool = True,
1124
1311
  ) -> list[Session]:
1125
1312
  if not RING_REGISTRY.is_dir():
@@ -1173,12 +1360,27 @@ def _hook_sessions(
1173
1360
  # 3. 該 cwd 只有「單一」hook row 時,無論 tty 是否對得上都不靠 tty 殺——hook 寫進來
1174
1361
  # 的 tty 不一定可靠(終端 tty 會被作業系統重配,甚至跨 session 錯置),拿它隱藏
1175
1362
  # 唯一活著的 session 會讓整列憑空消失。
1363
+ # 4. 「這個 provider 這輪 ps/lsof 掃描失敗」(值為 ``None``)是「未知」,不是
1364
+ # 「真的偵測到零個 live process」——未知時完全不動這個 provider 底下任何一筆
1365
+ # row 的狀態,保留既有狀態,避免單次系統瞬間卡頓(ps timeout)把整版 session
1366
+ # 誤判 ENDED(見 ring-vanishing-sessions 診斷)。
1176
1367
  if out:
1177
1368
  proc_counts: dict[tuple[str, str], int] = {}
1178
1369
  proc_ttys: dict[tuple[str, str], set[str]] = {}
1179
1370
  proc_cwds_by_provider: dict[str, list[str]] = {}
1371
+ unknown_providers: set[str] = set()
1180
1372
  for pk in _PROVIDER_PROCS:
1181
- 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
1182
1384
  for cwd, tty in provider_procs:
1183
1385
  real_cwd = _real(cwd)
1184
1386
  key = (pk, real_cwd)
@@ -1190,7 +1392,10 @@ def _hook_sessions(
1190
1392
  # 背景 agent 的 process 沒有可聚焦終端,不能拿來替同 cwd 的舊前景 hook row
1191
1393
  # 證明存活;它只精準認領 args 裡明載的 session id。Claude scan 仍使用包含背景
1192
1394
  # agent 的 session-id 配對,因此沒有 hook row 的 agent 也不會從看板消失。
1193
- background_ids = background_agent_session_ids()
1395
+ # 這次呼叫失敗(``None``=未知)時保守當成「沒有已知背景 agent」——claude-code
1396
+ # 這個 provider 本身這輪多半也偵測失敗,會被上面的 unknown_providers 整批保護,
1397
+ # 這裡的 fallback 只是避免拿 None 做 in 運算炸掉。
1398
+ background_ids = background_agent_session_ids() or set()
1194
1399
  rows_by_key: dict[tuple[str, str], list[Session]] = {}
1195
1400
  for s in out:
1196
1401
  pk = _canonical_provider(s.provider)
@@ -1201,9 +1406,11 @@ def _hook_sessions(
1201
1406
  rows_by_key.setdefault((pk, _real(s.cwd)), []).append(s)
1202
1407
 
1203
1408
  for key, rows in rows_by_key.items():
1409
+ pk, row_cwd = key
1410
+ if pk in unknown_providers:
1411
+ continue # 這輪掃描失敗,未知不等於離場,保留既有狀態
1204
1412
  live_n = proc_counts.get(key, 0)
1205
1413
  if live_n == 0:
1206
- pk, row_cwd = key
1207
1414
  if _has_ancestor_live_process(row_cwd, proc_cwds_by_provider.get(pk, [])):
1208
1415
  # hook payload 的 cwd 落在使用者 cd 進去的子目錄,但 claude process 實際
1209
1416
  # cwd(lsof 量到的)仍停在啟動目錄——兩者都正規化過,子目錄底下自然量不到
@@ -56,7 +56,9 @@ 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"
@@ -31,7 +31,13 @@ class ClaudeCodeSource:
31
31
  # 背景 agent 的 process cwd 可能仍是 daemon/啟動目錄,不能拿來按 cwd 猜哪些
32
32
  # 前景 transcript 活著。前景先只用可聚焦 process 判活,背景再按 session id 認領。
33
33
  procs = registry._claude_procs()
34
- agent_ids = registry.background_agent_session_ids()
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()
35
41
  merged: dict[str, Session] = {}
36
42
  scanned = registry._scan_sessions(procs)
37
43
  _activate_background_agents(scanned, agent_ids)
@@ -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()
@@ -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