ring-cli 0.11.4__tar.gz → 0.13.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 (58) hide show
  1. {ring_cli-0.11.4 → ring_cli-0.13.0}/PKG-INFO +37 -6
  2. {ring_cli-0.11.4 → ring_cli-0.13.0}/README.en.md +35 -4
  3. {ring_cli-0.11.4 → ring_cli-0.13.0}/pyproject.toml +3 -1
  4. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/cli.py +2 -0
  5. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/config.py +25 -0
  6. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/hook.py +92 -11
  7. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/hook_protocol.py +15 -10
  8. ring_cli-0.13.0/src/ring/payload_log.py +110 -0
  9. ring_cli-0.13.0/src/ring/question_detect.py +93 -0
  10. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/registry.py +73 -22
  11. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/__init__.py +3 -1
  12. ring_cli-0.13.0/src/ring/sources/local_llm.py +157 -0
  13. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/transcript.py +20 -0
  14. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/tui.py +23 -5
  15. {ring_cli-0.11.4 → ring_cli-0.13.0}/LICENSE +0 -0
  16. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/__init__.py +0 -0
  17. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/__main__.py +0 -0
  18. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/__init__.py +0 -0
  19. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/_args.py +0 -0
  20. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/completion.py +0 -0
  21. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/digest.py +0 -0
  22. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/doctor.py +0 -0
  23. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/focus.py +0 -0
  24. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/gc.py +0 -0
  25. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/hook.py +0 -0
  26. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/stats.py +0 -0
  27. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/__init__.py +0 -0
  28. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/applescript.py +0 -0
  29. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/base.py +0 -0
  30. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/iterm2.py +0 -0
  31. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/linux_wm.py +0 -0
  32. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/neovim.py +0 -0
  33. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/terminal.py +0 -0
  34. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/tmux.py +0 -0
  35. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/gc.py +0 -0
  36. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/i18n.py +0 -0
  37. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/ipc.py +0 -0
  38. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/labels.py +0 -0
  39. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  40. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
  41. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/ring.pot +0 -0
  42. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/__init__.py +0 -0
  43. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/base.py +0 -0
  44. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/command.py +0 -0
  45. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/notify_send.py +0 -0
  46. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/ntfy.py +0 -0
  47. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/osascript_notifier.py +0 -0
  48. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/terminal_notifier.py +0 -0
  49. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/webhook.py +0 -0
  50. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/osascript.py +0 -0
  51. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/permission.py +0 -0
  52. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/plugins.py +0 -0
  53. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/base.py +0 -0
  54. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/claude_code.py +0 -0
  55. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/codex.py +0 -0
  56. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/hook_registry.py +0 -0
  57. {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/stats.py +0 -0
  58. {ring_cli-0.11.4 → ring_cli-0.13.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.13.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.
@@ -319,9 +326,31 @@ Claude Code events:
319
326
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
320
327
  | `SessionEnd` | removed from the board |
321
328
 
322
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
323
- also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
324
- 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
329
+ `Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission
330
+ request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at
331
+ the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any
332
+ trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`),
333
+ with the question as its detail; a question that only appears mid-message, with a statement at the end,
334
+ does not count — conservative on purpose, biased toward missing a case rather than a false positive. This
335
+ applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the
336
+ `detect_stop_questions` config key can turn it off (on by default).
337
+
338
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`,
339
+ and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare
340
+ event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while
341
+ an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence
342
+ itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than
343
+ `codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command
344
+ as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it.
345
+
346
+ Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish
347
+ these):
348
+
349
+ - **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running
350
+ command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up
351
+ action, 🔴 lingers until the next event or `Stop`.
352
+ - The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook
353
+ event to notify from); headless `--watch` does not send this particular notification.
325
354
 
326
355
  Verify that hooks are writing:
327
356
 
@@ -402,6 +431,8 @@ legend = true
402
431
  active_window_seconds = 21600
403
432
  working_threshold_seconds = 90
404
433
  waiting_window_seconds = 1800
434
+ codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off
435
+ detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡
405
436
  notify_sound = true
406
437
  notify_sound_name = "Glass"
407
438
  notify_ignore_dnd = false
@@ -431,7 +462,7 @@ RiNG is not tied to a specific tool or terminal.
431
462
 
432
463
  | Extension Point | Purpose | Built-ins |
433
464
  |-----------------|---------|-----------|
434
- | `SessionSource` | find sessions | Claude Code, Codex, hook registry |
465
+ | `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry |
435
466
  | `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
