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
122 changes: 122 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Contributing to RiNG

謝謝你願意幫 RiNG 變好。這份文件整理本 repo 的開發流程、測試方式與送 PR 前的檢查項目。

## 開發環境

RiNG 需要 Python 3.13+,專案使用 `uv` 管理依賴與執行環境。

```sh
uv sync --all-groups
uv run ring
uv run ring --watch
```

PyPI 發佈名是 `ring-cli`,但 Python module 與 CLI 指令都叫 `ring`。

## 常用命令

```sh
uv run poe format # ruff check --fix + ruff format
uv run poe lint # ruff check + mypy
uv run poe test # pytest
uv run poe cover # pytest + coverage report
uv run poe all # format + lint + coverage
uv run poe ci # pre-commit hooks + coverage
```

測試預設會用 `pytest-xdist` 平行執行;要跑單一測試時可以直接用 pytest:

```sh
uv run pytest tests/test_cli.py
uv run pytest tests/test_cli.py::test_name
```

## Pre-commit Hooks

建議安裝 hooks,讓格式化、lint、lockfile、commit message 檢查在本機先跑過。

```sh
uv run poe setup-pre-commit
```

目前 hooks 會檢查 TOML/YAML、private key、拼字、`uv.lock`、Ruff、Mypy、Commitizen 等項目。
`no-commit-to-branch` 會擋直接 commit 到受保護分支;必要時請在 feature branch 上工作。

## 程式風格

- Python 程式碼放在 `src/ring/`,測試放在 `tests/`。
- Ruff 目標版本是 Python 3.13,行寬 120。
- Mypy 使用 strict mode;新增 API 時請補齊型別。
- CLI 行為、輸出格式、hook protocol、JSON keys 會被使用者腳本依賴;改動時請優先維持相容,必要 breaking change 要清楚標示。
- 專案刻意允許台灣漢語註解與 UI 字串;新增英文 UI 時也要注意 i18n。

## 測試準則

請依改動範圍補測試:

- CLI 參數與輸出:`tests/test_cli.py`
- TUI / render 行為:`tests/test_tui.py`、`tests/test_render.py`
- source discovery:`tests/test_sources.py`、`tests/test_discover.py`
- hook / registry / IPC:`tests/test_hook.py`、`tests/test_registry.py`、`tests/test_ipc.py`
- notifier / focus / permission reply:對應 `tests/test_notify.py`、`tests/test_focus.py`、`tests/test_permission.py`

送 PR 前至少跑:

```sh
uv run poe lint
uv run poe test
```

若改到跨模組行為、輸出格式或發佈相關設定,請跑:

```sh
uv run poe all
```

## i18n

改到使用者可見字串時,請更新翻譯檔。

```sh
uv run poe i18n-extract
uv run poe i18n-check
uv run poe i18n-compile
```

注意:不要用 `pybabel update`。這會把空 `msgstr` 塞進 `.po`,可能弄壞 i18n 測試。新字串請手動把已翻譯條目加進各語言 `.po`,再重新 compile 並 commit `.po` 與 `.mo`。

## Commit 與 Changelog

本專案使用 Commitizen / Conventional Commits,commit message 例如:

```text
feat: add webhook retry backoff
fix: keep stale codex sessions hidden
docs: document hook setup
test: cover tmux permission parsing
```

版本與 changelog 由 Commitizen 管理。一般 PR 不需要手動改 `CHANGELOG.md`,除非正在做 release / bump。

## PR Checklist

送 PR 前請確認:

- 變更範圍聚焦,沒有混入無關格式化或產物。
- 新功能或修 bug 有對應測試,或在 PR 說明中交代為什麼無法測。
- `uv.lock` 與依賴設定一致。
- 使用者可見字串已處理 i18n。
- README / docs 已同步更新 CLI 旗標、設定鍵、輸出格式或行為變更。
- 有 breaking change 時,PR 說明清楚列出影響與遷移方式。

## 回報 Issue

請盡量附上:

- 作業系統與終端環境,例如 macOS + iTerm2、tmux、Linux X11 / Wayland。
- `ring --version` 與安裝方式。
- 相關命令與實際輸出。
- 若和 hook / focus / permission reply 有關,請附 `ring doctor` 的相關段落。

