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.
- {ring_cli-0.11.4 → ring_cli-0.13.0}/PKG-INFO +37 -6
- {ring_cli-0.11.4 → ring_cli-0.13.0}/README.en.md +35 -4
- {ring_cli-0.11.4 → ring_cli-0.13.0}/pyproject.toml +3 -1
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/cli.py +2 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/config.py +25 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/hook.py +92 -11
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/hook_protocol.py +15 -10
- ring_cli-0.13.0/src/ring/payload_log.py +110 -0
- ring_cli-0.13.0/src/ring/question_detect.py +93 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/registry.py +73 -22
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/__init__.py +3 -1
- ring_cli-0.13.0/src/ring/sources/local_llm.py +157 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/transcript.py +20 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/tui.py +23 -5
- {ring_cli-0.11.4 → ring_cli-0.13.0}/LICENSE +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/gc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/labels.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/permission.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.11.4 → ring_cli-0.13.0}/src/ring/stats.py +0 -0
- {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.
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.
|
|
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
|
-
|
|
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(→ 動作/清除)、
|
|
49
|
-
#
|
|
50
|
-
|
|
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
|
|
213
|
-
|
|
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
|
|
352
|
+
WAITING 是 hook 資料驅動的(scan 模式永不標 WAITING),hook 是第一手知道「等你」
|
|
274
353
|
的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
|
|
275
|
-
|
|
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"
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|