436
467
  | `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook |
437
468
 
@@ -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.
@@ -294,9 +301,31 @@ Claude Code events:
294
301
  | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting |
295
302
  | `SessionEnd` | removed from the board |
296
303
 
297
- Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex
298
- also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays
299
- 🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`.
304
+ `Stop` has one more exception: an agent sometimes doesn't use `AskUserQuestion` or trigger a permission
305
+ request, and just asks a plain-text question before stopping (e.g. "want me to fix B too?"). RiNG looks at
306
+ the **end** of the last assistant message for this: if it ends in a question mark (`?` / `?`, with any
307
+ trailing fenced code block stripped first) the session is promoted to 🔴 waiting (`waiting_kind="question"`),
308
+ with the question as its detail; a question that only appears mid-message, with a statement at the end,
309
+ does not count — conservative on purpose, biased toward missing a case rather than a false positive. This
310
+ applies to both Claude Code and Codex (`Stop` payloads from both carry `last_assistant_message`); the
311
+ `detect_stop_questions` config key can turn it off (on by default).
312
+
313
+ Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, `PostToolUse`,
314
+ and `Stop`. Codex also emits `PermissionRequest` before an existing policy auto-approves the call, so a bare
315
+ event stays 🟢 working at the hook level. Codex hooks have no "user approved" event and no heartbeat — while
316
+ an approval prompt is pending, the hook channel is **completely silent**. RiNG therefore treats the silence
317
+ itself as the signal: when the last hook event is a `PermissionRequest` and nothing has followed for more than
318
+ `codex_permission_wait_seconds` (default 10s), the board marks the session 🔴 waiting with the pending command
319
+ as its detail; any subsequent event (the next tool call, `Stop`) naturally clears it.
320
+
321
+ Known Codex-side limitations (verified against 0.144.4; the hook payload has no field that could distinguish
322
+ these):
323
+
324
+ - **Approve and deny are indistinguishable**: neither emits a hook event. After you approve a long-running
325
+ command, 🔴 lingers until the command finishes (`PostToolUse`); after a deny with no immediate follow-up
326
+ action, 🔴 lingers until the next event or `Stop`.
327
+ - The system notification for this timeout-promoted 🔴 is sent by the TUI's alert scheduler (there is no hook
328
+ event to notify from); headless `--watch` does not send this particular notification.
300
329
 
301
330
  Verify that hooks are writing:
302
331
 
@@ -377,6 +406,8 @@ legend = true
377
406
  active_window_seconds = 21600
378
407
  working_threshold_seconds = 90
379
408
  waiting_window_seconds = 1800
409
+ codex_permission_wait_seconds = 10 # bare Codex PermissionRequest + hook silence beyond this → 🔴 waiting; 0 = off
410
+ detect_stop_questions = true # promote Stop to 🔴 waiting when it ends in a plain-text question; false = Stop always 🟡
380
411
  notify_sound = true
381
412
  notify_sound_name = "Glass"
382
413
  notify_ignore_dnd = false
@@ -406,7 +437,7 @@ RiNG is not tied to a specific tool or terminal.
406
437
 
407
438
  | Extension Point | Purpose | Built-ins |
408
439
  |-----------------|---------|-----------|
409
- | `SessionSource` | find sessions | Claude Code, Codex, hook registry |
440
+ | `SessionSource` | find sessions | Claude Code, Codex, Ollama, llama.cpp, hook registry |
410
441
  | `Focuser` | jump to terminals | tmux, iTerm2, Terminal.app, Linux X11 (wmctrl) |
411
442
  | `Notifier` | notify when sessions are waiting | terminal-notifier, osascript, notify-send, ntfy, webhook |
412
443
 
@@ -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.13.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",
@@ -402,6 +402,8 @@ def run_config(args: list[str]) -> int:
402
402
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
403
403
  # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
404
404
  # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
405
+ # 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的
406
+ # 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。
405
407
  frames = 0
406
408
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
407
409
  if not HAVE_RICH:
@@ -9,6 +9,11 @@
9
9
  active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
10
10
  working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
11
11
  waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
12
+ codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數
13
+ # → 看板判定真的停下來等核可(🔴 等你);0 = 關閉
14
+ detect_stop_questions = true # Stop 事件時,若最後一則 assistant 訊息「結尾」是純文字
15
+ # 提問(沒用 AskUserQuestion、沒有權限請求)→ 🔴 等你
16
+ # (waiting_kind="question");關閉則 Stop 一律 🟡
12
17
  notify_sound = true # 系統通知帶聲音
13
18
  notify_sound_name = "Glass" # macOS / terminal-notifier sound name
14
19
  notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
@@ -27,6 +32,10 @@
27
32
  notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
28
33
  focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
29
34
  plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*)