請不要貼出含有 token、private key、完整私有 transcript 或其他敏感資訊的內容。
30 changes: 27 additions & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,31 @@ Claude Code events:
| `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
| `SessionEnd` | removed from the board |

Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
`Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission
request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at
the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any
trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`),
with the question as its detail; a question that only appears mid-message, with a statement at the end,
does not count — conservative on purpose, biased toward missing a case rather than a false positive. This
applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the
`detect_stop_questions` config key can turn it off (on by default).

Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`,
and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare
event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while
an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence
itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than
`codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command
as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it.

Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish
these):

- **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running
command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up
action, 🔴 lingers until the next event or `Stop`.
- The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook
event to notify from); headless `--watch` does not send this particular notification.

Verify that hooks are writing:

Expand Down Expand Up @@ -384,6 +406,8 @@ legend = true
active_window_seconds = 21600
working_threshold_seconds = 90
waiting_window_seconds = 1800
codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off
detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡
notify_sound = true
notify_sound_name = "Glass"
notify_ignore_dnd = false
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,27 @@ Claude Code 註冊這幾個事件,對應到狀態:
| `PermissionRequest` / `PreToolUse` 的 `AskUserQuestion` | 🔴 等你(權限 / 選項需要你決策) |
| `SessionEnd` | 從看板消失 |

Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`Stop`。Codex 的
`PermissionRequest` 也會在既有 policy 自動放行前送出;因此裸事件維持 🟢 工作中,只有 payload
明確標示 `requires_action` / `waiting_for` 時才轉成 🔴 等你。
`Stop` 還有一種例外:agent 有時不用 `AskUserQuestion`、不觸發權限請求,只在文字裡問了一句話
就停下來(例如「要不要順便修 B?」)。這種情況下 RiNG 會看最後一則 assistant 訊息的**結尾**——
結尾是問句(`?` / `?`,圍欄程式碼區塊先剝掉再看)就升級成 🔴 等你(`waiting_kind="question"`),
detail 帶那句問句;問句只出現在訊息中段、結尾是陳述句則不算,保守判斷、寧可漏報不要誤報。
Claude Code 與 Codex 兩邊都適用(兩者的 `Stop` payload 都帶 `last_assistant_message`);
`detect_stop_questions` 設定鍵可關閉這個行為(預設開)。

Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`PostToolUse`、`Stop`。
Codex 的 `PermissionRequest` 也會在既有 policy 自動放行前送出,所以裸事件在 hook 層維持 🟢
工作中。Codex 的 hook 沒有「使用者已核可」事件、沒有心跳,等核可期間 hook 通道**完全靜默**
——RiNG 因此把靜默本身當訊號:最後一個 hook 事件是 `PermissionRequest`、又超過
`codex_permission_wait_seconds`(預設 10 秒)沒有任何後續事件,看板就把它標成 🔴 等你,
detail 帶當初要跑的指令摘要;任何後續事件(下一個工具、`Stop`)到了就自然轉回。

Codex 側的已知限制(0.144.4 實證,hook payload 無任何欄位可區分):

- **分不出核可還是拒絕**:你按下 allow 或 deny 時,hook 都不會收到事件。allow 之後若那條指令
本身跑很久,🔴 會多亮到指令跑完(`PostToolUse`)為止;deny 之後若 agent 沒有立即的後續動作,
🔴 會亮到下一個事件或 `Stop` 才清。
- 核可等待轉 🔴 的系統通知由 TUI 的提醒排程器代發(沒有 hook 事件可在當下發);headless
`--watch` 不發這一種通知。

hook 只對**新開的 session** 生效,所以裝完要重開。確認方法:

Expand Down Expand Up @@ -411,6 +429,8 @@ legend = true # 是否預設顯示圖例
active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
working_threshold_seconds = 90 # 多久沒動 → 🟢 工作中 變 🟡 閒置
waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數 → 🔴 等你;0 = 關閉
detect_stop_questions = true # Stop 結尾是純文字提問時升級成 🔴 等你;false = Stop 一律 🟡
notify_sound = true # 系統通知是否播放聲音
notify_sound_name = "Glass" # macOS / terminal-notifier sound name
notify_ignore_dnd = false # macOS terminal-notifier 是否穿透勿擾 / Focus
Expand Down
2 changes: 2 additions & 0 deletions src/ring/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ def run_config(args: list[str]) -> int:
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
# 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
# watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
# 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的
# 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。
frames = 0
footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
if not HAVE_RICH:
Expand Down
18 changes: 18 additions & 0 deletions src/ring/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數
# → 看板判定真的停下來等核可(🔴 等你);0 = 關閉
detect_stop_questions = true # Stop 事件時,若最後一則 assistant 訊息「結尾」是純文字
# 提問(沒用 AskUserQuestion、沒有權限請求)→ 🔴 等你
# (waiting_kind="question");關閉則 Stop 一律 🟡
notify_sound = true # 系統通知帶聲音
notify_sound_name = "Glass" # macOS / terminal-notifier sound name
notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
Expand Down Expand Up @@ -64,6 +69,13 @@ class Config:
active_window_seconds: int = 6 * 60 * 60
working_threshold_seconds: int = 90
waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
# Codex 的 hook 沒有「使用者已核可」事件也沒有心跳(0.144.4 實證):policy 自動放行時
# 下一個事件幾秒內就到;真的停下來等人時 hook 通道完全靜默。所以「最後一個事件是
# PermissionRequest 且已靜默超過這個門檻」就判定在等核可。0 = 關閉這個判定。
codex_permission_wait_seconds: int = 10
# Stop 事件時,若最後一則 assistant 訊息結尾是純文字提問(見 question_detect.py)
# → 升級成 🔴 等你(waiting_kind="question")。預設開;關閉則 Stop 一律維持 🟡。
detect_stop_questions: bool = True
notify_sound: bool = True
notify_sound_name: str = "Glass"
notify_ignore_dnd: bool = False
Expand Down Expand Up @@ -139,6 +151,10 @@ def load(path: Path | None = None) -> Config:
active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
codex_permission_wait_seconds=_as_int(
raw.get("codex_permission_wait_seconds"), d.codex_permission_wait_seconds
),
detect_stop_questions=_as_bool(raw.get("detect_stop_questions"), d.detect_stop_questions),
notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
notify_sound_name=(
raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
Expand Down Expand Up @@ -212,6 +228,8 @@ def _coerce_str_list(s: str) -> list[str]:
"active_window_seconds": _coerce_int,
"working_threshold_seconds": _coerce_int,
"waiting_window_seconds": _coerce_int,
"codex_permission_wait_seconds": _coerce_int,
"detect_stop_questions": _coerce_bool,
"notify_sound": _coerce_bool,
"notify_sound_name": str,
"notify_ignore_dnd": _coerce_bool,
Expand Down
Loading