Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,18 @@ RiNG collects sessions from registered sources. Built-ins:
|--------|------------|-----------|
| **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 |
| **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 |
| **Ollama zero-config** | interactive `ollama run` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `ollama serve` |
| **llama.cpp zero-config** | interactive `llama-cli` processes with a controlling terminal | process-liveness only; shows cwd, TTY, and model, and excludes `llama-server` |
| **hook registry** | `~/.config/ring/sessions/`, written by `ring hook` | precise: 🔴 waiting / 🟢 working / 🟡 idle / ⚫ ended |

Zero-config needs no setup. For precise “who needs me”, install hooks so provider events feed the RiNG registry.
RiNG includes installers for Claude Code and Codex; other tools can use the provider-neutral `ring hook` protocol.

Ollama and llama.cpp do not expose a session transcript or interaction hooks that RiNG can read, so their
zero-config rows stay 🟡: the row means the interactive CLI is alive, not that RiNG can distinguish generation
from waiting for the next prompt. The row disappears when the CLI exits. An outer agent that can emit lifecycle
events can use the provider-neutral hook protocol for precise states.

## States

RiNG reduces every session to four user-facing states. 🔴 waiting is sorted first.
Expand Down Expand Up @@ -406,7 +413,7 @@ RiNG is not tied to a specific tool or terminal.