35
+ debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload
36
+ # (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl);
37
+ # 預設關閉,payload 可能含使用者輸入才要開;
38
+ # 環境變數 RING_DEBUG_PAYLOAD_LOG 可覆寫(優先於本鍵)
30
39
  """
31
40
 
32
41
  from __future__ import annotations
@@ -60,6 +69,13 @@ class Config:
60
69
  active_window_seconds: int = 6 * 60 * 60
61
70
  working_threshold_seconds: int = 90
62
71
  waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
72
+ # Codex 的 hook 沒有「使用者已核可」事件也沒有心跳(0.144.4 實證):policy 自動放行時
73
+ # 下一個事件幾秒內就到;真的停下來等人時 hook 通道完全靜默。所以「最後一個事件是
74
+ # PermissionRequest 且已靜默超過這個門檻」就判定在等核可。0 = 關閉這個判定。
75
+ codex_permission_wait_seconds: int = 10
76
+ # Stop 事件時,若最後一則 assistant 訊息結尾是純文字提問(見 question_detect.py)
77
+ # → 升級成 🔴 等你(waiting_kind="question")。預設開;關閉則 Stop 一律維持 🟡。
78
+ detect_stop_questions: bool = True
63
79
  notify_sound: bool = True
64
80
  notify_sound_name: str = "Glass"
65
81
  notify_ignore_dnd: bool = False
@@ -77,6 +93,7 @@ class Config:
77
93
  notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
78
94
  focusers: tuple[str, ...] = () # 空=用內建預設順序
79
95
  plugins: tuple[str, ...] = () # 啟動時 import 的外部 plugin 模組(entry point 之外的本機路)
96
+ debug_payload_log: bool = False # 診斷用:記錄 hook 收到的原始 payload;預設關閉
80
97
  colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))
81
98
 
82
99
 
@@ -134,6 +151,10 @@ def load(path: Path | None = None) -> Config:
134
151
  active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
135
152
  working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
136
153
  waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
154
+ codex_permission_wait_seconds=_as_int(
155
+ raw.get("codex_permission_wait_seconds"), d.codex_permission_wait_seconds
156
+ ),
157
+ detect_stop_questions=_as_bool(raw.get("detect_stop_questions"), d.detect_stop_questions),
137
158
  notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
138
159
  notify_sound_name=(
139
160
  raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
@@ -148,6 +169,7 @@ def load(path: Path | None = None) -> Config:
148
169
  notify_also=_as_str_tuple(raw.get("notify_also")),
149
170
  focusers=_as_str_tuple(raw.get("focusers")),
150
171
  plugins=_as_str_tuple(raw.get("plugins")),
172
+ debug_payload_log=_as_bool(raw.get("debug_payload_log"), d.debug_payload_log),
151
173
  colors=_parse_colors(raw.get("colors")),
152
174
  )
153
175
 
@@ -206,6 +228,8 @@ _SETTERS: dict[str, Callable[[str], object]] = {
206
228
  "active_window_seconds": _coerce_int,
207
229
  "working_threshold_seconds": _coerce_int,
208
230
  "waiting_window_seconds": _coerce_int,
231
+ "codex_permission_wait_seconds": _coerce_int,
232
+ "detect_stop_questions": _coerce_bool,
209
233
  "notify_sound": _coerce_bool,
210
234
  "notify_sound_name": str,
211
235
  "notify_ignore_dnd": _coerce_bool,
@@ -218,6 +242,7 @@ _SETTERS: dict[str, Callable[[str], object]] = {
218
242
  "notify_also": _coerce_str_list,
219
243
  "focusers": _coerce_str_list,
220
244
  "plugins": _coerce_str_list,
245
+ "debug_payload_log": _coerce_bool,
221
246
  }
222
247
 
223
248
 
@@ -9,8 +9,15 @@ Agent CLI 在各事件把一段 JSON 從 stdin 餵進來。我們據此 upsert
9
9
  事件 → 狀態:
10
10
  SessionStart / UserPromptSubmit → 🟢 工作中(剛開始 / 你剛回話,台上在跑)
11
11
  PreToolUse(非 action)/ PostToolUse → 🟢 工作中(工具在動,順便清掉剛答完的等你)
12
- Stop → 🟡 跑完停著(回完一輪,不代表需要你回應)
13
- PermissionRequest / actionable Notification / AskUserQuestion → 🔴 等你(需要你決策)
12
+ Stop → 🟡 跑完停著(回完一輪,不代表需要你回應);但最後一則
13
+ assistant 訊息「結尾」像純文字提問時 → 🔴 等你
14
+ (waiting_kind="question",見 question_detect.py;
15
+ config 鍵 detect_stop_questions 可關閉,預設開)
16
+ PermissionRequest(裸) → 🟢 工作中(權限判定中,多半瞬間自動放行;Claude Code
17
+ 真的停下來等人由後續的 permission_prompt Notification
18
+ 兜底轉 🔴;Codex 沒有這種事件,改由讀取側的靜默逾時
19
+ 判定補上,見 registry._promote_codex_permission_wait)
20
+ actionable Notification / AskUserQuestion → 🔴 等你(需要你決策)
14
21
  SessionEnd → 刪檔(乾淨離場)
15
22
  """
16
23
 
@@ -22,15 +29,17 @@ import shutil
22
29
  import subprocess
23
30
  import sys
24
31
  import time
25
- from dataclasses import dataclass
32
+ from dataclasses import dataclass, replace
26
33
  from pathlib import Path
27
34
  from typing import Any
28
35
  from urllib.parse import quote
29
36
 
30
37
  from ring.config import get_config
31
- from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload
38
+ from ring.hook_protocol import HOOK_EVENTS, NormalizedHookEvent, adapter_for, provider_from_payload
32
39
  from ring.i18n import gettext as _
33
40
  from ring.i18n import set_lang
41
+ from ring.payload_log import maybe_log_raw_payload
42
+ from ring.question_detect import stop_last_assistant_text, trailing_question_detail
34
43
  from ring.registry import (
35
44
  RING_REGISTRY,
36
45
  Session,
@@ -45,9 +54,10 @@ _HOOK_EVENTS = list(HOOK_EVENTS)
45
54
 
46
55
  # Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。
47
56
  # 保守取有實證可用的:PermissionRequest(裸事件只代表權限準備判定;明確需互動才 → 🔴)、
48
- # PreToolUse(→ 動作/清除)、Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件
49
- # 有風險,故不照搬 Claude 全套。
50
- _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"]
57
+ # PreToolUse(→ 動作/清除)、PostToolUse(工具跑完 → 清除;0.144.4 binary 內嵌 schema
58
+ # 證實存在——它是「核可後最早的下一個事件」,能盡早清掉讀取側的核可等待判定)、
59
+ # Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。
60
+ _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "PostToolUse", "Stop"]
51
61
 
52
62
  # hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會
53
63
  # block 到使用者作答。install 用它判斷「既有條目要不要更新」——舊版裝的 timeout=10 會被升上來。
@@ -157,6 +167,9 @@ def run_hook(provider: str = "claude-code") -> int:
157
167
  return 0
158
168
 
