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.
- {ring_cli-0.11.2 → ring_cli-0.11.4}/PKG-INFO +4 -2
- {ring_cli-0.11.2 → ring_cli-0.11.4}/README.en.md +3 -1
- {ring_cli-0.11.2 → ring_cli-0.11.4}/pyproject.toml +1 -1
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/hook.py +3 -2
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/hook_protocol.py +7 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/registry.py +279 -72
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/__init__.py +3 -1
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/claude_code.py +7 -1
- ring_cli-0.11.4/src/ring/sources/codex.py +21 -0
- ring_cli-0.11.2/src/ring/sources/codex.py +0 -16
- {ring_cli-0.11.2 → ring_cli-0.11.4}/LICENSE +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/__init__.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/__main__.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/cli.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/config.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/base.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/gc.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/i18n.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/ipc.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/labels.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/base.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/command.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/osascript.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/permission.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/plugins.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/base.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/stats.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/transcript.py +0 -0
- {ring_cli-0.11.2 → ring_cli-0.11.4}/src/ring/tui.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
48
|
-
# Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex
|
|
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
|
-
|
|
595
|
+
result = subprocess.run(["ps", "-Ao", "pid,tty,comm,args"], capture_output=True, text=True, timeout=3)
|
|
560
596
|
except (OSError, subprocess.SubprocessError):
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
567
|
-
"""把 ``ps``
|
|
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,
|
|
576
|
-
if len(parts) <
|
|
625
|
+
parts = line.split(None, 3)
|
|
626
|
+
if len(parts) < 3:
|
|
577
627
|
continue
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
|
646
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
out = ""
|
|
832
|
+
snapshot = _ps_codex_snapshot()
|
|
833
|
+
if snapshot is None:
|
|
834
|
+
return None
|
|
710
835
|
pids: list[int] = []
|
|
711
|
-
for
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
|
821
|
-
cwd =
|
|
995
|
+
for pid in pids:
|
|
996
|
+
cwd = cwd_by_pid.get(pid, "")
|
|
822
997
|
if cwd:
|
|
823
|
-
procs.append((cwd,
|
|
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
|
|
831
|
-
cwd =
|
|
1017
|
+
for pid in pids:
|
|
1018
|
+
cwd = cwd_by_pid.get(pid, "")
|
|
832
1019
|
if cwd:
|
|
833
|
-
procs.append((cwd,
|
|
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: #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|