| Extension Point | Purpose | Built-ins |
|-----------------|---------|-----------|
| `SessionSource` | find sessions | Claude Code, Codex, hook registry |
| `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry |
| `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
| `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook |

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,17 @@ RiNG 會從已註冊的 source 收集 session;目前內建這幾種:
|------|------|----------|
| **Claude Code zero-config**(預設) | 掃 `~/.claude/projects/**/*.jsonl` 的 mtime + 記錄裡的 `cwd` 欄位 | 免設定;可辨識近期活動與回合結束。需要回應的通知要靠 hook 才精準 |
| **Codex zero-config**(預設) | 讀 `~/.codex/state_5.sqlite` threads + rollout JSONL,並用 live `codex` process 配 tty | 免設定;可辨識 live / ended / 回合結束。同 cwd 多 session 建議裝 hook 取得精準跳轉 |
| **Ollama zero-config**(預設) | 偵測有控制終端的 `ollama run` process | 行程存活層級;顯示 cwd、TTY 與模型,不把 `ollama serve` 當 session |
| **llama.cpp zero-config**(預設) | 偵測有控制終端的 `llama-cli` process | 行程存活層級;顯示 cwd、TTY 與模型,不把 `llama-server` 當 session |
| **hook registry**(opt-in,精準) | RiNG hook 在 `Notification` / `UserPromptSubmit` / `Stop` / `SessionEnd` 即時寫 `~/.config/ring/sessions/` | 準(🔴 等你 / 🟢 工作中 / 🟡 跑完停著 / ⚫ 已離場) |

zero-config 不必設定就能用;想要精準的「誰在等你」,就讓 provider 的 hook 餵進 RiNG registry。
RiNG 內建 Claude Code / Codex hook 安裝器;其他工具可直接走 provider-neutral `ring hook` protocol。

Ollama 與 llama.cpp 本身沒有 RiNG 可讀的 session transcript 或互動 hook,因此 zero-config 列固定以
🟡 顯示:它代表互動式 CLI 還活著,不代表 RiNG 能分辨它正在生成或等下一個 prompt。CLI 結束後列
就會移除。若外層 agent 能送出生命週期事件,可用下方的中立 hook protocol 取得精準狀態。

## 狀態機

RiNG 把每個 session 壓成四種狀態。看板排序時,🔴 等你永遠排最上面並 highlight。
Expand Down Expand Up @@ -448,7 +454,7 @@ core 不綁死任何特定工具或終端。三個維度都可插拔,每個都

| 維度 | 在做什麼 | 內建 |
|------|----------|------|
| `SessionSource` | 從哪裡找到 session | Claude Code、Codex、RiNG hook registry |
| `SessionSource` | 從哪裡找到 session | Claude Code、Codex、Ollama、llama.cpp、RiNG hook registry |
| `Focuser` | 跳轉時把焦點帶去哪個終端 | tmux、iTerm2、Terminal.app、Linux X11(wmctrl)|
| `Notifier` | 等你時怎麼發系統通知 | terminal-notifier、osascript、notify-send、ntfy、webhook |

Expand All @@ -475,7 +481,8 @@ core 不綁死任何特定工具或終端。三個維度都可插拔,每個都
### 其他 agent CLI(`SessionSource`)

內建 `HookRegistrySource`(讀 `~/.config/ring/sessions/`)、`ClaudeCodeSource`(掃
`~/.claude`)與 `CodexSource`(讀 `~/.codex/state_5.sqlite`)。要監測其他工具,
`~/.claude`)、`CodexSource`(讀 `~/.codex/state_5.sqlite`),以及 Ollama / llama.cpp
互動式 CLI 的 process source。要監測其他工具,
可以優先餵 `ring hook`;若工具沒有 hook,再寫一個 source 吐出 `Session`、註冊即可:

```python
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ authors = [{ name = "Wei Lee", email = "weilee.rx@gmail.com" }]
keywords = [
"claude-code",
"codex",
"ollama",
"llama-cpp",
"tui",
"dashboard",
"session",
Expand Down
7 changes: 7 additions & 0 deletions src/ring/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*)
debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload
# (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl);
# 預設關閉,payload 可能含使用者輸入才要開;
# 環境變數 RING_DEBUG_PAYLOAD_LOG 可覆寫(優先於本鍵)
"""

from __future__ import annotations
Expand Down Expand Up @@ -77,6 +81,7 @@ class Config:
notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
focusers: tuple[str, ...] = () # 空=用內建預設順序
plugins: tuple[str, ...] = () # 啟動時 import 的外部 plugin 模組(entry point 之外的本機路)
debug_payload_log: bool = False # 診斷用:記錄 hook 收到的原始 payload;預設關閉
colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))


Expand Down Expand Up @@ -148,6 +153,7 @@ def load(path: Path | None = None) -> Config:
notify_also=_as_str_tuple(raw.get("notify_also")),
focusers=_as_str_tuple(raw.get("focusers")),
plugins=_as_str_tuple(raw.get("plugins")),
debug_payload_log=_as_bool(raw.get("debug_payload_log"), d.debug_payload_log),
colors=_parse_colors(raw.get("colors")),
)

Expand Down Expand Up @@ -218,6 +224,7 @@ def _coerce_str_list(s: str) -> list[str]:
"notify_also": _coerce_str_list,
"focusers": _coerce_str_list,
"plugins": _coerce_str_list,
"debug_payload_log": _coerce_bool,
}


Expand Down
4 changes: 4 additions & 0 deletions src/ring/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload
from ring.i18n import gettext as _
from ring.i18n import set_lang
from ring.payload_log import maybe_log_raw_payload
from ring.registry import (
RING_REGISTRY,
Session,
Expand Down Expand Up @@ -157,6 +158,9 @@ def run_hook(provider: str = "claude-code") -> int:
return 0

selected_provider = provider_from_payload(data, fallback=provider)
# 取證 log:診斷用、預設關閉(見 payload_log.py)。故意放在任何狀態判定/改寫之前,
# 記下 hook 實際收到的原始資料——不影響、不參與下面的正規化流程。
maybe_log_raw_payload(selected_provider, data)
_record_session_state(data, selected_provider)
return _delegate_to_agent_hooks(raw, selected_provider)

Expand Down
110 changes: 110 additions & 0 deletions src/ring/payload_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""原始 hook payload 取證 logger——診斷用,預設關閉。

診斷「claude-code 裸 PermissionRequest 被誤判 WAITING」與「codex 裸 PermissionRequest
永遠判 WORKING」這類問題時,需要看到 hook 實際收到的原始 event payload——現有的
registry(``~/.config/ring/sessions/*.json``)與 ``events.jsonl`` 都只留正規化後的
status,原始資料在 ``hook_protocol.normalize`` 跑完就遺失了。

這支 logger 在 hook 收到 stdin 的當下、任何狀態判定/改寫之前,把原始 payload 整段
append 一行,供事後分析用。設計原則跟 ``stats.log_transition`` 一致:

- append-only、JSONL,一行一事件
- 檔案超過上限自動砍半保新,不無限成長
- 任何錯誤(含讀 config 失敗、payload 無法序列化)一律安靜吞掉,絕不影響 hook 主流程

開關:``debug_payload_log`` config 鍵,或環境變數 ``RING_DEBUG_PAYLOAD_LOG``
(``1``/``true``/``yes``/``on`` 開,``0``/``false``/``no``/``off`` 關;env 優先於 config)。
**預設關閉**——payload 可能含使用者輸入/檔案內容,開了才寫。
"""