159
169
  selected_provider = provider_from_payload(data, fallback=provider)
170
+ # 取證 log:診斷用、預設關閉(見 payload_log.py)。故意放在任何狀態判定/改寫之前,
171
+ # 記下 hook 實際收到的原始資料——不影響、不參與下面的正規化流程。
172
+ maybe_log_raw_payload(selected_provider, data)
160
173
  _record_session_state(data, selected_provider)
161
174
  return _delegate_to_agent_hooks(raw, selected_provider)
162
175
 
@@ -167,6 +180,7 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
167
180
  event = adapter.normalize(data)
168
181
  if event is None:
169
182
  return
183
+ event = _maybe_flag_trailing_question(event, data)
170
184
 
171
185
  unhide_session(event.session_id)
172
186
  path = RING_REGISTRY / f"{quote(event.session_id, safe=':')}.json"
@@ -193,6 +207,9 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
193
207
  "cwd": event.cwd,
194
208
  "origin_cwd": event.cwd,
195
209
  "status": event.status.value,
210
+ # 最後一個 hook 事件名:讀取側據此做 codex 的核可等待判定(最後事件是
211
+ # PermissionRequest 且靜默逾時 → 🔴),任何後續事件覆寫它就自然清紅。
212
+ "last_event": event.event,
196
213
  "last_active": now,
197
214
  "heartbeat_at": now,
198
215
  "last_action": last_action,
@@ -207,10 +224,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
207
224
  payload["todo"] = list(todo)
208
225
  if event.waiting_for:
209
226
  payload["waiting_for"] = event.waiting_for
227
+
228
+ # 裸 PermissionRequest(→ WORKING)帶著最具體的 tool/指令摘要;真的停下來等人時,
229
+ # Claude Code 只補發籠統的 permission_prompt Notification("Claude needs your
230
+ # permission",不含 tool/指令)。所以把摘要暫存進 row,Notification 轉 WAITING 時
231
+ # (還新鮮的話)拿它當 waiting_detail,看板與通知才看得到具體是哪條指令在等核可。
232
+ # Codex 沒有 Notification:這份暫存改由讀取側的靜默逾時判定拿去當 waiting_detail
233
+ # (見 registry._promote_codex_permission_wait)。
234
+ pending_detail, pending_at = _pending_permission_detail(event, prev_row, now)
235
+ if pending_detail:
236
+ payload["pending_permission_detail"] = pending_detail
237
+ payload["pending_permission_detail_at"] = pending_at
238
+
210
239
  if event.status is Status.WAITING and event.waiting_kind:
211
240
  payload["waiting_kind"] = event.waiting_kind
212
- if event.status is Status.WAITING and event.detail:
213
- payload["waiting_detail"] = event.detail
241
+ if event.status is Status.WAITING:
242
+ waiting_detail = event.detail
243
+ if event.event == "Notification" and event.waiting_kind == "permission" and pending_detail:
244
+ waiting_detail = pending_detail
245
+ if waiting_detail:
246
+ payload["waiting_detail"] = waiting_detail
214
247
  tty = event.tty or _controlling_tty() or _session_tty(adapter.process_names)
215
248
  if tty:
216
249
  payload["tty"] = tty
@@ -240,6 +273,26 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
240
273
  _ring_waiting_now(event, payload, last_action)
241
274
 
242
275
 
276
+ def _maybe_flag_trailing_question(event: NormalizedHookEvent, data: dict[str, Any]) -> NormalizedHookEvent:
277
+ """Stop 事件、目前判定 🟡 跑完停著、且訊息結尾像純文字提問時,升級成 🔴 等你。
278
+
279
+ 這是 hook_protocol._ALWAYS_STATUS 的 Stop→IDLE 之外唯一會讀 assistant 文字的地方——
280
+ 寫死的映射本身測不到「agent 用文字問了問題但沒用 AskUserQuestion」這種情況(見
281
+ ring.question_detect 模組 docstring)。保守判準:只看訊息結尾,中段有問句、結尾是
282
+ 陳述句一律不算,寧可漏報不要誤報。config 關閉(detect_stop_questions=false)或非
283
+ Stop / 非 IDLE 一律原樣放行;抓不到文字或結尾不是問句也放行。
284
+ """
285
+ if event.event != "Stop" or event.status is not Status.IDLE:
286
+ return event
287
+ if not get_config().detect_stop_questions:
288
+ return event
289
+ text = stop_last_assistant_text(data)
290
+ detail = trailing_question_detail(text)
291
+ if not detail:
292
+ return event
293
+ return replace(event, status=Status.WAITING, waiting_kind="question", detail=detail)
294
+
295
+
243
296
  def _previous_row(path: Path) -> dict[str, Any]:
244
297
  """讀 registry 檔目前的內容(給轉換偵測與通知冷卻用);沒檔 / 壞檔回空 dict。"""
245
298
  try:
@@ -254,6 +307,32 @@ def _as_epoch(v: Any) -> float | None:
254
307
  return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
255
308
 
256
309
 
