diff --git a/README.en.md b/README.en.md index 146dce0..9609af3 100644 --- a/README.en.md +++ b/README.en.md @@ -294,7 +294,9 @@ Claude Code events: | `PermissionRequest` / `PreToolUse` with `AskUserQuestion` | 🔴 waiting | | `SessionEnd` | removed from the board | -Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. +Codex currently installs the supported interactive events: `PreToolUse`, `PermissionRequest`, and `Stop`. Codex +also emits `PermissionRequest` before an existing policy auto-approves the call; a bare event therefore stays +🟢 working, and it only turns 🔴 waiting when the payload explicitly carries `requires_action` / `waiting_for`. Verify that hooks are writing: diff --git a/README.md b/README.md index 78c1afc..71ef224 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,9 @@ Claude Code 註冊這幾個事件,對應到狀態: | `PermissionRequest` / `PreToolUse` 的 `AskUserQuestion` | 🔴 等你(權限 / 選項需要你決策) | | `SessionEnd` | 從看板消失 | -Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`Stop`。 +Codex 目前註冊 Codex 支援的互動事件:`PreToolUse`、`PermissionRequest`、`Stop`。Codex 的 +`PermissionRequest` 也會在既有 policy 自動放行前送出;因此裸事件維持 🟢 工作中,只有 payload +明確標示 `requires_action` / `waiting_for` 時才轉成 🔴 等你。 hook 只對**新開的 session** 生效,所以裝完要重開。確認方法: diff --git a/src/ring/hook.py b/src/ring/hook.py index 507259d..38dc0e5 100644 --- a/src/ring/hook.py +++ b/src/ring/hook.py @@ -44,8 +44,9 @@ _HOOK_EVENTS = list(HOOK_EVENTS) # Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。 -# 保守取有實證可用的:PermissionRequest(→ 🔴 等核可)、PreToolUse(→ 動作/清除)、 -# Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。 +# 保守取有實證可用的:PermissionRequest(裸事件只代表權限準備判定;明確需互動才 → 🔴)、 +# PreToolUse(→ 動作/清除)、Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件 +# 有風險,故不照搬 Claude 全套。 _CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"] # hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會 diff --git a/src/ring/hook_protocol.py b/src/ring/hook_protocol.py index 7ba1eb6..01c05e5 100644 --- a/src/ring/hook_protocol.py +++ b/src/ring/hook_protocol.py @@ -105,6 +105,13 @@ def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None: status = Status.WORKING elif explicit_requires_action is not None: status = Status.WAITING if explicit_requires_action else Status.IDLE + elif event == "PermissionRequest" and self.provider == "codex": + # Codex 會在權限「準備判定」時送 PermissionRequest;即使既有 policy + # 隨後自動放行、畫面從未停下來等人,也會經過這個 hook。裸事件因此只能 + # 證明 agent 還在處理工具呼叫,不能當成使用者需要回應。若 payload 有 + # requires_action / waiting_for 等明確訊號,已由上面的分支判成 WAITING; + # 明確的 requires_action=false 也在上面判成 IDLE,不會落到這裡的 WORKING。 + status = Status.WORKING elif event == "Notification": status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE elif event == "PreToolUse": diff --git a/tests/test_hook.py b/tests/test_hook.py index b4f6ca3..6f7bf5a 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -348,6 +348,35 @@ def test_codex_provider_writes_qualified_session(monkeypatch: pytest.MonkeyPatch assert data["status"] == Status.IDLE.value +def test_codex_bare_permission_request_stays_working(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Codex 權限 policy 自動放行時也會送 hook;裸事件不代表真的停下來等人。""" + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed(monkeypatch, {"session_id": "thread-1", "event": "PermissionRequest", "cwd": "/repo"}) + + assert hook.run_hook(provider="codex") == 0 + + data = json.loads((tmp_path / "codex:thread-1.json").read_text()) + assert data["status"] == Status.WORKING.value + + +def test_codex_explicit_permission_request_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + { + "session_id": "thread-1", + "event": "PermissionRequest", + "cwd": "/repo", + "requires_action": True, + }, + ) + + assert hook.run_hook(provider="codex") == 0 + + data = json.loads((tmp_path / "codex:thread-1.json").read_text()) + assert data["status"] == Status.WAITING.value + + def test_payload_provider_overrides_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) _feed(monkeypatch, {"provider": "codex", "session_id": "thread-2", "event": "UserPromptSubmit", "cwd": "/repo"})