from __future__ import annotations

import json
import os
import time
from collections.abc import Mapping
from pathlib import Path
from typing import Any

PAYLOAD_LOG_PATH = Path.home() / ".config" / "ring" / "hook_payloads.jsonl"

# log 檔上限;超過就砍半保新(append 前檢查)。原始 payload 比 events.jsonl 的轉換記錄
# 大很多(可能含完整 tool_input/transcript 片段),給足空間再砍半。
_MAX_BYTES = 20 * 1024 * 1024

_ENV_FLAG = "RING_DEBUG_PAYLOAD_LOG"

_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}


def payload_log_enabled() -> bool:
"""是否要記原始 payload:env var 優先,其次 config 鍵,預設關閉。"""
env = os.environ.get(_ENV_FLAG, "").strip().lower()
if env in _TRUE:
return True
if env in _FALSE:
return False
try:
from ring.config import get_config

return get_config().debug_payload_log
except Exception:
return False


def _raw_event_name(data: Mapping[str, Any]) -> str:
"""從原始 payload 猜事件名(只為了記錄好讀,不影響任何判定邏輯)。"""
for key in ("event", "event_name", "hook_event_name", "hookEventName"):
v = data.get(key)
if isinstance(v, str) and v:
return v
return ""


def maybe_log_raw_payload(
provider: str,
data: Mapping[str, Any],
*,
path: Path | None = None,
now: float | None = None,
) -> None:
"""開關開啟時,把 hook 收到的原始 payload 整段 append 一行(診斷用)。

呼叫端應放在 hook 處理最前端,早於 ``hook_protocol`` 的任何狀態判定/改寫。
開關預設關閉(見模組 docstring);任何錯誤一律安靜吞掉,呼叫端不需要自己包 try。
"""
try:
if not payload_log_enabled():
return
p = path or PAYLOAD_LOG_PATH
line = json.dumps(
{
"ts": now if now is not None else time.time(),
"provider": provider,
"event": _raw_event_name(data),
"payload": data,
},
ensure_ascii=False,
default=str,
)
p.parent.mkdir(parents=True, exist_ok=True)
_trim_if_oversized(p)
with p.open("a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass


def _trim_if_oversized(p: Path) -> None:
"""log 超過 ``_MAX_BYTES`` 時砍半保新,避免無上限成長。失敗安靜放棄(不擋 append)。"""
try:
if p.stat().st_size <= _MAX_BYTES:
return
lines = p.read_text(encoding="utf-8").splitlines()
keep = lines[len(lines) // 2 :]
tmp = p.with_suffix(".jsonl.tmp")
tmp.write_text("\n".join(keep) + "\n", encoding="utf-8")
tmp.replace(p) # atomic
except Exception:
pass
5 changes: 4 additions & 1 deletion src/ring/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,10 @@ def running_agent_pids() -> list[int]:
顯示用途,不是存活判定;真正的 ENDED 判定路徑(``_hook_sessions``)用的是
未攤平的 ``running_claude_pids`` / ``running_codex_pids`` 原始回傳值。
"""
return [*(running_claude_pids() or []), *(running_codex_pids() or [])]
# 延後 import,避免 registry(Session model)與 sources package 初始化時循環相依。
from ring.sources.local_llm import running_pids as running_local_llm_pids

return [*(running_claude_pids() or []), *(running_codex_pids() or []), *running_local_llm_pids()]


def _pids_cwd(pids: list[int]) -> dict[int, str] | None:
Expand Down
4 changes: 3 additions & 1 deletion src/ring/sources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
from ring.sources.claude_code import source as _claude_code
from ring.sources.codex import source as _codex
from ring.sources.hook_registry import source as _hook_registry
from ring.sources.local_llm import llama_cpp_source as _llama_cpp
from ring.sources.local_llm import ollama_source as _ollama

# 註冊表(順序=彙整順序)。hook registry 先於 zero-config source,精準事件優先。
_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex]
_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex, _ollama, _llama_cpp]


def register_source(source: SessionSource, *, first: bool = False) -> None:
Expand Down
Loading