ring-cli 0.11.4__tar.gz → 0.12.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.11.4 → ring_cli-0.12.0}/PKG-INFO +10 -3
- {ring_cli-0.11.4 → ring_cli-0.12.0}/README.en.md +8 -1
- {ring_cli-0.11.4 → ring_cli-0.12.0}/pyproject.toml +3 -1
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/config.py +7 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/hook.py +4 -0
- ring_cli-0.12.0/src/ring/payload_log.py +110 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/registry.py +4 -1
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/__init__.py +3 -1
- ring_cli-0.12.0/src/ring/sources/local_llm.py +157 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/LICENSE +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/cli.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/gc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/labels.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/permission.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/stats.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/transcript.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/tui.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/watcher.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
|
|
5
|
-
Keywords: claude-code,codex,tui,dashboard,session,monitor,rich
|
|
5
|
+
Keywords: claude-code,codex,ollama,llama-cpp,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
|
|
@@ -268,11 +268,18 @@ RiNG collects sessions from registered sources. Built-ins:
|
|
|
268
268
|
|--------|------------|-----------|
|
|
269
269
|
| **Claude Code zero-config** | `~/.claude/projects/**/*.jsonl`, mtimes, and `cwd` fields | no setup; detects recent activity and turn completion. Precise user-action prompts require hooks |
|
|
270
270
|
| **Codex zero-config** | `~/.codex/state_5.sqlite`, rollout JSONL, and live `codex` processes | no setup; detects live / ended / turn completion. Use hooks for precise jumps when multiple sessions share a cwd |
|
|
271
|
+
| **Ollama zero-config** | interactive `ollama run` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `ollama serve` |
|
|
272
|
+
| **llama.cpp zero-config** | interactive `llama-cli` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `llama-server` |
|
|
271
273
|
| **hook registry** | `~/.config/ring/sessions/`, written by `ring hook` | precise: 🔴 waiting / 🟢 working / 🟡 idle / ⚫ ended |
|
|
272
274
|
|
|
273
275
|
Zero-config needs no setup. For precise “who needs me”, install hooks so provider events feed the RiNG registry.
|
|
274
276
|
RiNG includes installers for Claude Code and Codex; other tools can use the provider-neutral `ring hook` protocol.
|
|
275
277
|
|
|
278
|
+
Ollama and llama.cpp do not expose a session transcript or interaction hooks that RiNG can read, so their
|
|
279
|
+
zero-config rows stay 🟡: the row means the interactive CLI is alive, not that RiNG can distinguish generation
|
|
280
|
+
from waiting for the next prompt. The row disappears when the CLI exits. An outer agent that can emit lifecycle
|
|
281
|
+
events can use the provider-neutral hook protocol for precise states.
|
|
282
|
+
|
|
276
283
|
## States
|
|
277
284
|
|
|
278
285
|
RiNG reduces every session to four user-facing states. 🔴 waiting is sorted first.
|
|
@@ -431,7 +438,7 @@ RiNG is not tied to a specific tool or terminal.
|
|
|
431
438
|
|
|
432
439
|
| Extension Point | Purpose | Built-ins |
|
|
433
440
|
|-----------------|---------|-----------|
|
|
434
|
-
| `SessionSource` | find sessions | Claude Code, Codex, hook registry |
|
|
441
|
+
| `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry |
|
|
435
442
|
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
|
|
436
443
|
| `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook |
|
|
437
444
|
|
|
@@ -243,11 +243,18 @@ RiNG collects sessions from registered sources. Built-ins:
|
|
|
243
243
|
|--------|------------|-----------|
|
|
244
244
|
| **Claude Code zero-config** | `~/.claude/projects/**/*.jsonl`, mtimes, and `cwd` fields | no setup; detects recent activity and turn completion. Precise user-action prompts require hooks |
|
|
245
245
|
| **Codex zero-config** | `~/.codex/state_5.sqlite`, rollout JSONL, and live `codex` processes | no setup; detects live / ended / turn completion. Use hooks for precise jumps when multiple sessions share a cwd |
|
|
246
|
+
| **Ollama zero-config** | interactive `ollama run` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `ollama serve` |
|
|
247
|
+
| **llama.cpp zero-config** | interactive `llama-cli` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `llama-server` |
|
|
246
248
|
| **hook registry** | `~/.config/ring/sessions/`, written by `ring hook` | precise: 🔴 waiting / 🟢 working / 🟡 idle / ⚫ ended |
|
|
247
249
|
|
|
248
250
|
Zero-config needs no setup. For precise “who needs me”, install hooks so provider events feed the RiNG registry.
|
|
249
251
|
RiNG includes installers for Claude Code and Codex; other tools can use the provider-neutral `ring hook` protocol.
|
|
250
252
|
|
|
253
|
+
Ollama and llama.cpp do not expose a session transcript or interaction hooks that RiNG can read, so their
|
|
254
|
+
zero-config rows stay 🟡: the row means the interactive CLI is alive, not that RiNG can distinguish generation
|
|
255
|
+
from waiting for the next prompt. The row disappears when the CLI exits. An outer agent that can emit lifecycle
|
|
256
|
+
events can use the provider-neutral hook protocol for precise states.
|
|
257
|
+
|
|
251
258
|
## States
|
|
252
259
|
|
|
253
260
|
RiNG reduces every session to four user-facing states. 🔴 waiting is sorted first.
|
|
@@ -406,7 +413,7 @@ RiNG is not tied to a specific tool or terminal.
|
|
|
406
413
|
|
|
407
414
|
| Extension Point | Purpose | Built-ins |
|
|
408
415
|
|-----------------|---------|-----------|
|
|
409
|
-
| `SessionSource` | find sessions | Claude Code, Codex, hook registry |
|
|
416
|
+
| `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry |
|
|
410
417
|
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
|
|
411
418
|
| `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook |
|
|
412
419
|
|
|
@@ -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.12.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"
|
|
@@ -12,6 +12,8 @@ authors = [{ name = "Wei Lee", email = "weilee.rx@gmail.com" }]
|
|
|
12
12
|
keywords = [
|
|
13
13
|
"claude-code",
|
|
14
14
|
"codex",
|
|
15
|
+
"ollama",
|
|
16
|
+
"llama-cpp",
|
|
15
17
|
"tui",
|
|
16
18
|
"dashboard",
|
|
17
19
|
"session",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
|
|
28
28
|
focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
|
|
29
29
|
plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*)
|
|
30
|
+
debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload
|
|
31
|
+
# (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl);
|
|
32
|
+
# 預設關閉,payload 可能含使用者輸入才要開;
|
|
33
|
+
# 環境變數 RING_DEBUG_PAYLOAD_LOG 可覆寫(優先於本鍵)
|
|
30
34
|
"""
|
|
31
35
|
|
|
32
36
|
from __future__ import annotations
|
|
@@ -77,6 +81,7 @@ class Config:
|
|
|
77
81
|
notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
|
|
78
82
|
focusers: tuple[str, ...] = () # 空=用內建預設順序
|
|
79
83
|
plugins: tuple[str, ...] = () # 啟動時 import 的外部 plugin 模組(entry point 之外的本機路)
|
|
84
|
+
debug_payload_log: bool = False # 診斷用:記錄 hook 收到的原始 payload;預設關閉
|
|
80
85
|
colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))
|
|
81
86
|
|
|
82
87
|
|
|
@@ -148,6 +153,7 @@ def load(path: Path | None = None) -> Config:
|
|
|
148
153
|
notify_also=_as_str_tuple(raw.get("notify_also")),
|
|
149
154
|
focusers=_as_str_tuple(raw.get("focusers")),
|
|
150
155
|
plugins=_as_str_tuple(raw.get("plugins")),
|
|
156
|
+
debug_payload_log=_as_bool(raw.get("debug_payload_log"), d.debug_payload_log),
|
|
151
157
|
colors=_parse_colors(raw.get("colors")),
|
|
152
158
|
)
|
|
153
159
|
|
|
@@ -218,6 +224,7 @@ _SETTERS: dict[str, Callable[[str], object]] = {
|
|
|
218
224
|
"notify_also": _coerce_str_list,
|
|
219
225
|
"focusers": _coerce_str_list,
|
|
220
226
|
"plugins": _coerce_str_list,
|
|
227
|
+
"debug_payload_log": _coerce_bool,
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
|
|
@@ -31,6 +31,7 @@ from ring.config import get_config
|
|
|
31
31
|
from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload
|
|
32
32
|
from ring.i18n import gettext as _
|
|
33
33
|
from ring.i18n import set_lang
|
|
34
|
+
from ring.payload_log import maybe_log_raw_payload
|
|
34
35
|
from ring.registry import (
|
|
35
36
|
RING_REGISTRY,
|
|
36
37
|
Session,
|
|
@@ -157,6 +158,9 @@ def run_hook(provider: str = "claude-code") -> int:
|
|
|
157
158
|
return 0
|
|
158
159
|
|
|
159
160
|
selected_provider = provider_from_payload(data, fallback=provider)
|
|
161
|
+
# 取證 log:診斷用、預設關閉(見 payload_log.py)。故意放在任何狀態判定/改寫之前,
|
|
162
|
+
# 記下 hook 實際收到的原始資料——不影響、不參與下面的正規化流程。
|
|
163
|
+
maybe_log_raw_payload(selected_provider, data)
|
|
160
164
|
_record_session_state(data, selected_provider)
|
|
161
165
|
return _delegate_to_agent_hooks(raw, selected_provider)
|
|
162
166
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""原始 hook payload 取證 logger——診斷用,預設關閉。
|
|
2
|
+
|
|
3
|
+
診斷「claude-code 裸 PermissionRequest 被誤判 WAITING」與「codex 裸 PermissionRequest
|
|
4
|
+
永遠判 WORKING」這類問題時,需要看到 hook 實際收到的原始 event payload——現有的
|
|
5
|
+
registry(``~/.config/ring/sessions/*.json``)與 ``events.jsonl`` 都只留正規化後的
|
|
6
|
+
status,原始資料在 ``hook_protocol.normalize`` 跑完就遺失了。
|
|
7
|
+
|
|
8
|
+
這支 logger 在 hook 收到 stdin 的當下、任何狀態判定/改寫之前,把原始 payload 整段
|
|
9
|
+
append 一行,供事後分析用。設計原則跟 ``stats.log_transition`` 一致:
|
|
10
|
+
|
|
11
|
+
- append-only、JSONL,一行一事件
|
|
12
|
+
- 檔案超過上限自動砍半保新,不無限成長
|
|
13
|
+
- 任何錯誤(含讀 config 失敗、payload 無法序列化)一律安靜吞掉,絕不影響 hook 主流程
|
|
14
|
+
|
|
15
|
+
開關:``debug_payload_log`` config 鍵,或環境變數 ``RING_DEBUG_PAYLOAD_LOG``
|
|
16
|
+
(``1``/``true``/``yes``/``on`` 開,``0``/``false``/``no``/``off`` 關;env 優先於 config)。
|
|
17
|
+
**預設關閉**——payload 可能含使用者輸入/檔案內容,開了才寫。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
PAYLOAD_LOG_PATH = Path.home() / ".config" / "ring" / "hook_payloads.jsonl"
|
|
30
|
+
|
|
31
|
+
# log 檔上限;超過就砍半保新(append 前檢查)。原始 payload 比 events.jsonl 的轉換記錄
|
|
32
|
+
# 大很多(可能含完整 tool_input/transcript 片段),給足空間再砍半。
|
|
33
|
+
_MAX_BYTES = 20 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
_ENV_FLAG = "RING_DEBUG_PAYLOAD_LOG"
|
|
36
|
+
|
|
37
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
38
|
+
_FALSE = {"0", "false", "no", "off"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def payload_log_enabled() -> bool:
|
|
42
|
+
"""是否要記原始 payload:env var 優先,其次 config 鍵,預設關閉。"""
|
|
43
|
+
env = os.environ.get(_ENV_FLAG, "").strip().lower()
|
|
44
|
+
if env in _TRUE:
|
|
45
|
+
return True
|
|
46
|
+
if env in _FALSE:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
from ring.config import get_config
|
|
50
|
+
|
|
51
|
+
return get_config().debug_payload_log
|
|
52
|
+
except Exception:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _raw_event_name(data: Mapping[str, Any]) -> str:
|
|
57
|
+
"""從原始 payload 猜事件名(只為了記錄好讀,不影響任何判定邏輯)。"""
|
|
58
|
+
for key in ("event", "event_name", "hook_event_name", "hookEventName"):
|
|
59
|
+
v = data.get(key)
|
|
60
|
+
if isinstance(v, str) and v:
|
|
61
|
+
return v
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def maybe_log_raw_payload(
|
|
66
|
+
provider: str,
|
|
67
|
+
data: Mapping[str, Any],
|
|
68
|
+
*,
|
|
69
|
+
path: Path | None = None,
|
|
70
|
+
now: float | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""開關開啟時,把 hook 收到的原始 payload 整段 append 一行(診斷用)。
|
|
73
|
+
|
|
74
|
+
呼叫端應放在 hook 處理最前端,早於 ``hook_protocol`` 的任何狀態判定/改寫。
|
|
75
|
+
開關預設關閉(見模組 docstring);任何錯誤一律安靜吞掉,呼叫端不需要自己包 try。
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
if not payload_log_enabled():
|
|
79
|
+
return
|
|
80
|
+
p = path or PAYLOAD_LOG_PATH
|
|
81
|
+
line = json.dumps(
|
|
82
|
+
{
|
|
83
|
+
"ts": now if now is not None else time.time(),
|
|
84
|
+
"provider": provider,
|
|
85
|
+
"event": _raw_event_name(data),
|
|
86
|
+
"payload": data,
|
|
87
|
+
},
|
|
88
|
+
ensure_ascii=False,
|
|
89
|
+
default=str,
|
|
90
|
+
)
|
|
91
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
_trim_if_oversized(p)
|
|
93
|
+
with p.open("a", encoding="utf-8") as f:
|
|
94
|
+
f.write(line + "\n")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _trim_if_oversized(p: Path) -> None:
|
|
100
|
+
"""log 超過 ``_MAX_BYTES`` 時砍半保新,避免無上限成長。失敗安靜放棄(不擋 append)。"""
|
|
101
|
+
try:
|
|
102
|
+
if p.stat().st_size <= _MAX_BYTES:
|
|
103
|
+
return
|
|
104
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
105
|
+
keep = lines[len(lines) // 2 :]
|
|
106
|
+
tmp = p.with_suffix(".jsonl.tmp")
|
|
107
|
+
tmp.write_text("\n".join(keep) + "\n", encoding="utf-8")
|
|
108
|
+
tmp.replace(p) # atomic
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
@@ -872,7 +872,10 @@ def running_agent_pids() -> list[int]:
|
|
|
872
872
|
顯示用途,不是存活判定;真正的 ENDED 判定路徑(``_hook_sessions``)用的是
|
|
873
873
|
未攤平的 ``running_claude_pids`` / ``running_codex_pids`` 原始回傳值。
|
|
874
874
|
"""
|
|
875
|
-
|
|
875
|
+
# 延後 import,避免 registry(Session model)與 sources package 初始化時循環相依。
|
|
876
|
+
from ring.sources.local_llm import running_pids as running_local_llm_pids
|
|
877
|
+
|
|
878
|
+
return [*(running_claude_pids() or []), *(running_codex_pids() or []), *running_local_llm_pids()]
|
|
876
879
|
|
|
877
880
|
|
|
878
881
|
def _pids_cwd(pids: list[int]) -> dict[int, str] | None:
|
|
@@ -13,9 +13,11 @@ from ring.sources.base import SessionSource
|
|
|
13
13
|
from ring.sources.claude_code import source as _claude_code
|
|
14
14
|
from ring.sources.codex import source as _codex
|
|
15
15
|
from ring.sources.hook_registry import source as _hook_registry
|
|
16
|
+
from ring.sources.local_llm import llama_cpp_source as _llama_cpp
|
|
17
|
+
from ring.sources.local_llm import ollama_source as _ollama
|
|
16
18
|
|
|
17
19
|
# 註冊表(順序=彙整順序)。hook registry 先於 zero-config source,精準事件優先。
|
|
18
|
-
_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex]
|
|
20
|
+
_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex, _ollama, _llama_cpp]
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def register_source(source: SessionSource, *, first: bool = False) -> None:
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Ollama 與 llama.cpp 互動式 CLI 的 zero-config 行程來源。
|
|
2
|
+
|
|
3
|
+
兩者都沒有可供 RiNG 讀取的 session transcript;這個來源因此只承諾行程層級的
|
|
4
|
+
存活資訊。只收有控制終端的 ``ollama run`` / ``llama-cli``,刻意排除長駐 API
|
|
5
|
+
server,避免把基礎設施誤當成「需要使用者回去處理」的 session。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import ring.registry as registry
|
|
16
|
+
from ring.registry import Session, Status
|
|
17
|
+
|
|
18
|
+
_CACHE_TTL = 1.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class LocalLLMProcess:
|
|
23
|
+
pid: int
|
|
24
|
+
provider: str
|
|
25
|
+
cwd: str
|
|
26
|
+
tty: str
|
|
27
|
+
started_at: float
|
|
28
|
+
model: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_process_cache: tuple[float, list[LocalLLMProcess] | None] = (-1.0, [])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _elapsed_seconds(value: str) -> int | None:
|
|
35
|
+
"""解析 ps ``etime``(``[[dd-]hh:]mm:ss``)。"""
|
|
36
|
+
try:
|
|
37
|
+
day_part, clock = value.split("-", 1) if "-" in value else ("0", value)
|
|
38
|
+
fields = [int(part) for part in clock.split(":")]
|
|
39
|
+
if len(fields) == 2:
|
|
40
|
+
hours, minutes, seconds = 0, fields[0], fields[1]
|
|
41
|
+
elif len(fields) == 3:
|
|
42
|
+
hours, minutes, seconds = fields
|
|
43
|
+
else:
|
|
44
|
+
return None
|
|
45
|
+
return int(day_part) * 86400 + hours * 3600 + minutes * 60 + seconds
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _command_tokens(comm: str, args: str) -> tuple[str, list[str]]:
|
|
51
|
+
"""找出實際 executable 與其後參數;ps args 不保證保留 shell quoting。"""
|
|
52
|
+
tokens = args.split()
|
|
53
|
+
executable = os.path.basename(comm.strip())
|
|
54
|
+
for index, token in enumerate(tokens):
|
|
55
|
+
if os.path.basename(token) == executable:
|
|
56
|
+
return executable, tokens[index + 1 :]
|
|
57
|
+
return executable, tokens[1:] if tokens else []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _classify(comm: str, args: str) -> tuple[str, str] | None:
|
|
61
|
+
executable, argv = _command_tokens(comm, args)
|
|
62
|
+
if executable == "ollama":
|
|
63
|
+
if len(argv) < 2 or argv[0] != "run":
|
|
64
|
+
return None
|
|
65
|
+
return "ollama", argv[1]
|
|
66
|
+
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"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scan_processes() -> list[LocalLLMProcess] | None:
|
|
76
|
+
"""一次 ps + 一次批次 lsof 找出兩種本機互動 CLI;失敗以 ``None`` 表示未知。"""
|
|
77
|
+
global _process_cache
|
|
78
|
+
monotonic_now = time.monotonic()
|
|
79
|
+
if 0 <= monotonic_now - _process_cache[0] <= _CACHE_TTL:
|
|
80
|
+
return _process_cache[1]
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["ps", "-Ao", "pid=,tty=,etime=,comm=,args="],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
timeout=3,
|
|
88
|
+
)
|
|
89
|
+
except (OSError, subprocess.SubprocessError):
|
|
90
|
+
return None
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
now = time.time()
|
|
95
|
+
candidates: list[tuple[int, str, float, str, str]] = []
|
|
96
|
+
for line in result.stdout.splitlines():
|
|
97
|
+
parts = line.split(None, 4)
|
|
98
|
+
if len(parts) < 4:
|
|
99
|
+
continue
|
|
100
|
+
tty = registry._normalize_tty(parts[1])
|
|
101
|
+
if not tty:
|
|
102
|
+
continue
|
|
103
|
+
classified = _classify(parts[3], parts[4] if len(parts) == 5 else "")
|
|
104
|
+
elapsed = _elapsed_seconds(parts[2])
|
|
105
|
+
if classified is None or elapsed is None:
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
pid = int(parts[0])
|
|
109
|
+
except ValueError:
|
|
110
|
+
continue
|
|
111
|
+
provider, model = classified
|
|
112
|
+
candidates.append((pid, tty, now - elapsed, provider, model))
|
|
113
|
+
|
|
114
|
+
cwd_by_pid = registry._pids_cwd([pid for pid, *_rest in candidates])
|
|
115
|
+
if cwd_by_pid is None:
|
|
116
|
+
return None
|
|
117
|
+
processes = [
|
|
118
|
+
LocalLLMProcess(pid, provider, cwd, tty, started_at, model)
|
|
119
|
+
for pid, tty, started_at, provider, model in candidates
|
|
120
|
+
if (cwd := cwd_by_pid.get(pid, ""))
|
|
121
|
+
]
|
|
122
|
+
_process_cache = (monotonic_now, processes)
|
|
123
|
+
return processes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def running_pids() -> list[int]:
|
|
127
|
+
"""顯示用途的 live local-LLM CLI pid;掃描未知時回空清單。"""
|
|
128
|
+
return [process.pid for process in (_scan_processes() or [])]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LocalLLMSource:
|
|
132
|
+
def __init__(self, provider: str) -> None:
|
|
133
|
+
self.name = provider
|
|
134
|
+
|
|
135
|
+
def discover(self) -> list[Session]:
|
|
136
|
+
processes = _scan_processes()
|
|
137
|
+
if processes is None:
|
|
138
|
+
return []
|
|
139
|
+
return [
|
|
140
|
+
Session(
|
|
141
|
+
session_id=f"{self.name}:pid-{process.pid}",
|
|
142
|
+
cwd=process.cwd,
|
|
143
|
+
status=Status.IDLE,
|
|
144
|
+
last_active=process.started_at,
|
|
145
|
+
last_action=process.model,
|
|
146
|
+
source=self.name,
|
|
147
|
+
tty=process.tty,
|
|
148
|
+
provider=self.name,
|
|
149
|
+
origin_cwd=process.cwd,
|
|
150
|
+
)
|
|
151
|
+
for process in processes
|
|
152
|
+
if process.provider == self.name
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
ollama_source = LocalLLMSource("ollama")
|
|
157
|
+
llama_cpp_source = LocalLLMSource("llama.cpp")
|
|
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
|