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.
- {ring_cli-0.11.1 → ring_cli-0.11.3}/PKG-INFO +4 -1
- {ring_cli-0.11.1 → ring_cli-0.11.3}/README.en.md +3 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/pyproject.toml +1 -1
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/registry.py +313 -66
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/__init__.py +8 -1
- ring_cli-0.11.3/src/ring/sources/claude_code.py +52 -0
- ring_cli-0.11.3/src/ring/sources/codex.py +21 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/tui.py +29 -0
- ring_cli-0.11.1/src/ring/sources/claude_code.py +0 -23
- ring_cli-0.11.1/src/ring/sources/codex.py +0 -16
- {ring_cli-0.11.1 → ring_cli-0.11.3}/LICENSE +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/__init__.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/__main__.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/cli.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/config.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/base.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/gc.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/hook.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/i18n.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/ipc.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/labels.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/base.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/command.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/osascript.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/permission.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/plugins.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/base.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/stats.py +0 -0
- {ring_cli-0.11.1 → ring_cli-0.11.3}/src/ring/transcript.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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,20 +702,53 @@ def running_claude_pids() -> list[int]:
|
|
|
633
702
|
return pids
|
|
634
703
|
|
|
635
704
|
|
|
636
|
-
def
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
out = ""
|
|
832
|
+
snapshot = _ps_codex_snapshot()
|
|
833
|
+
if snapshot is None:
|
|
834
|
+
return None
|
|
692
835
|
pids: list[int] = []
|
|
693
|
-
for
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
969
|
+
def _claude_procs() -> list[tuple[str, str]] | None:
|
|
970
|
+
"""每個可聚焦的前景 Claude:(cwd, tty)。cwd 判活躍/分流,tty 給終端跳轉。
|
|
780
971
|
|
|
781
|
-
|
|
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
|
|
787
|
-
cwd =
|
|
995
|
+
for pid in pids:
|
|
996
|
+
cwd = cwd_by_pid.get(pid, "")
|
|
788
997
|
if cwd:
|
|
789
|
-
procs.append((cwd,
|
|
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
|
|
797
|
-
cwd =
|
|
1017
|
+
for pid in pids:
|
|
1018
|
+
cwd = cwd_by_pid.get(pid, "")
|
|
798
1019
|
if cwd:
|
|
799
|
-
procs.append((cwd,
|
|
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: #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|