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.
Files changed (57) hide show
  1. {ring_cli-0.11.4 → ring_cli-0.12.0}/PKG-INFO +10 -3
  2. {ring_cli-0.11.4 → ring_cli-0.12.0}/README.en.md +8 -1
  3. {ring_cli-0.11.4 → ring_cli-0.12.0}/pyproject.toml +3 -1
  4. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/config.py +7 -0
  5. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/hook.py +4 -0
  6. ring_cli-0.12.0/src/ring/payload_log.py +110 -0
  7. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/registry.py +4 -1
  8. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/__init__.py +3 -1
  9. ring_cli-0.12.0/src/ring/sources/local_llm.py +157 -0
  10. {ring_cli-0.11.4 → ring_cli-0.12.0}/LICENSE +0 -0
  11. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/__init__.py +0 -0
  12. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/__main__.py +0 -0
  13. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/cli.py +0 -0
  14. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/__init__.py +0 -0
  15. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/_args.py +0 -0
  16. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/completion.py +0 -0
  17. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/digest.py +0 -0
  18. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/doctor.py +0 -0
  19. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/focus.py +0 -0
  20. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/gc.py +0 -0
  21. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/hook.py +0 -0
  22. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/commands/stats.py +0 -0
  23. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/__init__.py +0 -0
  24. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/applescript.py +0 -0
  25. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/base.py +0 -0
  26. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/iterm2.py +0 -0
  27. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/linux_wm.py +0 -0
  28. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/neovim.py +0 -0
  29. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/terminal.py +0 -0
  30. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/focus/tmux.py +0 -0
  31. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/gc.py +0 -0
  32. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/hook_protocol.py +0 -0
  33. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/i18n.py +0 -0
  34. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/ipc.py +0 -0
  35. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/labels.py +0 -0
  36. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  37. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  38. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/locale/ring.pot +0 -0
  39. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/__init__.py +0 -0
  40. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/base.py +0 -0
  41. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/command.py +0 -0
  42. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/notify_send.py +0 -0
  43. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/ntfy.py +0 -0
  44. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/osascript_notifier.py +0 -0
  45. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/terminal_notifier.py +0 -0
  46. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/notify/webhook.py +0 -0
  47. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/osascript.py +0 -0
  48. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/permission.py +0 -0
  49. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/plugins.py +0 -0
  50. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/base.py +0 -0
  51. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/claude_code.py +0 -0
  52. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/codex.py +0 -0
  53. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/sources/hook_registry.py +0 -0
  54. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/stats.py +0 -0
  55. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/transcript.py +0 -0
  56. {ring_cli-0.11.4 → ring_cli-0.12.0}/src/ring/tui.py +0 -0
  57. {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.11.4
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.11.4"
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
- return [*(running_claude_pids() or []), *(running_codex_pids() or [])]
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