310
+ # 暫存的權限 detail 新鮮期(秒):permission_prompt Notification 通常在裸 PermissionRequest
311
+ # 後 ~6 秒到;超過這個窗口的暫存視為過期(大概率屬於已解決的舊請求),不再拿來當 detail。
312
+ _PENDING_DETAIL_TTL = 120.0
313
+
314
+ # 這些事件代表權限已放行(工具跑完)或回合已推進(新輸入 / 回合結束)→ 清掉暫存。
315
+ _PENDING_DETAIL_CLEAR_EVENTS = {"PostToolUse", "UserPromptSubmit", "Stop"}
316
+
317
+
318
+ def _pending_permission_detail(event: NormalizedHookEvent, prev_row: dict[str, Any], now: float) -> tuple[str, float]:
319
+ """決定這次要寫進 row 的「暫存權限 detail」:(detail, 暫存時間戳);不留就回 ("", 0.0)。
320
+
321
+ - 裸 PermissionRequest(→ WORKING)帶 detail → 以這次的 detail 起新暫存
322
+ - PostToolUse / UserPromptSubmit / Stop → 清掉(已放行或回合推進,舊暫存失效)
323
+ - 其餘事件 → 沿用 prev_row 的暫存(row 每次重建,不帶會遺失),但過期就丟
324
+ """
325
+ if event.event == "PermissionRequest" and event.status is Status.WORKING and event.detail:
326
+ return event.detail, now
327
+ if event.event in _PENDING_DETAIL_CLEAR_EVENTS:
328
+ return "", 0.0
329
+ prev_detail = prev_row.get("pending_permission_detail")
330
+ prev_at = _as_epoch(prev_row.get("pending_permission_detail_at"))
331
+ if isinstance(prev_detail, str) and prev_detail and prev_at is not None and now - prev_at <= _PENDING_DETAIL_TTL:
332
+ return prev_detail, prev_at
333
+ return "", 0.0
334
+
335
+
257
336
  def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool:
258
337
  """hook 的 waiting 通知是否放行:距上次通知未滿 ``waiting_cooldown_seconds`` 就不發。
259
338
 
@@ -270,9 +349,11 @@ def _waiting_ring_allowed(last_notified: float | None, now: float) -> bool:
270
349
  def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
271
350
  """session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。
272
351
 
273
- WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」
352
+ WAITING 是 hook 資料驅動的(scan 模式永不標 WAITING),hook 是第一手知道「等你」
274
353
  的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
