ring-cli 0.3.0__tar.gz → 0.5.0__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.3.0 → ring_cli-0.5.0}/PKG-INFO +13 -9
- {ring_cli-0.3.0 → ring_cli-0.5.0}/README.en.md +11 -7
- {ring_cli-0.3.0 → ring_cli-0.5.0}/pyproject.toml +2 -2
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/cli.py +2 -20
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/config.py +1 -1
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/__init__.py +11 -3
- ring_cli-0.5.0/src/ring/focus/linux_wm.py +117 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/hook.py +42 -7
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/registry.py +9 -193
- ring_cli-0.5.0/src/ring/transcript.py +204 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/tui.py +2 -7
- {ring_cli-0.3.0 → ring_cli-0.5.0}/LICENSE +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/gc.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/labels.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.3.0 → ring_cli-0.5.0}/src/ring/watcher.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
7
7
|
Author-email: Wei Lee <weilee.rx@gmail.com>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Operating System :: MacOS
|
|
@@ -57,8 +57,8 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
57
57
|
|
|
58
58
|
- **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
|
|
59
59
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
60
|
-
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2,
|
|
61
|
-
- **
|
|
60
|
+
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
|
|
61
|
+
- **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
|
|
62
62
|
- **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
|
|
63
63
|
- **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
|
|
64
64
|
|
|
@@ -133,14 +133,17 @@ Select a session and press `Enter`. RiNG focuses the terminal where that session
|
|
|
133
133
|
|
|
134
134
|
- **tmux**: switches directly to the pane via `switch-client`.
|
|
135
135
|
- **iTerm2 / Terminal.app** on macOS: uses the session `tty` and AppleScript to focus the matching tab. The first run may ask for macOS Automation permission.
|
|
136
|
+
- **Linux X11 window** (`wmctrl`, best-effort fallback): for Linux without tmux — walks from the `tty` up to the terminal window that owns it and raises it via `wmctrl`. **Limits**: X11 only (usually a no-op on Wayland), raises the whole window but cannot pick the tab, and gnome-terminal's client/server model may not match. Requires `apt install wmctrl`.
|
|
136
137
|
|
|
137
138
|
TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
|
|
138
139
|
one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
|
|
139
140
|
|
|
140
141
|
### Notifications
|
|
141
142
|
|
|
142
|
-
Default behavior: with hooks installed,
|
|
143
|
-
a system notification
|
|
143
|
+
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
144
|
+
and sends a system notification right then — **no RiNG board needs to be open**, so it rings you even
|
|
145
|
+
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
146
|
+
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
144
147
|
|
|
145
148
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
146
149
|
|
|
@@ -266,7 +269,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
|
|
|
266
269
|
|
|
267
270
|
### agent-hooks
|
|
268
271
|
|
|
269
|
-
Notification details:
|
|
272
|
+
Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
|
|
273
|
+
the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
|
|
270
274
|
RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
|
|
271
275
|
on Linux. If none are available, RiNG keeps the board running and skips notifications.
|
|
272
276
|
|
|
@@ -307,7 +311,7 @@ notify_ignore_dnd = false
|
|
|
307
311
|
notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
|
|
308
312
|
notify_repeat_seconds = [30, 120, 300]
|
|
309
313
|
notify_repeat_max = 3
|
|
310
|
-
focusers = ["tmux", "iTerm2", "Terminal"]
|
|
314
|
+
focusers = ["tmux", "iTerm2", "Terminal", "linux-wm"]
|
|
311
315
|
|
|
312
316
|
[colors]
|
|
313
317
|
waiting = "bold red"
|
|
@@ -326,7 +330,7 @@ RiNG is not tied to a specific tool or terminal.
|
|
|
326
330
|
| Extension Point | Purpose | Built-ins |
|
|
327
331
|
|-----------------|---------|-----------|
|
|
328
332
|
| `SessionSource` | find sessions | Claude Code, Codex, hook registry |
|
|
329
|
-
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app |
|
|
333
|
+
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
|
|
330
334
|
| `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send |
|
|
331
335
|
|
|
332
336
|
Each backend is a small module under `ring/sources/`, `ring/focus/`, or `ring/notify/`, registered via `register_*()`.
|
|
@@ -32,8 +32,8 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
32
32
|
|
|
33
33
|
- **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
|
|
34
34
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
35
|
-
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2,
|
|
36
|
-
- **
|
|
35
|
+
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
|
|
36
|
+
- **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
|
|
37
37
|
- **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
|
|
38
38
|
- **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
|
|
39
39
|
|
|
@@ -108,14 +108,17 @@ Select a session and press `Enter`. RiNG focuses the terminal where that session
|
|
|
108
108
|
|
|
109
109
|
- **tmux**: switches directly to the pane via `switch-client`.
|
|
110
110
|
- **iTerm2 / Terminal.app** on macOS: uses the session `tty` and AppleScript to focus the matching tab. The first run may ask for macOS Automation permission.
|
|
111
|
+
- **Linux X11 window** (`wmctrl`, best-effort fallback): for Linux without tmux — walks from the `tty` up to the terminal window that owns it and raises it via `wmctrl`. **Limits**: X11 only (usually a no-op on Wayland), raises the whole window but cannot pick the tab, and gnome-terminal's client/server model may not match. Requires `apt install wmctrl`.
|
|
111
112
|
|
|
112
113
|
TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
|
|
113
114
|
one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
|
|
114
115
|
|
|
115
116
|
### Notifications
|
|
116
117
|
|
|
117
|
-
Default behavior: with hooks installed,
|
|
118
|
-
a system notification
|
|
118
|
+
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
119
|
+
and sends a system notification right then — **no RiNG board needs to be open**, so it rings you even
|
|
120
|
+
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
121
|
+
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
119
122
|
|
|
120
123
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
121
124
|
|
|
@@ -241,7 +244,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
|
|
|
241
244
|
|
|
242
245
|
### agent-hooks
|
|
243
246
|
|
|
244
|
-
Notification details:
|
|
247
|
+
Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
|
|
248
|
+
the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
|
|
245
249
|
RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
|
|
246
250
|
on Linux. If none are available, RiNG keeps the board running and skips notifications.
|
|
247
251
|
|
|
@@ -282,7 +286,7 @@ notify_ignore_dnd = false
|
|
|
282
286
|
notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
|
|
283
287
|
notify_repeat_seconds = [30, 120, 300]
|
|
284
288
|
notify_repeat_max = 3
|
|
285
|
-
focusers = ["tmux", "iTerm2", "Terminal"]
|
|
289
|
+
focusers = ["tmux", "iTerm2", "Terminal", "linux-wm"]
|
|
286
290
|
|
|
287
291
|
[colors]
|
|
288
292
|
waiting = "bold red"
|
|
@@ -301,7 +305,7 @@ RiNG is not tied to a specific tool or terminal.
|
|
|
301
305
|
| Extension Point | Purpose | Built-ins |
|
|
302
306
|
|-----------------|---------|-----------|
|
|
303
307
|
| `SessionSource` | find sessions | Claude Code, Codex, hook registry |
|
|
304
|
-
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app |
|
|
308
|
+
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
|
|
305
309
|
| `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send |
|
|
306
310
|
|
|
307
311
|
Each backend is a small module under `ring/sources/`, `ring/focus/`, or `ring/notify/`, registered via `register_*()`.
|
|
@@ -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.
|
|
5
|
+
version = "0.5.0"
|
|
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"
|
|
@@ -11,7 +11,7 @@ license-files = ["LICENSE"]
|
|
|
11
11
|
authors = [{ name = "Wei Lee", email = "weilee.rx@gmail.com" }]
|
|
12
12
|
keywords = ["claude-code", "codex", "tui", "dashboard", "session", "monitor", "rich"]
|
|
13
13
|
classifiers = [
|
|
14
|
-
"Development Status ::
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
15
|
"Environment :: Console",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Operating System :: MacOS",
|
|
@@ -331,25 +331,15 @@ def run_config(args: list[str]) -> int:
|
|
|
331
331
|
|
|
332
332
|
|
|
333
333
|
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
cfg = get_config()
|
|
334
|
+
# 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
|
|
335
|
+
# watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
|
|
338
336
|
frames = 0
|
|
339
337
|
footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
|
|
340
338
|
if not HAVE_RICH:
|
|
341
|
-
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
342
339
|
try:
|
|
343
340
|
while True:
|
|
344
341
|
sys.stdout.write("\033[2J\033[H")
|
|
345
342
|
sessions = board(show_all)
|
|
346
|
-
alerts = scheduler.feed(sessions)
|
|
347
|
-
try:
|
|
348
|
-
hint = notify_waiting(alerts)
|
|
349
|
-
if hint:
|
|
350
|
-
print(hint)
|
|
351
|
-
except Exception:
|
|
352
|
-
pass
|
|
353
343
|
print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
|
|
354
344
|
print(f"\n{footer_text}")
|
|
355
345
|
sys.stdout.flush()
|
|
@@ -361,18 +351,10 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
|
|
|
361
351
|
return 0
|
|
362
352
|
|
|
363
353
|
console = Console()
|
|
364
|
-
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
365
354
|
try:
|
|
366
355
|
with Live(console=console, screen=True, auto_refresh=False) as live:
|
|
367
356
|
while True:
|
|
368
357
|
sessions = board(show_all)
|
|
369
|
-
alerts = scheduler.feed(sessions)
|
|
370
|
-
try:
|
|
371
|
-
hint = notify_waiting(alerts)
|
|
372
|
-
if hint:
|
|
373
|
-
print(hint)
|
|
374
|
-
except Exception:
|
|
375
|
-
pass
|
|
376
358
|
body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
|
|
377
359
|
live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
|
|
378
360
|
frames += 1
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
# "none" = 完全不發通知(RiNG 當純看板)
|
|
21
21
|
notify_repeat_seconds = [30, 120, 300] # 持續等你時,多久後重複提醒
|
|
22
22
|
notify_repeat_max = 3 # 重複提醒上限;0 = 不限
|
|
23
|
-
focusers = ["tmux", "iTerm2", "Terminal"]
|
|
23
|
+
focusers = ["tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
每個終端是一個 ``Focuser``(見 ``base.Focuser``):core 不認識任何具體終端,
|
|
4
4
|
只依序問每個 focuser「這個 session 歸不歸你管」。要支援新終端=寫一個模組、
|
|
5
|
-
``register_focuser()`` 註冊,core 零改動。內建:tmux / iTerm2 / Terminal.app
|
|
5
|
+
``register_focuser()`` 註冊,core 零改動。內建:tmux / iTerm2 / Terminal.app /
|
|
6
|
+
Linux X11 視窗(wmctrl,best-effort fallback)。
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -10,13 +11,20 @@ from __future__ import annotations
|
|
|
10
11
|
from ring.config import get_config
|
|
11
12
|
from ring.focus.base import Focuser
|
|
12
13
|
from ring.focus.iterm2 import focuser as _iterm2
|
|
14
|
+
from ring.focus.linux_wm import focuser as _linux_wm
|
|
13
15
|
from ring.focus.terminal import focuser as _terminal
|
|
14
16
|
from ring.focus.tmux import focuser as _tmux
|
|
15
17
|
from ring.i18n import gettext as _
|
|
16
18
|
from ring.registry import Session, Status
|
|
17
19
|
|
|
18
|
-
# 內建 focuser。順序可由 config 的 `focusers` 覆寫。
|
|
19
|
-
|
|
20
|
+
# 內建 focuser。順序可由 config 的 `focusers` 覆寫。tmux 跨平台、最快,排最前;
|
|
21
|
+
# macOS app 在非 macOS 會自己 return None;linux-wm 殿後當 X11 fallback。
|
|
22
|
+
_BUILTIN: dict[str, Focuser] = {
|
|
23
|
+
"tmux": _tmux,
|
|
24
|
+
"iTerm2": _iterm2,
|
|
25
|
+
"Terminal": _terminal,
|
|
26
|
+
"linux-wm": _linux_wm,
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
|
|
22
30
|
def _initial_focusers() -> list[Focuser]:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Linux X11 視窗 focuser(best-effort):用 session 的 tty 找到擁有它的終端視窗、帶到前景。
|
|
2
|
+
|
|
3
|
+
定位是 macOS 之外的 fallback——Linux 上沒跑 tmux 的 session 原本完全跳不回去
|
|
4
|
+
(tmux 已由 tmux focuser 處理、iTerm2 / Terminal.app 是 macOS 限定),這個 focuser 補上那個洞。
|
|
5
|
+
|
|
6
|
+
機制與限制(誠實標明):
|
|
7
|
+
- 只在 Linux 且裝了 ``wmctrl`` 時接手;否則回 ``None`` 交給別的 focuser。
|
|
8
|
+
- 從 tty 上的 process 往上追到「擁有 X11 視窗」的祖先(多數終端模擬器——xterm / konsole /
|
|
9
|
+
alacritty / kitty / xfce4-terminal——是 shell 的祖先),用 ``wmctrl`` 把那個視窗帶到前景。
|
|
10
|
+
- **X11 限定**:Wayland 下 ``wmctrl`` 通常無效。
|
|
11
|
+
- **無法選分頁**:只能把整個終端視窗帶到前景,無法切到正確分頁(X11 沒有跨終端的分頁 API)。
|
|
12
|
+
- gnome-terminal 走 client/server,視窗 pid 不在 shell 的祖先鏈上,可能配不到(回 ``None``)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from ring.registry import Session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(cmd: list[str]) -> str | None:
|
|
25
|
+
"""跑一個唯讀/聚焦指令,成功回 stdout(可能為空字串),失敗回 ``None``。"""
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
28
|
+
except (OSError, subprocess.SubprocessError):
|
|
29
|
+
return None
|
|
30
|
+
return result.stdout if result.returncode == 0 else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tty_pids(tty: str) -> list[int]:
|
|
34
|
+
"""某 tty 上的所有 process pid。``tty`` 形如 ``/dev/pts/3``。"""
|
|
35
|
+
out = _run(["ps", "-o", "pid=", "-t", tty.removeprefix("/dev/")])
|
|
36
|
+
if out is None:
|
|
37
|
+
return []
|
|
38
|
+
pids = []
|
|
39
|
+
for tok in out.split():
|
|
40
|
+
try:
|
|
41
|
+
pids.append(int(tok))
|
|
42
|
+
except ValueError:
|
|
43
|
+
continue
|
|
44
|
+
return pids
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ppid_map() -> dict[int, int]:
|
|
48
|
+
"""全系統 pid → ppid 對照表。"""
|
|
49
|
+
out = _run(["ps", "-eo", "pid=,ppid="])
|
|
50
|
+
if out is None:
|
|
51
|
+
return {}
|
|
52
|
+
table: dict[int, int] = {}
|
|
53
|
+
for line in out.splitlines():
|
|
54
|
+
parts = line.split()
|
|
55
|
+
if len(parts) != 2:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
table[int(parts[0])] = int(parts[1])
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
return table
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ancestors(pid: int, ppid_map: dict[int, int]) -> list[int]:
|
|
65
|
+
"""``pid`` 自身 + 一路往上的祖先(由近到遠),遇環或到 init(1) 即止。"""
|
|
66
|
+
chain: list[int] = []
|
|
67
|
+
seen: set[int] = set()
|
|
68
|
+
cur = pid
|
|
69
|
+
while cur and cur > 1 and cur not in seen:
|
|
70
|
+
seen.add(cur)
|
|
71
|
+
chain.append(cur)
|
|
72
|
+
cur = ppid_map.get(cur, 0)
|
|
73
|
+
return chain
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _window_pids() -> dict[int, str]:
|
|
77
|
+
"""``wmctrl -lp`` 解析:擁有 X11 視窗的 pid → window id(同 pid 多視窗取第一個)。"""
|
|
78
|
+
out = _run(["wmctrl", "-lp"])
|
|
79
|
+
if out is None:
|
|
80
|
+
return {}
|
|
81
|
+
mapping: dict[int, str] = {}
|
|
82
|
+
for line in out.splitlines():
|
|
83
|
+
parts = line.split(None, 4) # winid desktop pid host title
|
|
84
|
+
if len(parts) < 4:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
pid = int(parts[2])
|
|
88
|
+
except ValueError:
|
|
89
|
+
continue
|
|
90
|
+
mapping.setdefault(pid, parts[0])
|
|
91
|
+
return mapping
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LinuxWMFocuser:
|
|
95
|
+
name = "linux-wm"
|
|
96
|
+
|
|
97
|
+
def try_focus(self, session: Session) -> tuple[bool, str] | None:
|
|
98
|
+
tty = session.tty
|
|
99
|
+
if not sys.platform.startswith("linux") or not tty or not shutil.which("wmctrl"):
|
|
100
|
+
return None
|
|
101
|
+
tty_pids = _tty_pids(tty)
|
|
102
|
+
if not tty_pids:
|
|
103
|
+
return None
|
|
104
|
+
ppid_map = _ppid_map()
|
|
105
|
+
win_pids = _window_pids()
|
|
106
|
+
for start in tty_pids:
|
|
107
|
+
for pid in _ancestors(start, ppid_map):
|
|
108
|
+
winid = win_pids.get(pid)
|
|
109
|
+
if not winid:
|
|
110
|
+
continue
|
|
111
|
+
if _run(["wmctrl", "-i", "-a", winid]) is not None:
|
|
112
|
+
return True, f"{self.name} {winid}"
|
|
113
|
+
return False, f"wmctrl activate failed for {winid}"
|
|
114
|
+
return None # 配不到視窗 → 交給別的 focuser,最後由 jump() 回報 tty 可能已關閉
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
focuser = LinuxWMFocuser()
|
|
@@ -33,12 +33,11 @@ from ring.i18n import gettext as _
|
|
|
33
33
|
from ring.i18n import set_lang
|
|
34
34
|
from ring.registry import (
|
|
35
35
|
RING_REGISTRY,
|
|
36
|
+
Session,
|
|
36
37
|
Status,
|
|
37
|
-
_extract_todo,
|
|
38
|
-
_latest_action,
|
|
39
38
|
_pid_tty,
|
|
40
|
-
_tail_records,
|
|
41
39
|
)
|
|
40
|
+
from ring.transcript import _extract_todo, _latest_action, _tail_records
|
|
42
41
|
|
|
43
42
|
_HOOK_EVENTS = list(HOOK_EVENTS)
|
|
44
43
|
|
|
@@ -127,11 +126,13 @@ def _session_tty(process_names: tuple[str, ...]) -> str:
|
|
|
127
126
|
|
|
128
127
|
|
|
129
128
|
def run_hook(provider: str = "claude-code") -> int:
|
|
130
|
-
"""讀一次 stdin → 寫 RiNG registry
|
|
129
|
+
"""讀一次 stdin → 寫 RiNG registry 狀態(看板)→ 轉等你時就地發通知 →(可選)委派 agent-hooks。
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
轉 🔴 等你的事件,會在當下直接發系統通知(見 ``_ring_waiting_now``)——不必開著看板,
|
|
132
|
+
關掉終端也照樣 ring 你。委派只在 ``notify_backend == "agent-hooks"`` 且 PATH 上有
|
|
133
|
+
``agent-hooks`` 時發生:把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、
|
|
134
|
+
收按鈕、把決策寫到 stdout 回給 Claude(這條路下 ``_ring_waiting_now`` 自動短路、不重複發)。
|
|
135
|
+
其餘情況 RiNG 記狀態 + 發通知後 exit 0(你在終端自己回答)。
|
|
135
136
|
"""
|
|
136
137
|
try:
|
|
137
138
|
raw = sys.stdin.read()
|
|
@@ -191,6 +192,40 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
|
|
|
191
192
|
tmp.write_text(json.dumps(payload))
|
|
192
193
|
tmp.replace(path) # atomic
|
|
193
194
|
|
|
195
|
+
if event.status is Status.WAITING:
|
|
196
|
+
_ring_waiting_now(event, payload, last_action)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
|
|
200
|
+
"""session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。
|
|
201
|
+
|
|
202
|
+
WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」
|
|
203
|
+
的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
|
|
204
|
+
你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
|
|
205
|
+
(agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。
|
|
206
|
+
失敗安靜吞掉,絕不擋住 session。
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
from ring.notify import notify_waiting
|
|
210
|
+
|
|
211
|
+
notify_waiting(
|
|
212
|
+
[
|
|
213
|
+
Session(
|
|
214
|
+
session_id=event.session_id,
|
|
215
|
+
cwd=event.cwd,
|
|
216
|
+
status=Status.WAITING,
|
|
217
|
+
last_active=float(payload.get("last_active", time.time())),
|
|
218
|
+
last_action=last_action,
|
|
219
|
+
source="hook",
|
|
220
|
+
tty=payload.get("tty"),
|
|
221
|
+
provider=event.provider,
|
|
222
|
+
origin_cwd=event.cwd,
|
|
223
|
+
)
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
194
229
|
|
|
195
230
|
def _delegate_to_agent_hooks(raw: str, selected_provider: str) -> int:
|
|
196
231
|
"""notify_backend=="agent-hooks" 且 agent-hooks 在 PATH → 透傳 payload 給它出決策。
|
|
@@ -31,6 +31,14 @@ from pathlib import Path
|
|
|
31
31
|
from typing import Any
|
|
32
32
|
|
|
33
33
|
from ring.config import get_config
|
|
34
|
+
from ring.transcript import (
|
|
35
|
+
_conversation_tail_kind,
|
|
36
|
+
_extract_todo,
|
|
37
|
+
_head_cwd,
|
|
38
|
+
_latest_action,
|
|
39
|
+
_recent_actions,
|
|
40
|
+
_tail_records,
|
|
41
|
+
)
|
|
34
42
|
|
|
35
43
|
CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
|
|
36
44
|
RING_REGISTRY = Path.home() / ".config" / "ring" / "sessions"
|
|
@@ -129,189 +137,6 @@ class Session:
|
|
|
129
137
|
return self.cwd.replace(home, "~", 1) if self.cwd.startswith(home) else self.cwd
|
|
130
138
|
|
|
131
139
|
|
|
132
|
-
def _tail_records(path: Path, max_tail: int = 128 * 1024) -> list[dict[str, Any]]:
|
|
133
|
-
"""讀 JSONL 檔尾若干筆合法 JSON 記錄(舊→新),只 seek 檔尾不整檔讀。"""
|
|
134
|
-
try:
|
|
135
|
-
size = path.stat().st_size
|
|
136
|
-
with path.open("rb") as f:
|
|
137
|
-
if size > max_tail:
|
|
138
|
-
f.seek(size - max_tail)
|
|
139
|
-
f.readline() # 丟掉被切一半的那行
|
|
140
|
-
chunk = f.read()
|
|
141
|
-
except OSError:
|
|
142
|
-
return []
|
|
143
|
-
out = []
|
|
144
|
-
for raw in chunk.splitlines():
|
|
145
|
-
raw = raw.strip()
|
|
146
|
-
if not raw:
|
|
147
|
-
continue
|
|
148
|
-
try:
|
|
149
|
-
out.append(json.loads(raw))
|
|
150
|
-
except json.JSONDecodeError:
|
|
151
|
-
continue
|
|
152
|
-
return out
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _head_cwd(path: Path, max_head: int = 128 * 1024) -> str:
|
|
156
|
-
"""讀 JSONL 檔頭,回傳第一筆含非空 ``cwd`` 欄位的紀錄之 cwd。
|
|
157
|
-
|
|
158
|
-
目的:修補 scan 模式「中途 cd 漂移」問題。Claude Code session 中途 ``cd``
|
|
159
|
-
後,新紀錄的 ``cwd`` 已指向目的地目錄,若只看檔尾(``_tail_records``)會
|
|
160
|
-
誤判 session 歸屬。改讀檔頭取「開場 cwd」才能把 session 留在它真正所屬的
|
|
161
|
-
專案列,而非漂到目的地專案。
|
|
162
|
-
|
|
163
|
-
跳過 JSONL 裡無 ``cwd`` 的 meta 筆(last-prompt、mode、permission-mode、
|
|
164
|
-
file-history-snapshot 等);命中即停,不必整檔讀完。
|
|
165
|
-
|
|
166
|
-
:param path: JSONL 檔案路徑。
|
|
167
|
-
:param max_head: 從檔頭最多讀取的位元組數,預設 128 KiB,足以涵蓋早期幾十筆紀錄。
|
|
168
|
-
:returns: 第一筆非空 cwd 字串;找不到(空檔、全無 cwd)回 ``""``。
|
|
169
|
-
"""
|
|
170
|
-
try:
|
|
171
|
-
with path.open("rb") as f:
|
|
172
|
-
chunk = f.read(max_head)
|
|
173
|
-
except OSError:
|
|
174
|
-
return ""
|
|
175
|
-
for raw in chunk.splitlines():
|
|
176
|
-
raw = raw.strip()
|
|
177
|
-
if not raw:
|
|
178
|
-
continue
|
|
179
|
-
try:
|
|
180
|
-
record = json.loads(raw)
|
|
181
|
-
except json.JSONDecodeError:
|
|
182
|
-
continue
|
|
183
|
-
cwd = record.get("cwd")
|
|
184
|
-
if cwd and isinstance(cwd, str):
|
|
185
|
-
return cwd
|
|
186
|
-
return ""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _blocks(record: dict[str, Any]) -> list[Any]:
|
|
190
|
-
msg = record.get("message")
|
|
191
|
-
if isinstance(msg, dict):
|
|
192
|
-
content = msg.get("content")
|
|
193
|
-
if isinstance(content, list):
|
|
194
|
-
return content
|
|
195
|
-
if isinstance(content, str):
|
|
196
|
-
return [{"type": "text", "text": content}]
|
|
197
|
-
return []
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
_CMD_NOISE = ("<local-command-stdout>", "<command-name>", "<command-message>", "<command-args>")
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _clean_text(s: str) -> str:
|
|
204
|
-
s = s.strip()
|
|
205
|
-
if any(marker in s for marker in _CMD_NOISE):
|
|
206
|
-
return "" # slash-command 的 stdout / 標記,不是真動作
|
|
207
|
-
return s.splitlines()[0].strip() if s else "" # 只取第一行,旁白長文不洗版
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _tool_summary(block: dict[str, Any]) -> str:
|
|
211
|
-
"""把一個 tool_use 變成簡短摘要,例如 → Edit foo.py / → Bash: git status。"""
|
|
212
|
-
name = str(block.get("name") or "")
|
|
213
|
-
if not name:
|
|
214
|
-
return ""
|
|
215
|
-
inp = block.get("input")
|
|
216
|
-
if not isinstance(inp, dict):
|
|
217
|
-
inp = {}
|
|
218
|
-
if name in ("Edit", "Write", "Read", "NotebookEdit"):
|
|
219
|
-
path = inp.get("file_path") or inp.get("notebook_path")
|
|
220
|
-
if path:
|
|
221
|
-
return f"→ {name} {Path(str(path)).name}"
|
|
222
|
-
elif name == "Bash":
|
|
223
|
-
cmd = str(inp.get("command") or "").strip().replace("\n", " ")
|
|
224
|
-
if cmd:
|
|
225
|
-
return f"→ Bash: {cmd[:40]}"
|
|
226
|
-
elif name in ("Grep", "Glob") and inp.get("pattern"):
|
|
227
|
-
return f"→ {name} {inp['pattern']}"
|
|
228
|
-
return f"→ {name}"
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _latest_action(records: list[dict[str, Any]]) -> str:
|
|
232
|
-
"""從新到舊找最近一個「真動作」:agent 的 tool_use 優先,其次 agent 的文字。
|
|
233
|
-
|
|
234
|
-
跳過 user 訊息、tool_result、slash-command 輸出這類雜訊。
|
|
235
|
-
"""
|
|
236
|
-
for record in reversed(records):
|
|
237
|
-
for block in _blocks(record):
|
|
238
|
-
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
239
|
-
summary = _tool_summary(block)
|
|
240
|
-
if summary:
|
|
241
|
-
return summary
|
|
242
|
-
if record.get("type") == "assistant":
|
|
243
|
-
for block in _blocks(record):
|
|
244
|
-
if isinstance(block, dict) and block.get("type") == "text":
|
|
245
|
-
txt = _clean_text(str(block.get("text", "")))
|
|
246
|
-
if txt:
|
|
247
|
-
return txt[:80]
|
|
248
|
-
return "—"
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None:
|
|
252
|
-
"""從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。"""
|
|
253
|
-
for record in reversed(records):
|
|
254
|
-
for block in _blocks(record):
|
|
255
|
-
if not isinstance(block, dict):
|
|
256
|
-
continue
|
|
257
|
-
if block.get("type") == "tool_use" and block.get("name") == "TodoWrite":
|
|
258
|
-
todos = (block.get("input") or {}).get("todos")
|
|
259
|
-
if isinstance(todos, list) and todos:
|
|
260
|
-
done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "completed")
|
|
261
|
-
return done, len(todos)
|
|
262
|
-
return None
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _conversation_tail_kind(records: list[dict[str, Any]]) -> str:
|
|
266
|
-
"""從對話尾巴往回走,判定最近一個「真訊息紀錄」的性質。
|
|
267
|
-
|
|
268
|
-
回傳值(str):
|
|
269
|
-
- ``"waiting"`` : 對話最後是 assistant end_turn 收尾,Claude 回完一輪。
|
|
270
|
-
- ``"working"`` : 對話最後是真人送出的 prompt,輪到 Claude 回應。
|
|
271
|
-
- ``"interrupted"`` : 工具呼叫進行中(assistant tool_use / user tool_result),尚未完成一輪。
|
|
272
|
-
- ``"none"`` : 沒有可判定的訊息(空 records、全噪音、無 assistant/user 訊息)。
|
|
273
|
-
|
|
274
|
-
演算法:從 ``reversed(records)`` 往回走,跳過噪音,找第一個真訊息紀錄,
|
|
275
|
-
依 type 與 content 判定後立即 return。絕不直接看 records[-1]——尾巴幾乎都是噪音。
|
|
276
|
-
|
|
277
|
-
限制(誠實標明):靠對話尾巴猜。Notification(卡權限等授權流程)在 JSONL 不留痕跡,
|
|
278
|
-
scan 模式測不到——那種需要使用者決策的狀態仍需 hook 模式偵測。
|
|
279
|
-
"""
|
|
280
|
-
for record in reversed(records):
|
|
281
|
-
t = record.get("type")
|
|
282
|
-
# --- 跳過噪音 ---
|
|
283
|
-
if t not in ("user", "assistant"):
|
|
284
|
-
continue # file-history-snapshot / system / permission-mode / mode / last-prompt 等
|
|
285
|
-
if record.get("isMeta") is True:
|
|
286
|
-
continue
|
|
287
|
-
if record.get("isSidechain") is True:
|
|
288
|
-
continue
|
|
289
|
-
# --- 第一個真訊息紀錄,依 type 判定 ---
|
|
290
|
-
if t == "assistant":
|
|
291
|
-
msg = record.get("message")
|
|
292
|
-
stop_reason = msg.get("stop_reason") if isinstance(msg, dict) else None
|
|
293
|
-
# assistant 帶 tool_use block → 工具呼叫進行中
|
|
294
|
-
if any(isinstance(b, dict) and b.get("type") == "tool_use" for b in _blocks(record)):
|
|
295
|
-
return "interrupted"
|
|
296
|
-
if stop_reason == "end_turn":
|
|
297
|
-
return "waiting"
|
|
298
|
-
# tool_use / stop_sequence / None / 其他 → 視為中途被打斷
|
|
299
|
-
return "interrupted"
|
|
300
|
-
# t == "user"
|
|
301
|
-
# user 帶工具結果(toolUseResult 欄位,或 content 含 tool_result block)→ 中途
|
|
302
|
-
if record.get("toolUseResult") is not None:
|
|
303
|
-
return "interrupted"
|
|
304
|
-
if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in _blocks(record)):
|
|
305
|
-
return "interrupted"
|
|
306
|
-
# command 噪音(slash-command stdout)→ 跳過,繼續往回走
|
|
307
|
-
text_blocks = [b for b in _blocks(record) if isinstance(b, dict) and b.get("type") == "text"]
|
|
308
|
-
if text_blocks and all(_clean_text(str(b.get("text", ""))) == "" for b in text_blocks):
|
|
309
|
-
continue
|
|
310
|
-
# 真人 prompt → 輪到 Claude 回應,不是回合結束
|
|
311
|
-
return "working"
|
|
312
|
-
return "none"
|
|
313
|
-
|
|
314
|
-
|
|
315
140
|
def _apply_waiting(
|
|
316
141
|
status: Status,
|
|
317
142
|
idle_seconds: float,
|
|
@@ -330,15 +155,6 @@ def _apply_waiting(
|
|
|
330
155
|
return status
|
|
331
156
|
|
|
332
157
|
|
|
333
|
-
def _recent_actions(records: list[dict[str, Any]], n: int = 5) -> list[str]:
|
|
334
|
-
acts = []
|
|
335
|
-
for record in records:
|
|
336
|
-
for block in _blocks(record):
|
|
337
|
-
if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name"):
|
|
338
|
-
acts.append(str(block["name"]))
|
|
339
|
-
return acts[-n:]
|
|
340
|
-
|
|
341
|
-
|
|
342
158
|
_tmux_cache: tuple[float, dict[str, str]] = (-1.0, {})
|
|
343
159
|
|
|
344
160
|
|
|
@@ -590,7 +406,7 @@ def _codex_threads(procs: list[tuple[str, str]]) -> list[Session]:
|
|
|
590
406
|
continue
|
|
591
407
|
updated_ms = int(row["updated_at_ms"] or 0)
|
|
592
408
|
last_active = updated_ms / 1000 if updated_ms else float(row["updated_at"] or 0)
|
|
593
|
-
if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(cwd, 0) == 0:
|
|
409
|
+
if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(_real(cwd), 0) == 0:
|
|
594
410
|
continue
|
|
595
411
|
rollout_path = Path(str(row["rollout_path"] or ""))
|
|
596
412
|
records = _tail_records(rollout_path) if rollout_path else []
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Transcript 解析層:把 agent CLI 的 JSONL 對話紀錄拆成 RiNG 要的訊號。
|
|
2
|
+
|
|
3
|
+
純函式、零外部依賴(只 stdlib),不認識 ``Session`` 也不碰 process / tmux——
|
|
4
|
+
那些屬於 ``registry``。把這層獨立出來,``registry`` 才能專注在「抓 session」,
|
|
5
|
+
解析邏輯也好單測。Claude Code 與 Codex 的 transcript 都走這裡的共用 helper。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _tail_records(path: Path, max_tail: int = 128 * 1024) -> list[dict[str, Any]]:
|
|
16
|
+
"""讀 JSONL 檔尾若干筆合法 JSON 記錄(舊→新),只 seek 檔尾不整檔讀。"""
|
|
17
|
+
try:
|
|
18
|
+
size = path.stat().st_size
|
|
19
|
+
with path.open("rb") as f:
|
|
20
|
+
if size > max_tail:
|
|
21
|
+
f.seek(size - max_tail)
|
|
22
|
+
f.readline() # 丟掉被切一半的那行
|
|
23
|
+
chunk = f.read()
|
|
24
|
+
except OSError:
|
|
25
|
+
return []
|
|
26
|
+
out = []
|
|
27
|
+
for raw in chunk.splitlines():
|
|
28
|
+
raw = raw.strip()
|
|
29
|
+
if not raw:
|
|
30
|
+
continue
|
|
31
|
+
try:
|
|
32
|
+
out.append(json.loads(raw))
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
continue
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _head_cwd(path: Path, max_head: int = 128 * 1024) -> str:
|
|
39
|
+
"""讀 JSONL 檔頭,回傳第一筆含非空 ``cwd`` 欄位的紀錄之 cwd。
|
|
40
|
+
|
|
41
|
+
目的:修補 scan 模式「中途 cd 漂移」問題。Claude Code session 中途 ``cd``
|
|
42
|
+
後,新紀錄的 ``cwd`` 已指向目的地目錄,若只看檔尾(``_tail_records``)會
|
|
43
|
+
誤判 session 歸屬。改讀檔頭取「開場 cwd」才能把 session 留在它真正所屬的
|
|
44
|
+
專案列,而非漂到目的地專案。
|
|
45
|
+
|
|
46
|
+
跳過 JSONL 裡無 ``cwd`` 的 meta 筆(last-prompt、mode、permission-mode、
|
|
47
|
+
file-history-snapshot 等);命中即停,不必整檔讀完。
|
|
48
|
+
|
|
49
|
+
:param path: JSONL 檔案路徑。
|
|
50
|
+
:param max_head: 從檔頭最多讀取的位元組數,預設 128 KiB,足以涵蓋早期幾十筆紀錄。
|
|
51
|
+
:returns: 第一筆非空 cwd 字串;找不到(空檔、全無 cwd)回 ``""``。
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
with path.open("rb") as f:
|
|
55
|
+
chunk = f.read(max_head)
|
|
56
|
+
except OSError:
|
|
57
|
+
return ""
|
|
58
|
+
for raw in chunk.splitlines():
|
|
59
|
+
raw = raw.strip()
|
|
60
|
+
if not raw:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
record = json.loads(raw)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
continue
|
|
66
|
+
cwd = record.get("cwd")
|
|
67
|
+
if cwd and isinstance(cwd, str):
|
|
68
|
+
return cwd
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _blocks(record: dict[str, Any]) -> list[Any]:
|
|
73
|
+
msg = record.get("message")
|
|
74
|
+
if isinstance(msg, dict):
|
|
75
|
+
content = msg.get("content")
|
|
76
|
+
if isinstance(content, list):
|
|
77
|
+
return content
|
|
78
|
+
if isinstance(content, str):
|
|
79
|
+
return [{"type": "text", "text": content}]
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_CMD_NOISE = ("<local-command-stdout>", "<command-name>", "<command-message>", "<command-args>")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _clean_text(s: str) -> str:
|
|
87
|
+
s = s.strip()
|
|
88
|
+
if any(marker in s for marker in _CMD_NOISE):
|
|
89
|
+
return "" # slash-command 的 stdout / 標記,不是真動作
|
|
90
|
+
return s.splitlines()[0].strip() if s else "" # 只取第一行,旁白長文不洗版
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _tool_summary(block: dict[str, Any]) -> str:
|
|
94
|
+
"""把一個 tool_use 變成簡短摘要,例如 → Edit foo.py / → Bash: git status。"""
|
|
95
|
+
name = str(block.get("name") or "")
|
|
96
|
+
if not name:
|
|
97
|
+
return ""
|
|
98
|
+
inp = block.get("input")
|
|
99
|
+
if not isinstance(inp, dict):
|
|
100
|
+
inp = {}
|
|
101
|
+
if name in ("Edit", "Write", "Read", "NotebookEdit"):
|
|
102
|
+
path = inp.get("file_path") or inp.get("notebook_path")
|
|
103
|
+
if path:
|
|
104
|
+
return f"→ {name} {Path(str(path)).name}"
|
|
105
|
+
elif name == "Bash":
|
|
106
|
+
cmd = str(inp.get("command") or "").strip().replace("\n", " ")
|
|
107
|
+
if cmd:
|
|
108
|
+
return f"→ Bash: {cmd[:40]}"
|
|
109
|
+
elif name in ("Grep", "Glob") and inp.get("pattern"):
|
|
110
|
+
return f"→ {name} {inp['pattern']}"
|
|
111
|
+
return f"→ {name}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _latest_action(records: list[dict[str, Any]]) -> str:
|
|
115
|
+
"""從新到舊找最近一個「真動作」:agent 的 tool_use 優先,其次 agent 的文字。
|
|
116
|
+
|
|
117
|
+
跳過 user 訊息、tool_result、slash-command 輸出這類雜訊。
|
|
118
|
+
"""
|
|
119
|
+
for record in reversed(records):
|
|
120
|
+
for block in _blocks(record):
|
|
121
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
122
|
+
summary = _tool_summary(block)
|
|
123
|
+
if summary:
|
|
124
|
+
return summary
|
|
125
|
+
if record.get("type") == "assistant":
|
|
126
|
+
for block in _blocks(record):
|
|
127
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
128
|
+
txt = _clean_text(str(block.get("text", "")))
|
|
129
|
+
if txt:
|
|
130
|
+
return txt[:80]
|
|
131
|
+
return "—"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None:
|
|
135
|
+
"""從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。"""
|
|
136
|
+
for record in reversed(records):
|
|
137
|
+
for block in _blocks(record):
|
|
138
|
+
if not isinstance(block, dict):
|
|
139
|
+
continue
|
|
140
|
+
if block.get("type") == "tool_use" and block.get("name") == "TodoWrite":
|
|
141
|
+
todos = (block.get("input") or {}).get("todos")
|
|
142
|
+
if isinstance(todos, list) and todos:
|
|
143
|
+
done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "completed")
|
|
144
|
+
return done, len(todos)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _conversation_tail_kind(records: list[dict[str, Any]]) -> str:
|
|
149
|
+
"""從對話尾巴往回走,判定最近一個「真訊息紀錄」的性質。
|
|
150
|
+
|
|
151
|
+
回傳值(str):
|
|
152
|
+
- ``"waiting"`` : 對話最後是 assistant end_turn 收尾,Claude 回完一輪。
|
|
153
|
+
- ``"working"`` : 對話最後是真人送出的 prompt,輪到 Claude 回應。
|
|
154
|
+
- ``"interrupted"`` : 工具呼叫進行中(assistant tool_use / user tool_result),尚未完成一輪。
|
|
155
|
+
- ``"none"`` : 沒有可判定的訊息(空 records、全噪音、無 assistant/user 訊息)。
|
|
156
|
+
|
|
157
|
+
演算法:從 ``reversed(records)`` 往回走,跳過噪音,找第一個真訊息紀錄,
|
|
158
|
+
依 type 與 content 判定後立即 return。絕不直接看 records[-1]——尾巴幾乎都是噪音。
|
|
159
|
+
|
|
160
|
+
限制(誠實標明):靠對話尾巴猜。Notification(卡權限等授權流程)在 JSONL 不留痕跡,
|
|
161
|
+
scan 模式測不到——那種需要使用者決策的狀態仍需 hook 模式偵測。
|
|
162
|
+
"""
|
|
163
|
+
for record in reversed(records):
|
|
164
|
+
t = record.get("type")
|
|
165
|
+
# --- 跳過噪音 ---
|
|
166
|
+
if t not in ("user", "assistant"):
|
|
167
|
+
continue # file-history-snapshot / system / permission-mode / mode / last-prompt 等
|
|
168
|
+
if record.get("isMeta") is True:
|
|
169
|
+
continue
|
|
170
|
+
if record.get("isSidechain") is True:
|
|
171
|
+
continue
|
|
172
|
+
# --- 第一個真訊息紀錄,依 type 判定 ---
|
|
173
|
+
if t == "assistant":
|
|
174
|
+
msg = record.get("message")
|
|
175
|
+
stop_reason = msg.get("stop_reason") if isinstance(msg, dict) else None
|
|
176
|
+
# assistant 帶 tool_use block → 工具呼叫進行中
|
|
177
|
+
if any(isinstance(b, dict) and b.get("type") == "tool_use" for b in _blocks(record)):
|
|
178
|
+
return "interrupted"
|
|
179
|
+
if stop_reason == "end_turn":
|
|
180
|
+
return "waiting"
|
|
181
|
+
# tool_use / stop_sequence / None / 其他 → 視為中途被打斷
|
|
182
|
+
return "interrupted"
|
|
183
|
+
# t == "user"
|
|
184
|
+
# user 帶工具結果(toolUseResult 欄位,或 content 含 tool_result block)→ 中途
|
|
185
|
+
if record.get("toolUseResult") is not None:
|
|
186
|
+
return "interrupted"
|
|
187
|
+
if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in _blocks(record)):
|
|
188
|
+
return "interrupted"
|
|
189
|
+
# command 噪音(slash-command stdout)→ 跳過,繼續往回走
|
|
190
|
+
text_blocks = [b for b in _blocks(record) if isinstance(b, dict) and b.get("type") == "text"]
|
|
191
|
+
if text_blocks and all(_clean_text(str(b.get("text", ""))) == "" for b in text_blocks):
|
|
192
|
+
continue
|
|
193
|
+
# 真人 prompt → 輪到 Claude 回應,不是回合結束
|
|
194
|
+
return "working"
|
|
195
|
+
return "none"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _recent_actions(records: list[dict[str, Any]], n: int = 5) -> list[str]:
|
|
199
|
+
acts = []
|
|
200
|
+
for record in records:
|
|
201
|
+
for block in _blocks(record):
|
|
202
|
+
if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name"):
|
|
203
|
+
acts.append(str(block["name"]))
|
|
204
|
+
return acts[-n:]
|
|
@@ -36,7 +36,6 @@ from ring.i18n import gettext as _
|
|
|
36
36
|
from ring.i18n import set_lang
|
|
37
37
|
from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence
|
|
38
38
|
from ring.labels import get_label, load_labels, set_label
|
|
39
|
-
from ring.notify import notify_waiting
|
|
40
39
|
from ring.registry import Session, Status, running_agent_pids
|
|
41
40
|
from ring.watcher import WaitingAlertScheduler
|
|
42
41
|
|
|
@@ -280,14 +279,10 @@ class RingApp(App[None]):
|
|
|
280
279
|
cur = next((s for s in self._sessions if s.session_id == self._focused_sid), None)
|
|
281
280
|
if cur is None or cur.status is not Status.WAITING:
|
|
282
281
|
self._focused_sid = None
|
|
282
|
+
# 系統通知(toast)改由 ``ring hook`` 在事件當下發出(見 hook._ring_waiting_now);
|
|
283
|
+
# 這裡只留 TUI 自己的 in-app 響鈴 / 訊息列與醒目標記,不重複發系統通知。
|
|
283
284
|
alerts = self._alerts.feed(self._sessions)
|
|
284
285
|
self._ring_on_waiting_alerts(alerts)
|
|
285
|
-
try:
|
|
286
|
-
hint = notify_waiting(alerts)
|
|
287
|
-
if hint:
|
|
288
|
-
self.notify(hint, timeout=10)
|
|
289
|
-
except Exception:
|
|
290
|
-
pass
|
|
291
286
|
self.sub_title = _header(len(self._sessions), len(running_agent_pids()))
|
|
292
287
|
labels = load_labels()
|
|
293
288
|
table.clear()
|
|
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
|