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.
Files changed (62) hide show
  1. {ring_cli-0.14.2 → ring_cli-0.15.0}/PKG-INFO +3 -2
  2. {ring_cli-0.14.2 → ring_cli-0.15.0}/README.en.md +2 -1
  3. {ring_cli-0.14.2 → ring_cli-0.15.0}/pyproject.toml +1 -1
  4. {ring_cli-0.14.2 → ring_cli-0.15.0}/pyproject.toml.orig +1 -1
  5. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/doctor.py +5 -0
  6. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/permission.py +22 -16
  7. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/registry.py +33 -1
  8. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/local_llm.py +92 -13
  9. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/tui.py +44 -15
  10. {ring_cli-0.14.2 → ring_cli-0.15.0}/LICENSE +0 -0
  11. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/__init__.py +0 -0
  12. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/__main__.py +0 -0
  13. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/cli.py +0 -0
  14. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/__init__.py +0 -0
  15. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/_args.py +0 -0
  16. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/completion.py +0 -0
  17. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/digest.py +0 -0
  18. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/focus.py +0 -0
  19. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/gc.py +0 -0
  20. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/hook.py +0 -0
  21. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/quiet.py +0 -0
  22. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/commands/stats.py +0 -0
  23. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/config.py +0 -0
  24. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/__init__.py +0 -0
  25. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/applescript.py +0 -0
  26. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/base.py +0 -0
  27. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/iterm2.py +0 -0
  28. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/linux_wm.py +0 -0
  29. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/neovim.py +0 -0
  30. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/terminal.py +0 -0
  31. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/tmux.py +0 -0
  32. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/focus/trace.py +0 -0
  33. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/gc.py +0 -0
  34. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/hook.py +0 -0
  35. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/hook_protocol.py +0 -0
  36. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/i18n.py +0 -0
  37. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/ipc.py +0 -0
  38. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/labels.py +0 -0
  39. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  40. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  41. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/locale/ring.pot +0 -0
  42. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/__init__.py +0 -0
  43. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/base.py +0 -0
  44. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/command.py +0 -0
  45. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/notify_send.py +0 -0
  46. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/ntfy.py +0 -0
  47. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/osascript_notifier.py +0 -0
  48. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/terminal_notifier.py +0 -0
  49. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify/webhook.py +0 -0
  50. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/notify_queue.py +0 -0
  51. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/osascript.py +0 -0
  52. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/payload_log.py +0 -0
  53. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/plugins.py +0 -0
  54. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/question_detect.py +0 -0
  55. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/__init__.py +0 -0
  56. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/base.py +0 -0
  57. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/claude_code.py +0 -0
  58. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/codex.py +0 -0
  59. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/sources/hook_registry.py +0 -0
  60. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/stats.py +0 -0
  61. {ring_cli-0.14.2 → ring_cli-0.15.0}/src/ring/transcript.py +0 -0
  62. {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.14.2
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, Codex, Ollama, and llama.cpp.
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, Codex, Ollama, and llama.cpp.
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ring-cli"
3
- version = "0.14.2"
3
+ version = "0.15.0"
4
4
  description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
5
5
  readme = "README.en.md"
6
6
  requires-python = ">=3.13"
@@ -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.14.2"
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
- # 送出數字後等 UI 反應的秒數,之後再抓一次畫面驗證。
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. 等 ``delay`` 秒再 capture 驗證:對話框消失 → ``OK``;數字落進輸入框 → 補
308
- Backspace、``MISFIRE``;同一個對話框還在 → ``STILL_PRESENT``。
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.sleep(delay)
323
- after = backend.capture()
324
- if after is None:
325
- return ReplyOutcome.UNVERIFIED
326
- after_dialog = parse_permission_dialog(after)
327
- if after_dialog is None:
328
- if digit_in_input_line(after, str(number)):
329
- backend.send_backspace()
330
- return ReplyOutcome.MISFIRE
331
- return ReplyOutcome.OK
332
- if after_dialog == expected:
333
- return ReplyOutcome.STILL_PRESENT
334
- return ReplyOutcome.OK # 對話框換成下一個請求 → 原請求已被回覆
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``,刻意排除長駐 API
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 與其後參數;ps args 不保證保留 shell quoting。"""
52
- tokens = args.split()
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 len(argv) < 2 or argv[0] != "run":
126
+ if not argv or argv[0] != "run" or (model := _ollama_model(argv)) is None:
64
127
  return None
65
- return "ollama", argv[1]
128
+ return "ollama", model
66
129
  if executable == "llama-cli":
67
- model_flags = {"-m", "--model", "-hf", "-hfr", "--hf-repo", "-mu", "--model-url"}
68
- for index, token in enumerate(argv[:-1]):
69
- if token in model_flags:
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] = [status_cell]
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
- self._finish_permission_reply(s, name, backend, dialog, number)
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
- """浮層選定後:再驗證一次、送鍵、依結果回報(見 permission.send_permission_reply)。"""
620
- outcome = permission.send_permission_reply(backend, dialog, number)
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._reload()
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