ring-cli 0.14.2__tar.gz → 0.15.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.14.2 → ring_cli-0.15.0}/PKG-INFO +3 -2
- {ring_cli-0.14.2 → ring_cli-0.15.0}/README.en.md +2 -1
- {ring_cli-0.14.2 → ring_cli-0.15.0}/pyproject.toml +1 -1
- {ring_cli-0.14.2 → ring_cli-0.15.0}/pyproject.toml.orig +1 -1
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/doctor.py +5 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/permission.py +22 -16
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/registry.py +33 -1
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/local_llm.py +92 -13
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/tui.py +44 -15
- {ring_cli-0.14.2 → ring_cli-0.15.0}/LICENSE +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/cli.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/quiet.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/config.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/trace.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/gc.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/hook.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/labels.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify_queue.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/payload_log.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/question_detect.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/stats.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/transcript.py +0 -0
- {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
|
|
5
5
|
Keywords: claude-code,codex,ollama,llama-cpp,tui,dashboard,session,monitor,rich
|
|
6
6
|
Author: Wei Lee
|
|
@@ -47,7 +47,8 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
|
|
|
47
47
|
|
|
48
48
|
## What It Does
|
|
49
49
|
|
|
50
|
-
- **One board for session state**: built-in sources for Claude Code
|
|
50
|
+
- **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
|
|
51
|
+
detection for interactive Ollama and llama.cpp CLIs.
|
|
51
52
|
- **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
|
|
52
53
|
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
|
|
53
54
|
- **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
|
|
@@ -21,7 +21,8 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
|
|
|
21
21
|
|
|
22
22
|
## What It Does
|
|
23
23
|
|
|
24
|
-
- **One board for session state**: built-in sources for Claude Code
|
|
24
|
+
- **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
|
|
25
|
+
detection for interactive Ollama and llama.cpp CLIs.
|
|
25
26
|
- **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
|
|
26
27
|
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
|
|
27
28
|
- **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
|
|
@@ -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.15.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"
|
|
@@ -37,6 +37,11 @@ def run_doctor(args: list[str]) -> int:
|
|
|
37
37
|
for src in src_list:
|
|
38
38
|
try:
|
|
39
39
|
found = src.discover()
|
|
40
|
+
diagnostic = getattr(src, "diagnostic_issue", None)
|
|
41
|
+
issue = diagnostic() if callable(diagnostic) else ""
|
|
42
|
+
if issue:
|
|
43
|
+
print(f" {src.name:<{width_src}} {_('偵測失敗')} ({issue})")
|
|
44
|
+
continue
|
|
40
45
|
n = len(found)
|
|
41
46
|
status_str = _("活著")
|
|
42
47
|
count_str = _("偵測到 {n} 個 session", n=n)
|
|
@@ -42,8 +42,9 @@ _SEPARATOR_RE = re.compile(r"^\s*─{10,}\s*$")
|
|
|
42
42
|
# 聊天輸入框的提示列:「❯ <內容>」。誤送時數字會出現在這裡。
|
|
43
43
|
_INPUT_LINE_RE = re.compile(r"^\s*❯\s*(.+?)\s*$")
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# 送出數字後驗證 UI 反應的最長秒數;期間短輪詢,對話框一消失就提早完成。
|
|
46
46
|
_VERIFY_DELAY = 0.4
|
|
47
|
+
_VERIFY_POLL_INTERVAL = 0.05
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
@dataclass(frozen=True)
|
|
@@ -304,8 +305,9 @@ def send_permission_reply(
|
|
|
304
305
|
1. 再 capture 一次:對話框不在 → ``NO_DIALOG``;跟 ``expected`` 不同 → ``CHANGED``。
|
|
305
306
|
兩者都**不送鍵**(使用者在浮層裡想的期間,對話框可能已被本人回掉或換內容)。
|
|
306
307
|
2. 送單一數字(不帶 Enter)。
|
|
307
|
-
3.
|
|
308
|
-
Backspace、``MISFIRE
|
|
308
|
+
3. 最多用 ``delay`` 秒短輪詢 capture:對話框一消失就提早回 ``OK``;數字落進
|
|
309
|
+
輸入框 → 補 Backspace、``MISFIRE``;期限到仍是同一個對話框 →
|
|
310
|
+
``STILL_PRESENT``。
|
|
309
311
|
"""
|
|
310
312
|
if not any(number == n for n, _text in expected.options):
|
|
311
313
|
return ReplyOutcome.CHANGED # 編號不在選項裡,視同對話框對不上,不送
|
|
@@ -319,16 +321,20 @@ def send_permission_reply(
|
|
|
319
321
|
return ReplyOutcome.CHANGED
|
|
320
322
|
if not backend.send_digit(str(number)):
|
|
321
323
|
return ReplyOutcome.SEND_FAILED
|
|
322
|
-
time.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
324
|
+
deadline = time.monotonic() + max(0.0, delay)
|
|
325
|
+
while True:
|
|
326
|
+
after = backend.capture()
|
|
327
|
+
if after is not None:
|
|
328
|
+
after_dialog = parse_permission_dialog(after)
|
|
329
|
+
if after_dialog is None:
|
|
330
|
+
if digit_in_input_line(after, str(number)):
|
|
331
|
+
backend.send_backspace()
|
|
332
|
+
return ReplyOutcome.MISFIRE
|
|
333
|
+
return ReplyOutcome.OK
|
|
334
|
+
if after_dialog != expected:
|
|
335
|
+
return ReplyOutcome.OK # 對話框換成下一個請求 → 原請求已被回覆
|
|
336
|
+
|
|
337
|
+
remaining = deadline - time.monotonic()
|
|
338
|
+
if remaining <= 0:
|
|
339
|
+
return ReplyOutcome.UNVERIFIED if after is None else ReplyOutcome.STILL_PRESENT
|
|
340
|
+
time.sleep(min(_VERIFY_POLL_INTERVAL, remaining))
|
|
@@ -455,6 +455,7 @@ def _is_bare_session_start_row(
|
|
|
455
455
|
|
|
456
456
|
_tmux_cache: tuple[float, dict[str, str]] = (-1.0, {})
|
|
457
457
|
_tmux_panes_cache: tuple[float, list[TmuxPane]] = (-1.0, [])
|
|
458
|
+
_process_rows_cache: tuple[float, dict[int, tuple[int, str]]] = (-1.0, {})
|
|
458
459
|
|
|
459
460
|
|
|
460
461
|
@dataclass(frozen=True)
|
|
@@ -530,7 +531,11 @@ def _tmux_pane_targets() -> dict[str, str]:
|
|
|
530
531
|
|
|
531
532
|
|
|
532
533
|
def _process_rows() -> dict[int, tuple[int, str]]:
|
|
533
|
-
"""pid → (ppid, args)。給 scan-only tmux pane process-tree
|
|
534
|
+
"""pid → (ppid, args)。給 scan-only tmux pane process-tree 消歧用;同輪共用短快取。"""
|
|
535
|
+
global _process_rows_cache
|
|
536
|
+
now = time.monotonic()
|
|
537
|
+
if 0.0 <= now - _process_rows_cache[0] <= _SUBPROCESS_CACHE_TTL:
|
|
538
|
+
return _process_rows_cache[1]
|
|
534
539
|
try:
|
|
535
540
|
out = subprocess.run(["ps", "-Ao", "pid=,ppid=,args="], capture_output=True, text=True, timeout=3).stdout
|
|
536
541
|
except (OSError, subprocess.SubprocessError):
|
|
@@ -546,6 +551,7 @@ def _process_rows() -> dict[int, tuple[int, str]]:
|
|
|
546
551
|
except ValueError:
|
|
547
552
|
continue
|
|
548
553
|
rows[pid] = (ppid, parts[2] if len(parts) == 3 else "")
|
|
554
|
+
_process_rows_cache = (now, rows)
|
|
549
555
|
return rows
|
|
550
556
|
|
|
551
557
|
|
|
@@ -584,6 +590,20 @@ def _tmux_process_tree_targets(sessions: list[Session]) -> dict[str, str]:
|
|
|
584
590
|
pids = _descendant_pids(pane.pane_pid, rows)
|
|
585
591
|
if not pids:
|
|
586
592
|
continue
|
|
593
|
+
# local AI 沒有 transcript session id 可放進 argv,但 session id 自帶真實 pid。
|
|
594
|
+
# 直接用 pane process tree 對 pid,比同 cwd 依序猜 pane 精準。
|
|
595
|
+
for s in candidates:
|
|
596
|
+
if s.session_id in result:
|
|
597
|
+
continue
|
|
598
|
+
prefix = f"{s.provider}:pid-"
|
|
599
|
+
if not s.session_id.startswith(prefix):
|
|
600
|
+
continue
|
|
601
|
+
try:
|
|
602
|
+
process_pid = int(s.session_id[len(prefix) :])
|
|
603
|
+
except ValueError:
|
|
604
|
+
continue
|
|
605
|
+
if process_pid in pids:
|
|
606
|
+
result[s.session_id] = pane.target
|
|
587
607
|
args_text = "\n".join(rows[pid][1] for pid in pids if pid in rows)
|
|
588
608
|
if not args_text:
|
|
589
609
|
continue
|
|
@@ -602,6 +622,8 @@ _codex_pids_cache: tuple[float, list[int]] = (-1.0, [])
|
|
|
602
622
|
_ps_claude_snapshot_cache: tuple[float, str] = (-1.0, "")
|
|
603
623
|
_ps_codex_snapshot_cache: tuple[float, str] = (-1.0, "")
|
|
604
624
|
_bg_agent_session_ids_cache: tuple[float, frozenset[str]] = (-1.0, frozenset())
|
|
625
|
+
_claude_procs_cache: tuple[float, list[tuple[str, str]]] = (-1.0, [])
|
|
626
|
+
_codex_procs_cache: tuple[float, list[tuple[str, str]]] = (-1.0, [])
|
|
605
627
|
|
|
606
628
|
# args 內任一出現即可判定「這是 claude 安裝二進位在跑」的路徑標記。ps comm 對
|
|
607
629
|
# daemon-exec 的二進位常被截斷(如 `/Users/weilee/.l`),單看 comm 不可靠。
|
|
@@ -1061,6 +1083,10 @@ def _claude_procs() -> list[tuple[str, str]] | None:
|
|
|
1061
1083
|
「沒有任何 claude process」處理。批次呼叫成功但個別 pid 沒查到 cwd/tty(剛死)
|
|
1062
1084
|
不算未知,那個 pid 直接不貢獻一列,語意與逐 pid 版本一致。
|
|
1063
1085
|
"""
|
|
1086
|
+
global _claude_procs_cache
|
|
1087
|
+
now = time.monotonic()
|
|
1088
|
+
if 0.0 <= now - _claude_procs_cache[0] <= _SUBPROCESS_CACHE_TTL:
|
|
1089
|
+
return _claude_procs_cache[1]
|
|
1064
1090
|
pids = running_foreground_claude_pids()
|
|
1065
1091
|
if pids is None:
|
|
1066
1092
|
return None
|
|
@@ -1075,6 +1101,7 @@ def _claude_procs() -> list[tuple[str, str]] | None:
|
|
|
1075
1101
|
cwd = cwd_by_pid.get(pid, "")
|
|
1076
1102
|
if cwd:
|
|
1077
1103
|
procs.append((cwd, tty_by_pid.get(pid, "")))
|
|
1104
|
+
_claude_procs_cache = (now, procs)
|
|
1078
1105
|
return procs
|
|
1079
1106
|
|
|
1080
1107
|
|
|
@@ -1083,6 +1110,10 @@ def _codex_procs() -> list[tuple[str, str]] | None:
|
|
|
1083
1110
|
|
|
1084
1111
|
cwd/tty 各只批次查一次,理由與 ``_claude_procs`` 相同。
|
|
1085
1112
|
"""
|
|
1113
|
+
global _codex_procs_cache
|
|
1114
|
+
now = time.monotonic()
|
|
1115
|
+
if 0.0 <= now - _codex_procs_cache[0] <= _SUBPROCESS_CACHE_TTL:
|
|
1116
|
+
return _codex_procs_cache[1]
|
|
1086
1117
|
pids = running_codex_pids()
|
|
1087
1118
|
if pids is None:
|
|
1088
1119
|
return None
|
|
@@ -1097,6 +1128,7 @@ def _codex_procs() -> list[tuple[str, str]] | None:
|
|
|
1097
1128
|
cwd = cwd_by_pid.get(pid, "")
|
|
1098
1129
|
if cwd:
|
|
1099
1130
|
procs.append((cwd, tty_by_pid.get(pid, "")))
|
|
1131
|
+
_codex_procs_cache = (now, procs)
|
|
1100
1132
|
return procs
|
|
1101
1133
|
|
|
1102
1134
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Ollama 與 llama.cpp 互動式 CLI 的 zero-config 行程來源。
|
|
2
2
|
|
|
3
3
|
兩者都沒有可供 RiNG 讀取的 session transcript;這個來源因此只承諾行程層級的
|
|
4
|
-
存活資訊。只收有控制終端的 ``ollama run`` / ``llama-cli
|
|
5
|
-
server,避免把基礎設施誤當成「需要使用者回去處理」的 session。
|
|
4
|
+
存活資訊。只收有控制終端的 ``ollama run`` / ``llama-cli`` / ``llama cli``,
|
|
5
|
+
刻意排除長駐 API server,避免把基礎設施誤當成「需要使用者回去處理」的 session。
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
11
13
|
import subprocess
|
|
12
14
|
import time
|
|
13
15
|
from dataclasses import dataclass
|
|
@@ -29,6 +31,7 @@ class LocalLLMProcess:
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
_process_cache: tuple[float, list[LocalLLMProcess] | None] = (-1.0, [])
|
|
34
|
+
_last_scan_error = ""
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
def _elapsed_seconds(value: str) -> int | None:
|
|
@@ -48,8 +51,13 @@ def _elapsed_seconds(value: str) -> int | None:
|
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def _command_tokens(comm: str, args: str) -> tuple[str, list[str]]:
|
|
51
|
-
"""找出實際 executable
|
|
52
|
-
|
|
54
|
+
"""找出實際 executable 與其後參數;有 quoting 時盡量保留含空格的參數。"""
|
|
55
|
+
try:
|
|
56
|
+
tokens = shlex.split(args)
|
|
57
|
+
except ValueError:
|
|
58
|
+
# ``ps args`` 不保證保留完整 shell quoting;遇到截斷引號時仍退回原本的
|
|
59
|
+
# whitespace split,至少不要讓整輪 process discovery 失敗。
|
|
60
|
+
tokens = args.split()
|
|
53
61
|
executable = os.path.basename(comm.strip())
|
|
54
62
|
for index, token in enumerate(tokens):
|
|
55
63
|
if os.path.basename(token) == executable:
|
|
@@ -57,28 +65,84 @@ def _command_tokens(comm: str, args: str) -> tuple[str, list[str]]:
|
|
|
57
65
|
return executable, tokens[1:] if tokens else []
|
|
58
66
|
|
|
59
67
|
|
|
68
|
+
def _ollama_model(argv: list[str]) -> str | None:
|
|
69
|
+
"""從 ``ollama run`` argv 找模型;容忍 Cobra 把 flags 放在 MODEL 前面。"""
|
|
70
|
+
value_flags = {
|
|
71
|
+
"--dimensions",
|
|
72
|
+
"--format",
|
|
73
|
+
"--keepalive",
|
|
74
|
+
"--width",
|
|
75
|
+
"--height",
|
|
76
|
+
"--steps",
|
|
77
|
+
"--seed",
|
|
78
|
+
"--negative",
|
|
79
|
+
}
|
|
80
|
+
index = 1 # argv[0] == "run"
|
|
81
|
+
while index < len(argv):
|
|
82
|
+
token = argv[index]
|
|
83
|
+
if token == "--":
|
|
84
|
+
return argv[index + 1] if index + 1 < len(argv) else None
|
|
85
|
+
if token in value_flags:
|
|
86
|
+
index += 2
|
|
87
|
+
continue
|
|
88
|
+
if token == "--think":
|
|
89
|
+
index += 2 if index + 1 < len(argv) and argv[index + 1] in {"true", "false", "high", "medium", "low"} else 1
|
|
90
|
+
continue
|
|
91
|
+
if token.startswith("-"):
|
|
92
|
+
# ``--flag=value`` 與無值 boolean flag 都只佔一格;``--think`` 的值可省略,
|
|
93
|
+
# 不可無條件吞掉下一格的模型名稱。
|
|
94
|
+
index += 1
|
|
95
|
+
continue
|
|
96
|
+
return token
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _llama_model(argv: list[str], *, fallback: str = "llama-cli") -> str:
|
|
101
|
+
"""從 llama.cpp argv 找模型來源;找不到時保留可辨識的 CLI 名稱。"""
|
|
102
|
+
model_flags = {
|
|
103
|
+
"-m",
|
|
104
|
+
"--model",
|
|
105
|
+
"-hf",
|
|
106
|
+
"-hfr",
|
|
107
|
+
"--hf-repo",
|
|
108
|
+
"-mu",
|
|
109
|
+
"--model-url",
|
|
110
|
+
"-dr",
|
|
111
|
+
"--docker-repo",
|
|
112
|
+
}
|
|
113
|
+
for index, token in enumerate(argv):
|
|
114
|
+
if token in model_flags and index + 1 < len(argv):
|
|
115
|
+
return argv[index + 1]
|
|
116
|
+
for flag in model_flags:
|
|
117
|
+
prefix = f"{flag}="
|
|
118
|
+
if token.startswith(prefix):
|
|
119
|
+
return token[len(prefix) :]
|
|
120
|
+
return fallback
|
|
121
|
+
|
|
122
|
+
|
|
60
123
|
def _classify(comm: str, args: str) -> tuple[str, str] | None:
|
|
61
124
|
executable, argv = _command_tokens(comm, args)
|
|
62
125
|
if executable == "ollama":
|
|
63
|
-
if
|
|
126
|
+
if not argv or argv[0] != "run" or (model := _ollama_model(argv)) is None:
|
|
64
127
|
return None
|
|
65
|
-
return "ollama",
|
|
128
|
+
return "ollama", model
|
|
66
129
|
if executable == "llama-cli":
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return "llama.cpp", argv[index + 1]
|
|
71
|
-
return "llama.cpp", "llama-cli"
|
|
130
|
+
return "llama.cpp", _llama_model(argv)
|
|
131
|
+
if executable == "llama" and argv and argv[0] in {"cli", "client"}:
|
|
132
|
+
return "llama.cpp", _llama_model(argv[1:], fallback=f"llama {argv[0]}")
|
|
72
133
|
return None
|
|
73
134
|
|
|
74
135
|
|
|
75
136
|
def _scan_processes() -> list[LocalLLMProcess] | None:
|
|
76
137
|
"""一次 ps + 一次批次 lsof 找出兩種本機互動 CLI;失敗以 ``None`` 表示未知。"""
|
|
77
|
-
global _process_cache
|
|
138
|
+
global _last_scan_error, _process_cache
|
|
78
139
|
monotonic_now = time.monotonic()
|
|
79
140
|
if 0 <= monotonic_now - _process_cache[0] <= _CACHE_TTL:
|
|
80
141
|
return _process_cache[1]
|
|
81
142
|
|
|
143
|
+
if shutil.which("ps") is None:
|
|
144
|
+
_last_scan_error = "ps not found"
|
|
145
|
+
return None
|
|
82
146
|
try:
|
|
83
147
|
result = subprocess.run(
|
|
84
148
|
["ps", "-Ao", "pid=,tty=,etime=,comm=,args="],
|
|
@@ -86,9 +150,11 @@ def _scan_processes() -> list[LocalLLMProcess] | None:
|
|
|
86
150
|
text=True,
|
|
87
151
|
timeout=3,
|
|
88
152
|
)
|
|
89
|
-
except (OSError, subprocess.SubprocessError):
|
|
153
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
154
|
+
_last_scan_error = f"ps: {exc}"
|
|
90
155
|
return None
|
|
91
156
|
if result.returncode != 0:
|
|
157
|
+
_last_scan_error = f"ps exit {result.returncode}"
|
|
92
158
|
return None
|
|
93
159
|
|
|
94
160
|
now = time.time()
|
|
@@ -111,14 +177,19 @@ def _scan_processes() -> list[LocalLLMProcess] | None:
|
|
|
111
177
|
provider, model = classified
|
|
112
178
|
candidates.append((pid, tty, now - elapsed, provider, model))
|
|
113
179
|
|
|
180
|
+
if candidates and shutil.which("lsof") is None:
|
|
181
|
+
_last_scan_error = "lsof not found"
|
|
182
|
+
return None
|
|
114
183
|
cwd_by_pid = registry._pids_cwd([pid for pid, *_rest in candidates])
|
|
115
184
|
if cwd_by_pid is None:
|
|
185
|
+
_last_scan_error = "lsof scan failed"
|
|
116
186
|
return None
|
|
117
187
|
processes = [
|
|
118
188
|
LocalLLMProcess(pid, provider, cwd, tty, started_at, model)
|
|
119
189
|
for pid, tty, started_at, provider, model in candidates
|
|
120
190
|
if (cwd := cwd_by_pid.get(pid, ""))
|
|
121
191
|
]
|
|
192
|
+
_last_scan_error = ""
|
|
122
193
|
_process_cache = (monotonic_now, processes)
|
|
123
194
|
return processes
|
|
124
195
|
|
|
@@ -152,6 +223,14 @@ class LocalLLMSource:
|
|
|
152
223
|
if process.provider == self.name
|
|
153
224
|
]
|
|
154
225
|
|
|
226
|
+
def diagnostic_issue(self) -> str:
|
|
227
|
+
"""最近一次掃描失敗原因;doctor 用,正常或尚無候選行程時為空。"""
|
|
228
|
+
if shutil.which("ps") is None:
|
|
229
|
+
return "ps not found"
|
|
230
|
+
if shutil.which("lsof") is None:
|
|
231
|
+
return "lsof not found"
|
|
232
|
+
return _last_scan_error
|
|
233
|
+
|
|
155
234
|
|
|
156
235
|
ollama_source = LocalLLMSource("ollama")
|
|
157
236
|
llama_cpp_source = LocalLLMSource("llama.cpp")
|
|
@@ -9,6 +9,7 @@ p 就地回覆權限請求(tmux 內的 session)、a 切換是否顯示已離
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import asyncio
|
|
12
13
|
import os
|
|
13
14
|
import time
|
|
14
15
|
from typing import ClassVar
|
|
@@ -18,6 +19,7 @@ from textual import events
|
|
|
18
19
|
from textual.app import App, ComposeResult
|
|
19
20
|
from textual.binding import Binding, BindingType
|
|
20
21
|
from textual.containers import Vertical
|
|
22
|
+
from textual.coordinate import Coordinate
|
|
21
23
|
from textual.screen import ModalScreen
|
|
22
24
|
from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static
|
|
23
25
|
|
|
@@ -201,6 +203,8 @@ class RingApp(App[None]):
|
|
|
201
203
|
# p 已確認終端對話框消失,但 provider 尚未送後續 hook 時,別讓同一筆舊
|
|
202
204
|
# PermissionRequest 又把列拉回 WAITING。value 是已回覆那筆 row 的 last_active revision。
|
|
203
205
|
self._permission_acks: dict[str, float] = {}
|
|
206
|
+
# send/capture 會碰 subprocess / AppleScript,放背景執行;同一個 session 同時只送一次。
|
|
207
|
+
self._permission_replies_inflight: set[str] = set()
|
|
204
208
|
|
|
205
209
|
@staticmethod
|
|
206
210
|
def _detect_own_tty() -> str:
|
|
@@ -390,6 +394,18 @@ class RingApp(App[None]):
|
|
|
390
394
|
parts.append(_("queue: {n}", n=count))
|
|
391
395
|
return " | " + " ".join(parts) if parts else ""
|
|
392
396
|
|
|
397
|
+
def _status_cell(self, s: Session) -> Text:
|
|
398
|
+
"""建立狀態欄 cell;完整刷新與權限回覆後的局部更新共用。"""
|
|
399
|
+
focused = s.session_id == self._focused_sid
|
|
400
|
+
marker = "👉 " if focused else ""
|
|
401
|
+
style = f"reverse {_STATUS_STYLE[s.status]}" if focused else _STATUS_STYLE[s.status]
|
|
402
|
+
suffix = f" {s.waiting_icon}" if s.status is Status.WAITING and s.waiting_icon else ""
|
|
403
|
+
if s.hook_stale:
|
|
404
|
+
suffix += " ⚠"
|
|
405
|
+
if s.kind == "agent":
|
|
406
|
+
suffix += " ⚙"
|
|
407
|
+
return Text(f"{marker}{s.status.marker} {status_label(s.status)}{suffix}", style=style)
|
|
408
|
+
|
|
393
409
|
def _reload(self) -> None:
|
|
394
410
|
# 每次刷新都續寫 presence,避免 TUI 開超過 TTL 後 `ring focus` 誤判 TUI 沒在跑、
|
|
395
411
|
# 退回去跳 session 自己的終端(scan 模式常沒 tty → 跳轉失敗)。
|
|
@@ -424,19 +440,10 @@ class RingApp(App[None]):
|
|
|
424
440
|
labels = load_labels()
|
|
425
441
|
table.clear()
|
|
426
442
|
for s in self._sessions:
|
|
427
|
-
focused = s.session_id == self._focused_sid
|
|
428
|
-
marker = "👉 " if focused else ""
|
|
429
|
-
style = f"reverse {_STATUS_STYLE[s.status]}" if focused else _STATUS_STYLE[s.status]
|
|
430
|
-
suffix = f" {s.waiting_icon}" if s.status is Status.WAITING and s.waiting_icon else ""
|
|
431
|
-
if s.hook_stale:
|
|
432
|
-
suffix += " ⚠"
|
|
433
|
-
if s.kind == "agent":
|
|
434
|
-
suffix += " ⚙"
|
|
435
|
-
status_cell = Text(f"{marker}{s.status.marker} {status_label(s.status)}{suffix}", style=style)
|
|
436
443
|
progress = f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·"
|
|
437
444
|
loc_cell = f"📍{_middle_truncate(s.location, _LOC_MAX)}"
|
|
438
445
|
project_cell = labeled_project(s.project, labels.get(s.session_id, ""))
|
|
439
|
-
cells: list[object] = [
|
|
446
|
+
cells: list[object] = [self._status_cell(s)]
|
|
440
447
|
if self._show_tool:
|
|
441
448
|
cells.append(provider_label(s.provider))
|
|
442
449
|
cells += [project_cell, progress, _rel(s.idle_for), loc_cell, s.last_action]
|
|
@@ -599,7 +606,14 @@ class RingApp(App[None]):
|
|
|
599
606
|
def _submit(number: int | None) -> None:
|
|
600
607
|
if number is None:
|
|
601
608
|
return # Esc 取消,不送
|
|
602
|
-
|
|
609
|
+
if s.session_id in self._permission_replies_inflight:
|
|
610
|
+
return
|
|
611
|
+
self._permission_replies_inflight.add(s.session_id)
|
|
612
|
+
self._set_status(_("… {project}:正在回覆權限", project=name))
|
|
613
|
+
self.run_worker(
|
|
614
|
+
self._finish_permission_reply(s, name, backend, dialog, number),
|
|
615
|
+
group="permission-reply",
|
|
616
|
+
)
|
|
603
617
|
|
|
604
618
|
self.push_screen(_PermissionModal(name, dialog), _submit)
|
|
605
619
|
|
|
@@ -608,7 +622,7 @@ class RingApp(App[None]):
|
|
|
608
622
|
self._set_status(text)
|
|
609
623
|
self.notify(text, severity="information" if ok else "warning", timeout=8)
|
|
610
624
|
|
|
611
|
-
def _finish_permission_reply(
|
|
625
|
+
async def _finish_permission_reply(
|
|
612
626
|
self,
|
|
613
627
|
s: Session,
|
|
614
628
|
name: str,
|
|
@@ -616,15 +630,30 @@ class RingApp(App[None]):
|
|
|
616
630
|
dialog: permission.PermissionDialog,
|
|
617
631
|
number: int,
|
|
618
632
|
) -> None:
|
|
619
|
-
"""
|
|
620
|
-
|
|
633
|
+
"""在背景驗證/送鍵,避免 subprocess 與等待時間凍結 Textual 事件迴圈。"""
|
|
634
|
+
try:
|
|
635
|
+
outcome = await asyncio.to_thread(permission.send_permission_reply, backend, dialog, number)
|
|
636
|
+
finally:
|
|
637
|
+
self._permission_replies_inflight.discard(s.session_id)
|
|
621
638
|
option = next((f"{n}. {text}" for n, text in dialog.options if n == number), str(number))
|
|
622
639
|
if outcome is permission.ReplyOutcome.OK:
|
|
623
640
|
if s.session_id == self._focused_sid:
|
|
624
641
|
self._focused_sid = None # 已就地回覆,解除通知標記
|
|
625
642
|
self._permission_acks[s.session_id] = s.last_active
|
|
643
|
+
# 先直接更新現有資料,別為了一筆 acknowledgment 同步重掃所有 process。
|
|
644
|
+
# 下一次既有輪詢仍會正常全量同步、重排表格。
|
|
645
|
+
row = next(
|
|
646
|
+
((idx, current) for idx, current in enumerate(self._sessions) if current.session_id == s.session_id),
|
|
647
|
+
None,
|
|
648
|
+
)
|
|
649
|
+
if row is not None and row[1].last_active <= s.last_active:
|
|
650
|
+
idx, current = row
|
|
651
|
+
current.status = Status.WORKING
|
|
652
|
+
current.waiting_kind = ""
|
|
653
|
+
current.waiting_detail = ""
|
|
654
|
+
self.query_one(DataTable).update_cell_at(Coordinate(idx, 0), self._status_cell(current))
|
|
626
655
|
self._toast(_("→ {project}:已回覆權限({option})", project=name, option=option), ok=True)
|
|
627
|
-
self.
|
|
656
|
+
self._update_detail()
|
|
628
657
|
return
|
|
629
658
|
messages = {
|
|
630
659
|
permission.ReplyOutcome.NO_DIALOG: _("{project}:權限對話框已不在,未送出任何按鍵", project=name),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|