275
- 你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
354
+ 你)。唯一的例外是 codex 的核可等待:那是讀取側對 hook row 的靜默逾時判定,沒有
355
+ 對應的 hook 事件可在這裡發通知,由 TUI 的提醒排程器代發(見 tui._ring_on_waiting_alerts)。
356
+ backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
276
357
  (agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。
277
358
  失敗安靜吞掉,絕不擋住 session。
278
359
  """
@@ -44,7 +44,6 @@ _ALWAYS_STATUS = {
44
44
  "UserPromptSubmit": Status.WORKING,
45
45
  "Stop": Status.IDLE,
46
46
  "SessionEnd": Status.ENDED,
47
- "PermissionRequest": Status.WAITING,
48
47
  }
49
48
 
50
49
  _ACTION_REQUIRED_NOTIFICATION_TYPES = {
@@ -105,13 +104,17 @@ class CommonHookAdapter:
105
104
  status = Status.WORKING
106
105
  elif explicit_requires_action is not None:
107
106
  status = Status.WAITING if explicit_requires_action else Status.IDLE
108
- elif event == "PermissionRequest" and self.provider == "codex":
109
- # Codex 會在權限「準備判定」時送 PermissionRequest;即使既有 policy
110
- # 隨後自動放行、畫面從未停下來等人,也會經過這個 hook。裸事件因此只能
111
- # 證明 agent 還在處理工具呼叫,不能當成使用者需要回應。若 payload 有
112
- # requires_action / waiting_for 等明確訊號,已由上面的分支判成 WAITING;
113
- # 明確的 requires_action=false 也在上面判成 IDLE,不會落到這裡的 WORKING。
114
- status = Status.WORKING
107
+ elif event == "PermissionRequest":
108
+ # PermissionRequest 在權限「準備判定」時就發,不分 provider:Claude Code
109
+ # 連 subagent 的唯讀工具呼叫都會經過(帶 agent_id/agent_type),Codex 則在
110
+ # policy 判定前發。多數在幾秒內被 policy 自動放行、畫面從未停下來等人——
111
+ # 裸事件因此只能證明 agent 還在處理工具呼叫,不能當成使用者需要回應
112
+ # → WORKING。payload 本身就要求互動(AskUserQuestion / questions / options)
113
+ # 才直接判 WAITING。Claude Code 真的停下來等人時,會在數秒後補發
114
+ # notification_type=permission_prompt 的 Notification,由下面的 Notification
115
+ # 分支兜底轉 WAITING(內建 debounce)。顯式 requires_action / waiting_for
116
+ # 訊號已由上面的分支先處理(true → WAITING、false → IDLE),不會落到這裡。
117
+ status = Status.WAITING if _is_action_required_payload(data) else Status.WORKING
115
118
  elif event == "Notification":
116
119
  status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE
117
120
  elif event == "PreToolUse":
@@ -238,12 +241,14 @@ def _waiting_kind(data: Mapping[str, Any], event: str, status: Status) -> str:
238
241
 
239
242
  if "plan" in waiting_for or "plan" in tool_name or "plan" in detail:
240
243
  return "plan"
244
+ if tool_name == "askuserquestion":
245
+ # 先於 PermissionRequest 判斷:AskUserQuestion 也會以 PermissionRequest 事件
246
+ # 進來(權限判定包著問題),但使用者要回的是「問題」不是「權限」。
247
+ return "question"
241
248
  if event == "PermissionRequest" or notification_type == "permission_prompt":
242
249
  return "permission"
243
250
  if waiting_for in {"approval", "permission"}:
244
251
  return "permission"
245
- if tool_name == "askuserquestion":
246
- return "question"
247
252
  if waiting_for in {
248
253
  "choice",
249
254
  "choices",
@@ -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
@@ -0,0 +1,93 @@
1
+ """偵測 Stop 事件裡「回合結尾純文字提問」。
2
+
3
+ agent 有時不用 ``AskUserQuestion``、不觸發權限請求,只是在文字裡問了一句話就停下來
4
+ (例如「要不要順便修 B?」)。既有的 Stop → 🟡 跑完停著是寫死的(見
5
+ ``hook_protocol._ALWAYS_STATUS``),從不讀 assistant 文字,這類提問因此測不到、使用者
6
+ 以為沒東西要回。
7
+
8
+ 設計原則:保守,寧可漏報也不要誤報。只看**訊息結尾**:
9
+
10
+ - 問句出現在訊息中段、結尾是陳述句 → 不算(避免把「先確認一下:你要 A 還是 B?我會用
11
+ A 繼續做」這種訊息誤判——它結尾是陳述句,不該轉紅)。
12
+ - 結尾若是圍欄程式碼區塊(```` ``` ... ``` ````),先把整個區塊剝掉再看區塊之前的最後一行
13
+ ——常見於「這樣可以嗎?\\n\\n```bash\\nls -la\\n```」,圍欄本身不是問句,但它前面那句是。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from ring.transcript import _last_assistant_text, _tail_records
23
+
24
+ _DETAIL_MAX = 160
25
+
26
+ # 收尾判斷前,先剝掉這些包裹字元(markdown 強調記號、引號、括號),
27
+ # 例如「...要繼續嗎?**」「...要繼續嗎?」」。
28
+ _TRAILING_WRAPPERS = "*_`\"' )]>"
29
+
30
+ _QUESTION_MARKS = ("?", "?")
31
+
32
+
33
+ def stop_last_assistant_text(data: Mapping[str, Any]) -> str:
34
+ """取得 Stop payload 對應的最後一則 assistant 訊息文字。
35
+
36
+ 優先讀 payload 本身的 ``last_assistant_message``(Claude Code、Codex 的實際 Stop
37
+ payload 都帶這欄——見 ``~/.config/ring/hook_payloads.jsonl`` 實錄);缺欄位或空字串
38
+ 才退回從 ``transcript_path`` 尾端讀(有界讀取,缺檔/壞格式安全回空字串,不炸)。
39
+ """
40
+ text = data.get("last_assistant_message")
41
+ if isinstance(text, str) and text.strip():
42
+ return text
43
+ tp = data.get("transcript_path") or data.get("transcriptPath")
44
+ if isinstance(tp, str) and tp:
45
+ try:
46
+ records = _tail_records(Path(tp))
47
+ except Exception:
48
+ return ""
49
+ return _last_assistant_text(records)
50
+ return ""
51
+
52
+
53
+ def _strip_trailing_code_fence(text: str) -> str:
54
+ """剝掉文字尾端的圍欄程式碼區塊(可能不只一個),回傳剝除後的文字。"""
55
+ lines = text.splitlines()
56
+ changed = True
57
+ while changed and lines:
58
+ changed = False
59
+ while lines and not lines[-1].strip():
60
+ lines.pop()
61
+ if lines and lines[-1].strip().startswith("```"):
62
+ closing = len(lines) - 1
63
+ opening = None
64
+ for i in range(closing - 1, -1, -1):
65
+ if lines[i].strip().startswith("```"):
66
+ opening = i
67
+ break
68
+ if opening is not None:
69
+ lines = lines[:opening]
70
+ changed = True
71
+ return "\n".join(lines)
72
+
73
+
74
+ def _strip_trailing_wrappers(line: str) -> str:
75
+ line = line.rstrip()
76
+ while line and line[-1] in _TRAILING_WRAPPERS:
77
+ line = line[:-1].rstrip()
78
+ return line
79
+
80
+
81
+ def trailing_question_detail(text: str) -> str:
82
+ """``text`` 的結尾若是問句,回傳該行摘要(截斷合理長度);否則回傳空字串。"""
83
+ if not text:
84
+ return ""
85
+ stripped = _strip_trailing_code_fence(text.rstrip())
86
+ lines = [ln for ln in stripped.splitlines() if ln.strip()]
87
+ if not lines:
88
+ return ""
89
+ last_line = _strip_trailing_wrappers(lines[-1])
90
+ if not last_line or last_line[-1] not in _QUESTION_MARKS:
91
+ return ""
92
+ detail = " ".join(last_line.split())
93
+ return detail if len(detail) <= _DETAIL_MAX else detail[: _DETAIL_MAX - 1] + "…"
@@ -8,7 +8,9 @@
8
8
  2. zero-config fallback:直接掃 ``~/.claude/projects/**/*.jsonl``,用檔案 mtime
9
9
  推活躍度,從記錄裡的 ``cwd`` 欄位還原真實路徑(避開目錄名以 ``-`` 編碼
10
10
  造成的 hyphen 還原歧義)。scan 模式不把「回完一輪」當成 🔴 WAITING;
11
- WAITING 只保留給 hook 可確認的權限 / 選項等互動。
11
+ WAITING 一律由 hook 資料驅動:hook 事件直接標的權限 / 選項互動,加上
12
+ codex 的核可等待靜默逾時判定(``_promote_codex_permission_wait``——它讀的
13
+ 也是 hook row,是推遲判定而非 scan 猜測)。
12
14
 
13
15
  額外富化:
14
16
  - ``tmux_target``:靠 tmux pane 的 current_path 對 cwd,給你「去哪」的座標。
@@ -53,6 +55,8 @@ _CFG = get_config()
53
55
  ACTIVE_WINDOW_SECONDS = _CFG.active_window_seconds # 只看最近這段時間動過的 session(預設 6h)
54
56
  WORKING_THRESHOLD_SECONDS = _CFG.working_threshold_seconds # 多久沒動 → 🟢 工作中 變 🟡 閒置
55
57
  WAITING_WINDOW_SECONDS = _CFG.waiting_window_seconds # IDLE 升 WAITING 的時間窗上限(預設 30 分)
58
+ # codex 裸 PermissionRequest 後 hook 靜默超過這秒數 → 判定真的停下來等核可(0 = 關閉)
59
+ CODEX_PERMISSION_WAIT_SECONDS = _CFG.codex_permission_wait_seconds
56
60
  _SUBPROCESS_CACHE_TTL = 1.0 # ps / tmux 結果的短快取,省掉同一次刷新內的重複呼叫
57
61
 
58
62
  # Claude Code SessionStart payload 的 source 值(不是 provider)。舊版 bug 曾把它誤當
@@ -349,7 +353,9 @@ def _apply_waiting(
349
353
  """對話尾是 end_turn 且在時間窗內時,將 live/idle scan row 收斂為 IDLE。
350
354
 
351
355
  純函式、可單測,不依賴 module-level 常數。
352
- 不把回合結束升成 WAITING;WAITING 只保留給 hook 確認的權限 / 選項互動:
356
+ 不把回合結束升成 WAITING;WAITING 一律由 hook 資料驅動(hook 直接標的權限 /
357
+ 選項互動,或 ``_promote_codex_permission_wait`` 對 hook row 的靜默逾時判定),
358
+ scan 猜測永遠不標:
353
359
  - WORKING(< 90s):若尾端已是 end_turn,代表回合其實結束了,收斂成 IDLE。
354
360
  - ENDED:超過活躍窗,不升。
355
361
  """
@@ -358,6 +364,34 @@ def _apply_waiting(
358
364
  return status
359
365
 
360
366
 
367
+ def _promote_codex_permission_wait(
368
+ provider: str,
369
+ status: Status,
370
+ last_event: str,
371
+ age_seconds: float,
372
+ threshold: float,
373
+ ) -> bool:
374
+ """codex hook row 的核可等待判定:裸 PermissionRequest 後靜默逾時 → 該升 🔴 嗎。
375
+
376
+ Codex(0.144.4 實證)的 hook 是封閉的 10 事件枚舉:沒有「使用者已核可」事件、沒有
377
+ 心跳,rollout 檔在等核可期間也完全靜默。policy 自動放行時,下一個事件(PostToolUse /
378
+ 下一個 PreToolUse / Stop)幾秒內就會到;真的停下來等人時 hook 通道只會一直沉默。
379
+ 所以「最後一個 hook 事件是 PermissionRequest 且已靜默超過門檻」本身就是可靠的等待
380
+ 訊號——這是對 hook 資料的推遲判定,不是 scan 猜測。任何後續 hook 事件會覆寫
381
+ last_event,自然清紅。
382
+
383
+ 只對 codex 啟用:claude-code 真的停下來等人時會補發 permission_prompt Notification
384
+ (hook 直接標 🔴),不需要、也不該重複走這條路。純函式、可單測。
385
+ """
386
+ return (
387
+ provider == "codex"
388
+ and status is Status.WORKING
389
+ and last_event == "PermissionRequest"
390
+ and threshold > 0
391
+ and age_seconds > threshold
392
+ )
393
+
394
+
361
395
  def _hook_heartbeat_stale(
362
396
  source_path: str,
363
397
  heartbeat_at: float,
@@ -872,7 +906,10 @@ def running_agent_pids() -> list[int]:
872
906
  顯示用途,不是存活判定;真正的 ENDED 判定路徑(``_hook_sessions``)用的是
873
907
  未攤平的 ``running_claude_pids`` / ``running_codex_pids`` 原始回傳值。
874
908
  """
875
- return [*(running_claude_pids() or []), *(running_codex_pids() or [])]
909
+ # 延後 import,避免 registry(Session model)與 sources package 初始化時循環相依。
910
+ from ring.sources.local_llm import running_pids as running_local_llm_pids
911
+
912
+ return [*(running_claude_pids() or []), *(running_codex_pids() or []), *running_local_llm_pids()]
876
913
 
877
914
 
878
915
  def _pids_cwd(pids: list[int]) -> dict[int, str] | None:
@@ -1326,26 +1363,40 @@ def _hook_sessions(
1326
1363
  if purge_session_start_phantoms:
1327
1364
  f.unlink(missing_ok=True)
1328
1365
  continue
1329
- out.append(
1330
- Session(
1331
- session_id=str(data["session_id"]),
1332
- cwd=str(data.get("cwd", "")),
1333
- status=Status(data.get("status", "idle")),
1334
- last_active=float(data.get("last_active", 0.0)),
1335
- last_action=str(data.get("last_action", "—")),
1336
- source="hook",
1337
- tmux_pane=str(data.get("tmux_pane", "")) or None,
1338
- tty=str(data.get("tty", "")) or None,
1339
- hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None,
1340
- heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))),
1341
- source_path=str(data.get("source_path", "")),
1342
- todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None,
1343
- provider=provider,
1344
- waiting_kind=str(data.get("waiting_kind", "")),
1345
- waiting_detail=str(data.get("waiting_detail", "")),
1346
- origin_cwd=str(data.get("origin_cwd", "")),
1347
- )
1366
+ row = Session(
1367
+ session_id=str(data["session_id"]),
1368
+ cwd=str(data.get("cwd", "")),
1369
+ status=Status(data.get("status", "idle")),
1370
+ last_active=float(data.get("last_active", 0.0)),
1371
+ last_action=str(data.get("last_action", "—")),
1372
+ source="hook",
1373
+ tmux_pane=str(data.get("tmux_pane", "")) or None,
1374
+ tty=str(data.get("tty", "")) or None,
1375
+ hook_pid=int(data["hook_pid"]) if str(data.get("hook_pid", "")).isdigit() else None,
1376
+ heartbeat_at=float(data.get("heartbeat_at", data.get("last_active", 0.0))),
1377
+ source_path=str(data.get("source_path", "")),
1378
+ todo=tuple(todo) if isinstance(todo, list) and len(todo) == 2 else None,
1379
+ provider=provider,
1380
+ waiting_kind=str(data.get("waiting_kind", "")),
1381
+ waiting_detail=str(data.get("waiting_detail", "")),
1382
+ origin_cwd=str(data.get("origin_cwd", "")),
1348
1383
  )
1384
+ if _promote_codex_permission_wait(
1385
+ _canonical_provider(provider),
1386
+ row.status,
1387
+ str(data.get("last_event", "")),
1388
+ time.time() - row.last_active,
1389
+ CODEX_PERMISSION_WAIT_SECONDS,
1390
+ ):
1391
+ row.status = Status.WAITING
1392
+ row.waiting_kind = "permission"
1393
+ pending_detail = data.get("pending_permission_detail")
1394
+ if isinstance(pending_detail, str) and pending_detail:
1395
+ # hook 在裸 PermissionRequest 當下暫存的指令摘要(120s TTL 只管
1396
+ # 「下一個事件來時還新不新鮮」;這裡 hook 靜默 = 同一筆請求還掛著,
1397
+ # 摘要必然還是它,直接沿用)。
1398
+ row.waiting_detail = pending_detail
1399
+ out.append(row)
1349
1400
  except (KeyError, ValueError):
1350
1401
  continue
1351
1402
  for s in out:
@@ -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")
@@ -131,6 +131,26 @@ def _latest_action(records: list[dict[str, Any]]) -> str:
131
131
  return "—"
132
132
 
133
133
 
134
+ def _last_assistant_text(records: list[dict[str, Any]]) -> str:
135
+ """從新到舊找最後一筆帶文字的 assistant 記錄,回傳其 text block 合併後的完整文字。
136
+
137
+ 跳過沒有 text block 的 assistant 記錄(例如純 tool_use);找不到回空字串。
138
+ 給 Stop 事件「回合結尾純文字提問」偵測用——跟 ``_latest_action`` 不同,這裡要完整
139
+ 文字(不截斷、不退回 tool_use 摘要),才能判斷訊息結尾是不是問句。
140
+ """
141
+ for record in reversed(records):
142
+ if record.get("type") != "assistant":
143
+ continue
144
+ texts = [
145
+ str(b.get("text", ""))
146
+ for b in _blocks(record)
147
+ if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
148
+ ]
149
+ if texts:
150
+ return "\n".join(texts)
151
+ return ""
152
+
153
+
134
154
  def _extract_todo(records: list[dict[str, Any]]) -> tuple[int, int] | None:
135
155
  """從新到舊找最新的 TodoWrite,回傳 (done, total)。真進度訊號。"""
136
156
  for record in reversed(records):
@@ -289,11 +289,29 @@ class RingApp(App[None]):
289
289
  return labeled_project(s.project, get_label(s.session_id))
290
290
 
291
291
  def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None:
292
- """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。"""
293
- if alerts:
294
- self.bell()
295
- names = ", ".join(sorted(self._display_name(s) for s in alerts))
296
- self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
292
+ """有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。
293
+
294
+ 系統通知原則上由 ``ring hook`` 在轉 🔴 的事件當下發(這裡不重複發,只響
295
+ in-app 鈴)。唯一的例外是 codex 的核可等待:它是讀取側的靜默逾時判定
296
+ (registry._promote_codex_permission_wait),沒有對應 hook 事件可發通知,
297
+ 所以由這裡代發系統通知——初次與重複提醒都走 WaitingAlertScheduler 同一條路。
298
+ codex + waiting_kind="permission" 只可能來自逾時判定(hook 對 codex 裸
299
+ PermissionRequest 一律記 working;AskUserQuestion 形狀的是 "question"),
300
+ 不會跟 hook 端的通知重複。失敗安靜吞,不影響看板。
301
+ """
302
+ if not alerts:
303
+ return
304
+ self.bell()
305
+ names = ", ".join(sorted(self._display_name(s) for s in alerts))
306
+ self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
307
+ promoted = [s for s in alerts if s.provider == "codex" and s.waiting_kind == "permission"]
308
+ if promoted:
309
+ try:
310
+ from ring.notify import notify_waiting
311
+
312
+ notify_waiting(promoted)
313
+ except Exception:
314
+ pass
297
315
 
298
316
  def _activate_own_window(self) -> None:
299
317
  """把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes