From a053f528d80619dff982a710091a9eaec7309e5a Mon Sep 17 00:00:00 2001 From: TwoMonkeys Date: Tue, 11 Aug 2026 13:32:15 +0800 Subject: [PATCH] fix: capture Codex plan review transcripts --- docs/demo/demo.md | 38 +- docs/demo/overview-and-quickstart.md | 25 + docs/demo/overview-and-quickstart.zh-CN.md | 25 + docs/install-guide.md | 25 +- docs/install-guide.zh-CN.md | 25 +- docs/integrations.md | 33 +- docs/integrations.zh-CN.md | 33 +- docs/regression-cases.md | 12 + .../.openspec.yaml | 2 + .../design.md | 44 ++ .../proposal.md | 28 ++ .../specs/agent-reply-capture/spec.md | 56 +++ .../tasks.md | 20 + openspec/config.yaml | 4 +- openspec/specs/agent-reply-capture/spec.md | 62 +++ src-tauri/src/cache.rs | 450 ++++++++++++++++-- src-tauri/src/cli.rs | 45 +- src-tauri/src/extract/codex.rs | 2 +- src-tauri/src/main.rs | 2 +- src-tauri/tests/anti_crosstalk.rs | 129 +++++ .../components/PersonalizationPanelTabs.tsx | 3 +- .../__tests__/PersonalizationPanel.test.tsx | 11 +- src/lib/locales.ts | 4 + 23 files changed, 1015 insertions(+), 63 deletions(-) create mode 100644 openspec/changes/archive/2026-08-11-capture-codex-plan-review/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-11-capture-codex-plan-review/design.md create mode 100644 openspec/changes/archive/2026-08-11-capture-codex-plan-review/proposal.md create mode 100644 openspec/changes/archive/2026-08-11-capture-codex-plan-review/specs/agent-reply-capture/spec.md create mode 100644 openspec/changes/archive/2026-08-11-capture-codex-plan-review/tasks.md create mode 100644 openspec/specs/agent-reply-capture/spec.md diff --git a/docs/demo/demo.md b/docs/demo/demo.md index 567bf42..3dbce12 100644 --- a/docs/demo/demo.md +++ b/docs/demo/demo.md @@ -93,7 +93,7 @@ graph TB CLIPBOARD["System Clipboard"] end - CODEX -->|"notify hook"| CLI + CODEX -->|"notify arg / Stop stdin"| CLI CLAUDE -->|"Stop hook (stdin)"| CLI GEMINI -->|"AfterAgent hook (stdin)"| CLI @@ -171,7 +171,7 @@ Each supported AI agent has a unique hook mechanism. cliV normalizes these into | Agent | Hook Type | Trigger | Data Source | Cache Key | |:---|:---|:---|:---|:---| -| Codex | `notify` | `agent-turn-complete` | CLI argument (JSON) | PID cache key + sidecar thread metadata | +| Codex | `notify` + `Stop` | completed turn / lifecycle stop | CLI argument + stdin (JSON) | PID cache key + sidecar thread metadata | | Claude Code | `Stop` | `Stop` event | stdin (JSON) | `session_id` + PID | | Gemini CLI | `AfterAgent` | After agent response | stdin (JSON) | `GEMINI_SESSION_ID` + PID | @@ -183,6 +183,29 @@ Each supported AI agent has a unique hook mechanism. cliV normalizes these into notify = ["cliv", "cache-codex"] ``` +Add a user-level `Stop` hook in `~/.codex/hooks.json` for Plan Review, then review it through `/hooks`: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +Codex's Plan decision dialog currently owns keyboard input. Choose No to return to the normal composer before pressing `Ctrl+G`; cliV then opens the plan captured by the Stop hook. + **Claude Code** — Add to `~/.claude/settings.json`: ```json @@ -233,7 +256,7 @@ cliV uses a hand-rolled CLI parser (no external crate dependencies) to keep the flowchart TD START["cliV invoked"] --> CHECK_SUB{"argv[1] is
subcommand?"} - CHECK_SUB -->|"cache-codex"| CODEX_CACHE["CacheCodex Mode
Parse JSON arg"] + CHECK_SUB -->|"cache-codex"| CODEX_CACHE["CacheCodex Mode
Parse argv or stdin JSON"] CHECK_SUB -->|"cache-claude"| CLAUDE_CACHE["CacheClaude Mode
Read stdin"] CHECK_SUB -->|"cache-gemini"| GEMINI_CACHE["CacheGemini Mode
Read stdin"] CHECK_SUB -->|"No"| GUI_MODE["GUI Mode"] @@ -261,8 +284,8 @@ flowchart TD pub enum CliMode { /// Launch the Tauri GUI (default). Gui, - /// Cache a Codex reply from notify hook: `cliv cache-codex ''` - CacheCodex(String), + /// Cache a Codex reply from notify argv or Stop-hook stdin. + CacheCodex(Option), /// Cache a Claude reply from Stop hook (stdin): `cliv cache-claude` CacheClaude, /// Cache a Gemini reply from AfterAgent hook (stdin): `cliv cache-gemini` @@ -1262,7 +1285,8 @@ graph TB cliv document.md # Cache an agent reply (called by hooks, not users) -cliv cache-codex '' +cliv cache-codex '' # notify payload from argv +cliv cache-codex # Stop-hook payload from stdin cliv cache-claude # reads from stdin cliv cache-gemini # reads from stdin @@ -1292,7 +1316,7 @@ mindmap serde_json dirs crate Integration - Codex notify hook + Codex notify + Stop hooks Claude Stop hook Gemini AfterAgent hook Storage diff --git a/docs/demo/overview-and-quickstart.md b/docs/demo/overview-and-quickstart.md index e092576..3b90b21 100644 --- a/docs/demo/overview-and-quickstart.md +++ b/docs/demo/overview-and-quickstart.md @@ -107,6 +107,31 @@ On macOS, if `cliv` is not in PATH, use: notify = ["/Applications/cliV.app/Contents/MacOS/cliv", "cache-codex"] ``` +Keep notify for compatibility, and create `~/.codex/hooks.json` to capture Plan Review content: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +Use `/hooks` in Codex to review and trust the command. If inline hooks already exist in `config.toml`, merge the Stop handler there instead of maintaining both hook formats. On macOS without a symlink, use the full cliV executable path in `command` too. + +Codex's Plan decision dialog currently captures keyboard input, so `Ctrl+G` does not launch `$EDITOR` inside that dialog. Choose No to return to the normal composer, then press `Ctrl+G`; cliV will load the plan captured by the Stop hook. + #### Claude Code Edit `~/.claude/settings.json` and merge this into your existing config: diff --git a/docs/demo/overview-and-quickstart.zh-CN.md b/docs/demo/overview-and-quickstart.zh-CN.md index 850d265..660f4a8 100644 --- a/docs/demo/overview-and-quickstart.zh-CN.md +++ b/docs/demo/overview-and-quickstart.zh-CN.md @@ -107,6 +107,31 @@ notify = ["cliv", "cache-codex"] notify = ["/Applications/cliV.app/Contents/MacOS/cliv", "cache-codex"] ``` +保留 notify 用于兼容,并创建 `~/.codex/hooks.json` 来捕获 Plan Review 内容: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +在 Codex 中使用 `/hooks` 审核并信任该命令。如果 `config.toml` 已经有 inline Hooks,请把 Stop handler 合并进去,不要同时维护两种 Hook 格式。macOS 未创建软链接时,`command` 也要使用 cliV 的完整可执行路径。 + +Codex 的 Plan 决策弹窗当前会接管键盘输入,因此在弹窗内按 `Ctrl+G` 不会启动 `$EDITOR`。选择 No 回到普通输入框后再按 `Ctrl+G`,cliV 会加载 Stop Hook 已捕获的计划。 + #### Claude Code 编辑 `~/.claude/settings.json`,把下面内容合并进现有配置: diff --git a/docs/install-guide.md b/docs/install-guide.md index b107f21..085f571 100644 --- a/docs/install-guide.md +++ b/docs/install-guide.md @@ -96,7 +96,7 @@ setx EDITOR cliv Then open a new terminal. -When an agent triggers `Ctrl+G`, it launches `$EDITOR`. That is how cliV opens from the CLI workflow. +When an agent triggers `Ctrl+G` from its normal composer, it launches `$EDITOR`. Codex's Plan decision dialog currently owns keyboard input, so return to the composer before using this shortcut. ## 3. Configure Agent Hooks @@ -111,6 +111,29 @@ Edit `~/.codex/config.toml`: notify = ["cliv", "cache-codex"] ``` +`notify` keeps compatibility with ordinary completed turns. To capture plans when Codex enters Plan Review, also create `~/.codex/hooks.json`: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +Open `/hooks` in Codex and trust this hook before testing it. Codex skips new or changed command hooks until they are reviewed. If you already define inline Codex hooks in `config.toml`, merge this `Stop` handler there instead of configuring both representations. On macOS without a symlink, replace the command with `/Applications/cliV.app/Contents/MacOS/cliv cache-codex`. + ### Claude Code Edit `~/.claude/settings.json`: diff --git a/docs/install-guide.zh-CN.md b/docs/install-guide.zh-CN.md index 6b1c4be..f961713 100644 --- a/docs/install-guide.zh-CN.md +++ b/docs/install-guide.zh-CN.md @@ -96,7 +96,7 @@ setx EDITOR cliv 然后重新打开一个新的终端。 -当 Agent 触发 `Ctrl+G` 时,它会启动 `$EDITOR`,这就是 cliV 被唤起的入口。 +在 Agent 的普通输入框中触发 `Ctrl+G` 时,它会启动 `$EDITOR`。Codex 的 Plan 决策弹窗当前会接管键盘输入,因此需要先回到输入框再使用该快捷键。 ## 3. 配置 Agent Hook @@ -111,6 +111,29 @@ setx EDITOR cliv notify = ["cliv", "cache-codex"] ``` +`notify` 继续兼容普通已完成回合。为了在 Codex 进入 Plan Review 时捕获计划,还要创建 `~/.codex/hooks.json`: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +测试前请在 Codex 中打开 `/hooks` 并信任该 Hook;新的或发生变化的 command Hook 在完成审核前会被跳过。如果你已经在 `config.toml` 中使用 inline Codex Hooks,请把这个 `Stop` handler 合并到现有配置,不要同时维护两种表示。macOS 未创建软链接时,把 command 改为 `/Applications/cliV.app/Contents/MacOS/cliv cache-codex`。 + ### Claude Code 编辑 `~/.claude/settings.json`: diff --git a/docs/integrations.md b/docs/integrations.md index 6412125..0f5eba8 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -87,6 +87,31 @@ notify = ["/Applications/cliV.app/Contents/MacOS/cliv", "cache-codex"] Codex passes JSON as a command-line argument to `cliv cache-codex`. +Keep that notify entry for compatibility, then add `~/.codex/hooks.json` so Plan Review replies are delivered through stdin: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +Current Codex Plan Review builds may leave both assistant-message fields null. In that case cliV reads only the same-session, same-turn `item_completed/Plan` entry from the `transcript_path` supplied by the Stop hook; no additional user command is required. + +Review and trust the command through `/hooks` in Codex. If the same config layer already uses inline hooks, merge this handler there rather than keeping both inline hooks and `hooks.json`. On macOS without a symlink, use `/Applications/cliV.app/Contents/MacOS/cliv cache-codex` as the command. + ### Claude Code `~/.claude/settings.json`: @@ -210,6 +235,9 @@ Codex: ```bash CODEX_THREAD_ID=424242 cliv cache-codex '{"type":"agent-turn-complete","thread-id":"test-123","last-assistant-message":"# Hello\nTest reply."}' cat ~/.codex/reply_cache/424242.md + +printf '%s' '{"hook_event_name":"Stop","session_id":"test-123","turn_id":"plan-1","permission_mode":"plan","last_assistant_message":"\n# Plan\n\n- Review this\n"}' | CODEX_THREAD_ID=424242 cliv cache-codex +cat ~/.codex/reply_cache/424242.md ``` Claude Code: @@ -230,6 +258,8 @@ If these commands write the expected `.md` files, cliV itself is working and the - the wrong hook file path - malformed hook JSON or TOML +- a Codex Stop hook that has not been trusted through `/hooks` +- Codex hooks disabled through the `features.hooks` setting - a shell quoting issue - a stale terminal session that has not reloaded `PATH` or `EDITOR` @@ -241,4 +271,5 @@ Check these in order: 2. Did the hook actually write a cache file under `~/.codex`, `~/.claude`, or `~/.gemini`? 3. Is the lookup key the one cliV is expecting for that launch context? 4. Did a non-default path or shell quoting issue stop the hook from running? -5. On Windows, did you reopen the terminal after install or after changing `EDITOR`? +5. For Codex Plan Review, is the Stop hook listed as trusted under `/hooks`? +6. On Windows, did you reopen the terminal after install or after changing `EDITOR`? diff --git a/docs/integrations.zh-CN.md b/docs/integrations.zh-CN.md index eff3105..b3fdbf4 100644 --- a/docs/integrations.zh-CN.md +++ b/docs/integrations.zh-CN.md @@ -87,6 +87,31 @@ notify = ["/Applications/cliV.app/Contents/MacOS/cliv", "cache-codex"] Codex 会把 JSON 作为命令行参数传给 `cliv cache-codex`。 +保留该 notify 配置用于兼容,再添加 `~/.codex/hooks.json`,让 Plan Review 回复通过 stdin 传入: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cliv cache-codex", + "timeout": 5, + "statusMessage": "Caching reply for cliV" + } + ] + } + ] + } +} +``` + +当前 Codex 的 Plan Review 可能让两个 assistant-message 字段都为 `null`。此时 cliV 只读取 Stop Hook 提供的 `transcript_path` 中同 session、同 turn 的 `item_completed/Plan` 条目;用户不需要执行额外命令。 + +请在 Codex 中通过 `/hooks` 审核并信任该命令。如果同一个配置层已经使用 inline Hooks,请把这个 handler 合并进去,不要同时保留 inline Hooks 和 `hooks.json`。macOS 未创建软链接时,command 使用 `/Applications/cliV.app/Contents/MacOS/cliv cache-codex`。 + ### Claude Code `~/.claude/settings.json`: @@ -210,6 +235,9 @@ Codex: ```bash CODEX_THREAD_ID=424242 cliv cache-codex '{"type":"agent-turn-complete","thread-id":"test-123","last-assistant-message":"# Hello\nTest reply."}' cat ~/.codex/reply_cache/424242.md + +printf '%s' '{"hook_event_name":"Stop","session_id":"test-123","turn_id":"plan-1","permission_mode":"plan","last_assistant_message":"\n# Plan\n\n- Review this\n"}' | CODEX_THREAD_ID=424242 cliv cache-codex +cat ~/.codex/reply_cache/424242.md ``` Claude Code: @@ -230,6 +258,8 @@ cat ~/.gemini/reply_cache/test-gemini.md - hook 文件路径写错了 - JSON / TOML 语法有误 +- Codex Stop Hook 尚未通过 `/hooks` 信任 +- Codex 的 `features.hooks` 设置禁用了 Hooks - shell 引号转义不对 - 终端仍然是旧会话,没有重新加载 `PATH` 或 `EDITOR` @@ -241,4 +271,5 @@ cat ~/.gemini/reply_cache/test-gemini.md 2. 对应 `~/.codex`、`~/.claude`、`~/.gemini` 下是否真的写出了 cache 文件? 3. 当前启动上下文里,cliV 查找的 lookup key 是否和你预期一致? 4. 是否因为非默认路径或 shell 引号问题,导致 hook 根本没执行? -5. Windows 上是否在安装或修改 `EDITOR` 之后重新打开了终端? +5. Codex Plan Review 场景下,`/hooks` 是否显示 Stop Hook 已受信任? +6. Windows 上是否在安装或修改 `EDITOR` 之后重新打开了终端? diff --git a/docs/regression-cases.md b/docs/regression-cases.md index d8ec98b..e01e8ca 100644 --- a/docs/regression-cases.md +++ b/docs/regression-cases.md @@ -115,6 +115,18 @@ --- +## Agent Reply Capture + +### ARC-001 — Codex Plan Review 应显示当前计划而不是空白或上一轮回复 +- **Area:** agent-integration +- **Scenario:** Codex 同一会话先完成普通回复,随后在 Plan 模式产生 `last_assistant_message: null`,计划仅存在于同 session、同 turn 的 transcript `item_completed/Plan` 事件中;在 Plan 决策弹窗选择 No 回到普通输入框后,再通过 `$EDITOR` 调起 cliV +- **Expected:** cliV 显示当前计划的 Markdown 内容,外层 transport tags 不可见;提交批注后内容写回 Codex 提供的临时 target +- **Coverage:** manual +- **Manual verification:** 在 `~/.codex/config.toml` 保留 notify,并在 `~/.codex/hooks.json` 配置、通过 `/hooks` 信任 `Stop -> cliv cache-codex`;重开 Codex,先完成一个普通回合,再进入 Plan 模式生成计划;在 Plan 决策弹窗选择 No 回到普通输入框,按 `Ctrl+G` 调起 cliV;确认显示的是当前计划,完成一条批注并验证 Codex 收到写回内容 +- **Reason:** null-message transcript fallback、双 transport、PID 覆盖和 extractor 回读已有 Rust 自动化;真实 Codex TUI 的 Plan Review、Hook trust 与外部 `$EDITOR` 交接需要安装态 Codex 进行跨进程人工验证 + +--- + ## Worktree Tooling ### WT-001 — shared-cache helper 不得让默认 Rust 安装失效 diff --git a/openspec/changes/archive/2026-08-11-capture-codex-plan-review/.openspec.yaml b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/.openspec.yaml new file mode 100644 index 0000000..a8821c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-11-capture-codex-plan-review/design.md b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/design.md new file mode 100644 index 0000000..b690851 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/design.md @@ -0,0 +1,44 @@ +## Context + +cliV currently captures Codex replies only from the `notify` command configured in `~/.codex/config.toml`. Codex Plan Review can produce both notify and Stop events with null assistant-message fields. The completed plan is persisted in the session transcript as an `item_completed` event whose item type is `Plan`, keyed by the same session and turn ids. cliV already uses PID-keyed cache files plus metadata to keep concurrent agent sessions isolated. + +## Goals / Non-Goals + +**Goals:** + +- Capture the current Codex plan before cliV is opened as the Plan Review editor. +- Preserve the existing notify command and cache lookup contract. +- Keep cache writes atomic and scoped to the originating Codex process. +- Prevent hook payload content from being copied into cliV logs. +- Give users accurate setup and trust instructions without taking ownership of Codex configuration. + +**Non-Goals:** + +- Running or attaching to Codex App Server. +- Automatically editing `~/.codex/config.toml` or `~/.codex/hooks.json`. +- Changing annotation or write-back behavior after content reaches the document store. + +## Decisions + +1. **Use one backward-compatible command with two transports.** `cliv cache-codex ` continues to parse notify input. `cliv cache-codex` reads a `Stop` payload from stdin. A new subcommand would duplicate cache behavior and make installation guidance harder to maintain. +2. **Normalize event schemas before writing.** Accepted notify and Stop payloads become one internal record containing session id, message, turn id, permission mode, and source. When a Stop message is empty, inspect only its declared transcript path and accept only an exact same-session, same-turn `item_completed/Plan` item. Unknown events or missing required content are logged without writing cache data. +3. **Accept every Stop response.** Plan mode is identified for diagnostics by `permission_mode: "plan"`, but Stop capture is not filtered to that mode. This makes the lifecycle hook a resilient primary source while notify remains compatible; duplicate successful events are harmless because they contain the same current assistant reply and use atomic replacement. +4. **Remove only an exact outer plan envelope.** When the complete trimmed response is enclosed by `` and ``, cache the enclosed Markdown. Embedded tags, code examples, and ordinary replies remain unchanged. +5. **Retain PID/session isolation with token-aware process matching.** Both sources use the current Codex ancestor identity and the existing metadata resolution. Agent discovery matches exact or `agent-*` path components within process names and command-line tokens, not arbitrary substrings, so Node package paths remain detectable while a workspace containing `codex` cannot steal cache ownership. A later accepted response from the same Codex process replaces the prior reply, so Plan Review cannot intentionally fall back to an earlier turn. +6. **Redact before logging argv.** Cache subcommand diagnostics record command shape and payload length, never the JSON argument itself. stdin continues to be represented only by length and parsed field metadata. +7. **Document a user-level Stop hook.** The canonical configuration keeps notify in `config.toml` and adds `Stop -> cliv cache-codex` in `hooks.json`, followed by review through `/hooks`. Users with inline hooks merge the handler there instead of configuring both hook representations. + +## Risks / Trade-offs + +- **Hook unavailable, disabled, or untrusted** → notify remains supported; documentation and the Integrations UI explain hook trust and troubleshooting. +- **Notify and Stop race** → both normalize to atomic writes for the same PID and normally carry identical content; null notify payloads do not replace valid Stop content. +- **Codex changes hook or transcript fields** → reject unknown or incomplete payloads without scanning unrelated sessions; strict session/turn/item matching and regression fixtures prevent cross-talk. +- **Plan envelope rules change** → exact-envelope stripping fails safe by preserving non-matching content. + +## Migration Plan + +Ship the dual-input command without changing existing installations. Users who need Plan Review capture add and trust the Stop hook; rollback consists of removing that hook while notify continues to work. No cache migration or dependency change is required. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-08-11-capture-codex-plan-review/proposal.md b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/proposal.md new file mode 100644 index 0000000..b0494bb --- /dev/null +++ b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/proposal.md @@ -0,0 +1,28 @@ +## Why + +Codex Plan Review can emit an `agent-turn-complete` notification whose `last-assistant-message` is null, so cliV's notify-only capture path has no plan content to display when Codex opens cliV as `$EDITOR`. Plan review is a high-value review point and must reliably show the current plan rather than an empty or previous reply. + +## What Changes + +- Extend `cliv cache-codex` to accept both the existing notify JSON argument and Codex `Stop` hook JSON on stdin. +- Normalize both event shapes into the existing PID-isolated reply cache and render an outer `` envelope as normal Markdown content. +- Keep the existing notify integration for compatibility while documenting the `Stop` hook as the Plan Review capture path. +- Redact cache payloads from cliV command-line logging. +- Update the Integrations UI, bilingual integration/install/demo documentation, and regression coverage. + +## Capabilities + +### New Capabilities + +- `agent-reply-capture`: Stable capture behavior for agent replies, including Codex notify and lifecycle-hook payloads used by Plan Review. + +### Modified Capabilities + +None. + +## Impact + +- Tauri/Rust CLI parsing, Codex cache ingestion, PID/session cache metadata flow, and diagnostic logging. +- Frontend Integrations guidance and its localized tests. +- Codex user configuration under `~/.codex/config.toml` and `~/.codex/hooks.json` remains user-owned and is documented rather than rewritten by cliV. +- Public documentation and manual regression guidance for Linux, macOS, and Windows. diff --git a/openspec/changes/archive/2026-08-11-capture-codex-plan-review/specs/agent-reply-capture/spec.md b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/specs/agent-reply-capture/spec.md new file mode 100644 index 0000000..931d318 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/specs/agent-reply-capture/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Codex reply capture accepts notify and Stop hook input +cliV SHALL preserve the `cliv cache-codex ` notify interface and SHALL also accept a Codex `Stop` lifecycle-hook payload from stdin when `cliv cache-codex` is invoked without a JSON argument. + +#### Scenario: Existing notify integration captures a reply +- **WHEN** `cliv cache-codex` receives an `agent-turn-complete` JSON argument with a non-empty thread id and assistant message +- **THEN** cliV stores that reply under the originating Codex process identity using the existing cache metadata contract + +#### Scenario: Plan mode Stop hook captures a plan +- **WHEN** `cliv cache-codex` receives a Plan-mode `Stop` payload whose assistant-message field is empty but whose transcript contains an `item_completed` Plan for the same session id and turn id +- **THEN** cliV stores that exact Plan item as the current reply for the originating Codex process + +#### Scenario: Stop hook captures a directly supplied plan +- **WHEN** a Plan-mode `Stop` payload contains a non-empty last assistant message +- **THEN** cliV stores that message without reading the transcript + +#### Scenario: Ordinary Stop hook captures a reply +- **WHEN** a valid `Stop` hook payload has a permission mode other than `plan` +- **THEN** cliV stores its last assistant message through the same cache path + +#### Scenario: Invalid or empty input does not replace content +- **WHEN** the cache command receives malformed JSON, an unsupported event, or neither a non-empty assistant message nor an exact same-session same-turn Plan transcript item +- **THEN** cliV does not replace the current reply cache with that input + +### Requirement: Current Codex reply replaces an earlier turn +For accepted Codex events, cliV SHALL atomically replace the PID-keyed reply for the originating Codex process while retaining session metadata used for alias resolution. + +#### Scenario: Plan follows an ordinary reply in one Codex process +- **WHEN** cliV captures an ordinary reply and then a valid Plan-mode Stop reply from the same Codex process +- **THEN** subsequent reply extraction returns the plan rather than the earlier reply + +### Requirement: Plan transport envelope is reviewable Markdown +cliV SHALL remove an exact outer `...` transport envelope before displaying the enclosed plan, without rewriting ordinary Markdown or embedded examples. + +#### Scenario: Wrapped plan is captured +- **WHEN** a Stop message consists of a proposed-plan opening tag, Markdown plan content, and the corresponding closing tag +- **THEN** cliV stores the enclosed Markdown without the outer transport tags + +#### Scenario: Non-envelope content is preserved +- **WHEN** a reply does not consist entirely of the proposed-plan envelope +- **THEN** cliV preserves the reply content unchanged + +### Requirement: Cache diagnostics do not expose reply payloads +cliV MUST NOT write complete Codex notify or Stop payloads, user prompts, or assistant messages to its diagnostic log while handling cache commands. + +#### Scenario: Notify JSON is supplied on the command line +- **WHEN** cliV logs invocation details for `cliv cache-codex ` +- **THEN** the log contains only redacted command information and payload length, not the JSON content + +### Requirement: Codex hook configuration remains user-owned +cliV SHALL document the notify and Stop-hook configuration required for complete Codex reply capture and SHALL NOT automatically rewrite Codex configuration files. + +#### Scenario: User configures Plan Review capture +- **WHEN** a user follows cliV's Codex integration instructions +- **THEN** the instructions retain notify compatibility, add a user-level Stop hook, and require the hook to be reviewed through Codex's hook trust flow diff --git a/openspec/changes/archive/2026-08-11-capture-codex-plan-review/tasks.md b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/tasks.md new file mode 100644 index 0000000..f1274f2 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-capture-codex-plan-review/tasks.md @@ -0,0 +1,20 @@ +## 1. Codex capture implementation + +- [x] 1.1 Extend `cliv cache-codex` to preserve notify arguments, read Stop-hook JSON from stdin, recover a null-message Plan from the exact same session/turn transcript item, and strip only an exact outer proposed-plan envelope. +- [x] 1.2 Reuse PID/session cache isolation for both sources and redact cache payloads from CLI invocation logs. + +## 2. Automated regression protection + +- [x] 2.1 Add Rust unit coverage for notify, Stop, real null-message Plan transcript fallback, invalid/empty payloads, exact turn isolation, envelope normalization, and argv redaction. +- [x] 2.2 Add a binary integration regression that simulates a Codex ancestor, writes an ordinary reply followed by the real null-message Plan event shape, and proves current-plan extraction from the same PID. + +## 3. User-facing integration guidance + +- [x] 3.1 Update localized Integrations settings copy and frontend tests to show the Codex `config.toml` plus `hooks.json` boundary and hook-trust requirement. +- [x] 3.2 Synchronize English and Chinese install, integration, and demo documentation for dual notify/Stop configuration, platform paths, verification, and troubleshooting. +- [x] 3.3 Add a named Codex Plan Review manual regression case with `Manual verification:` and the reason the real Codex UI boundary is not fully automated. + +## 4. Validation and closeout + +- [x] 4.1 Run `cargo test --manifest-path src-tauri/Cargo.toml`. +- [x] 4.2 Run the targeted Integrations frontend test, `pnpm typecheck`, and `pnpm test:docs`; record any environment-limited validation. diff --git a/openspec/config.yaml b/openspec/config.yaml index 729bd97..818e823 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -38,6 +38,6 @@ rules: - 变更涉及用户可见流程、agent integration 或持久化行为时,必须包含验证工作。 - 文档或公开使用方式变化时,明确是否同步对应中英文文档。 - bug fix 在可行时补 automated regression;若暂时无法自动化,更新 `docs/regression-cases.md`。 - - `Coverage: manual` 的回归 case 需要 `Manual verification:`;若当前还不适合自动化,再补 `Reason:`。 - - `Coverage: pending` 的回归 case 需要 `Reason:`。 + - "`Coverage: manual` 的回归 case 需要 `Manual verification:`;若当前还不适合自动化,再补 `Reason:`。" + - "`Coverage: pending` 的回归 case 需要 `Reason:`。" - 变更影响测试标准、回归 case 或 CI 覆盖声明时,纳入 `pnpm test:docs` 验证。 diff --git a/openspec/specs/agent-reply-capture/spec.md b/openspec/specs/agent-reply-capture/spec.md new file mode 100644 index 0000000..f6acf7b --- /dev/null +++ b/openspec/specs/agent-reply-capture/spec.md @@ -0,0 +1,62 @@ +# agent-reply-capture Specification + +## Purpose + +Define how cliV captures current agent replies across supported integration transports while preserving session isolation, reviewable content, safe diagnostics, and external configuration ownership. + +## Requirements + +### Requirement: Codex reply capture accepts notify and Stop hook input +cliV SHALL preserve the `cliv cache-codex ` notify interface and SHALL also accept a Codex `Stop` lifecycle-hook payload from stdin when `cliv cache-codex` is invoked without a JSON argument. + +#### Scenario: Existing notify integration captures a reply +- **WHEN** `cliv cache-codex` receives an `agent-turn-complete` JSON argument with a non-empty thread id and assistant message +- **THEN** cliV stores that reply under the originating Codex process identity using the existing cache metadata contract + +#### Scenario: Plan mode Stop hook captures a plan +- **WHEN** `cliv cache-codex` receives a Plan-mode `Stop` payload whose assistant-message field is empty but whose transcript contains an `item_completed` Plan for the same session id and turn id +- **THEN** cliV stores that exact Plan item as the current reply for the originating Codex process + +#### Scenario: Stop hook captures a directly supplied plan +- **WHEN** a Plan-mode `Stop` payload contains a non-empty last assistant message +- **THEN** cliV stores that message without reading the transcript + +#### Scenario: Ordinary Stop hook captures a reply +- **WHEN** a valid `Stop` hook payload has a permission mode other than `plan` +- **THEN** cliV stores its last assistant message through the same cache path + +#### Scenario: Invalid or empty input does not replace content +- **WHEN** the cache command receives malformed JSON, an unsupported event, or neither a non-empty assistant message nor an exact same-session same-turn Plan transcript item +- **THEN** cliV does not replace the current reply cache with that input + +### Requirement: Current Codex reply replaces an earlier turn +For accepted Codex events, cliV SHALL atomically replace the PID-keyed reply for the originating Codex process while retaining session metadata used for alias resolution. + +#### Scenario: Plan follows an ordinary reply in one Codex process +- **WHEN** cliV captures an ordinary reply and then a valid Plan-mode Stop reply from the same Codex process +- **THEN** subsequent reply extraction returns the plan rather than the earlier reply + +### Requirement: Plan transport envelope is reviewable Markdown +cliV SHALL remove an exact outer `...` transport envelope before displaying the enclosed plan, without rewriting ordinary Markdown or embedded examples. + +#### Scenario: Wrapped plan is captured +- **WHEN** a Stop message consists of a proposed-plan opening tag, Markdown plan content, and the corresponding closing tag +- **THEN** cliV stores the enclosed Markdown without the outer transport tags + +#### Scenario: Non-envelope content is preserved +- **WHEN** a reply does not consist entirely of the proposed-plan envelope +- **THEN** cliV preserves the reply content unchanged + +### Requirement: Cache diagnostics do not expose reply payloads +cliV MUST NOT write complete Codex notify or Stop payloads, user prompts, or assistant messages to its diagnostic log while handling cache commands. + +#### Scenario: Notify JSON is supplied on the command line +- **WHEN** cliV logs invocation details for `cliv cache-codex ` +- **THEN** the log contains only redacted command information and payload length, not the JSON content + +### Requirement: Codex hook configuration remains user-owned +cliV SHALL document the notify and Stop-hook configuration required for complete Codex reply capture and SHALL NOT automatically rewrite Codex configuration files. + +#### Scenario: User configures Plan Review capture +- **WHEN** a user follows cliV's Codex integration instructions +- **THEN** the instructions retain notify compatibility, add a user-level Stop hook, and require the hook to be reviewed through Codex's hook trust flow diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs index 5cf4b83..9335fb8 100644 --- a/src-tauri/src/cache.rs +++ b/src-tauri/src/cache.rs @@ -4,8 +4,8 @@ use crate::process::{collect_parent_processes, resolve_owner_identity, OwnerIden use serde::Serialize; use serde_json::Value; use std::fs; -use std::io::Read; -use std::path::PathBuf; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; fn home_dir() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) @@ -104,6 +104,14 @@ fn read_stdin() -> Option { } } +fn agent_process_name_matches(candidate: &str, agent_name: &str) -> bool { + Path::new(candidate).components().any(|component| { + let value = component.as_os_str().to_string_lossy().to_lowercase(); + let value = value.strip_suffix(".exe").unwrap_or(&value); + value == agent_name || value.starts_with(&format!("{agent_name}-")) + }) +} + /// Walk PPID chain to find the ancestor agent process PID. #[cfg(target_os = "linux")] fn find_ancestor_agent_pid(agent_name: &str) -> Option { @@ -125,7 +133,7 @@ fn find_ancestor_agent_pid(agent_name: &str) -> Option { level, pid, comm )); - if comm.contains(agent_name) { + if agent_process_name_matches(&comm, agent_name) { logging::debug(&format!( " ancestor: candidate {} match at pid={}", agent_name, pid @@ -135,15 +143,13 @@ fn find_ancestor_agent_pid(agent_name: &str) -> Option { // Fallback: when comm is a generic interpreter (e.g. "node"), // read /proc/PID/cmdline for the full invocation path. - if !comm.contains(agent_name) { + if !agent_process_name_matches(&comm, agent_name) { if let Ok(raw) = std::fs::read(format!("/proc/{}/cmdline", pid)) { - let cmdline = raw + let cmdline_matches = raw .split(|&b| b == 0) - .map(|seg| String::from_utf8_lossy(seg)) - .collect::>() - .join(" ") - .to_lowercase(); - if cmdline.contains(agent_name) { + .map(String::from_utf8_lossy) + .any(|token| agent_process_name_matches(&token, agent_name)); + if cmdline_matches { logging::debug(&format!( " ancestor: candidate {} cmdline match at pid={}", agent_name, pid @@ -213,7 +219,7 @@ fn find_ancestor_agent_pid(agent_name: &str) -> Option { level, pid, comm )); - if comm.contains(agent_name) { + if agent_process_name_matches(&comm, agent_name) { logging::debug(&format!( " ancestor: candidate {} match at pid={}", agent_name, pid @@ -313,44 +319,209 @@ fn resolve_agent_pid( // ─── Codex ──────────────────────────────────────────────── -pub fn cache_codex(json_arg: &str, launch: &LaunchConfig) { - logging::log(&format!("cache-codex: json_len={}", json_arg.len())); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CodexPayloadSource { + Notify, + Stop, +} - let data: Value = match serde_json::from_str(json_arg) { - Ok(v) => v, - Err(e) => { - logging::log(&format!("cache-codex: JSON parse error: {}", e)); - return; +impl CodexPayloadSource { + fn as_str(self) -> &'static str { + match self { + Self::Notify => "notify", + Self::Stop => "stop", } - }; + } +} - let event_type = data.get("type").and_then(|v| v.as_str()).unwrap_or(""); - logging::debug(&format!("cache-codex: type={}", event_type)); - if event_type != "agent-turn-complete" { - logging::debug("cache-codex: not agent-turn-complete, skip"); - return; +#[derive(Debug, PartialEq, Eq)] +struct CodexCachePayload { + source: CodexPayloadSource, + session_id: String, + turn_id: Option, + permission_mode: Option, + message: String, +} + +fn non_empty_string(data: &Value, key: &str) -> Option { + data.get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) +} + +fn normalize_codex_message(message: &str) -> Option { + if message.trim().is_empty() { + return None; } - let thread_id = match data.get("thread-id").and_then(|v| v.as_str()) { - Some(id) if !id.is_empty() => id, - _ => { - logging::log("cache-codex: no thread-id"); - return; + let trimmed = message.trim(); + if let Some(inner) = trimmed + .strip_prefix("") + .and_then(|value| value.strip_suffix("")) + { + let plan = inner.trim_matches(['\r', '\n']); + return (!plan.trim().is_empty()).then(|| plan.to_string()); + } + + Some(message.to_string()) +} + +fn read_codex_plan_from_transcript( + transcript_path: &Path, + session_id: &str, + turn_id: &str, +) -> Result, String> { + let transcript = fs::File::open(transcript_path) + .map_err(|error| format!("plan transcript open failed: {error}"))?; + let mut matched_plan = None; + + for line in BufReader::new(transcript).lines() { + let line = line.map_err(|error| format!("plan transcript read failed: {error}"))?; + let event: Value = match serde_json::from_str(&line) { + Ok(event) => event, + Err(_) => continue, + }; + let payload = match event.get("payload") { + Some(payload) => payload, + None => continue, + }; + let item = match payload.get("item") { + Some(item) => item, + None => continue, + }; + + let is_current_plan = event.get("type").and_then(Value::as_str) == Some("event_msg") + && payload.get("type").and_then(Value::as_str) == Some("item_completed") + && payload.get("thread_id").and_then(Value::as_str) == Some(session_id) + && payload.get("turn_id").and_then(Value::as_str) == Some(turn_id) + && item.get("type").and_then(Value::as_str) == Some("Plan"); + + if is_current_plan { + matched_plan = item + .get("text") + .and_then(Value::as_str) + .and_then(normalize_codex_message); + } + } + + Ok(matched_plan) +} + +fn parse_codex_payload(input: &str) -> Result, String> { + let data: Value = + serde_json::from_str(input).map_err(|error| format!("JSON parse error: {error}"))?; + + let (source, session_key, message_key, turn_key) = + if data.get("type").and_then(Value::as_str) == Some("agent-turn-complete") { + ( + CodexPayloadSource::Notify, + "thread-id", + "last-assistant-message", + "turn-id", + ) + } else if data.get("hook_event_name").and_then(Value::as_str) == Some("Stop") { + ( + CodexPayloadSource::Stop, + "session_id", + "last_assistant_message", + "turn_id", + ) + } else { + return Ok(None); + }; + + let session_id = non_empty_string(&data, session_key) + .ok_or_else(|| format!("{} payload has no {session_key}", source.as_str()))?; + let turn_id = non_empty_string(&data, turn_key); + let direct_message = data + .get(message_key) + .and_then(Value::as_str) + .and_then(normalize_codex_message); + let message = match direct_message { + Some(message) => message, + None if source == CodexPayloadSource::Stop => { + let transcript_path = non_empty_string(&data, "transcript_path").ok_or_else(|| { + format!( + "{} payload has no {message_key} or transcript_path", + source.as_str() + ) + })?; + let current_turn_id = turn_id.as_deref().ok_or_else(|| { + format!( + "{} payload has no {message_key} or {turn_key}", + source.as_str() + ) + })?; + read_codex_plan_from_transcript( + Path::new(&transcript_path), + &session_id, + current_turn_id, + )? + .ok_or_else(|| { + format!( + "{} transcript has no Plan for the current session and turn", + source.as_str() + ) + })? + } + None => { + return Err(format!( + "{} payload has no non-empty {message_key}", + source.as_str() + )); } }; - let message = match data.get("last-assistant-message").and_then(|v| v.as_str()) { - Some(m) if !m.is_empty() => m, - _ => { - logging::log("cache-codex: no last-assistant-message"); + Ok(Some(CodexCachePayload { + source, + session_id, + turn_id, + permission_mode: non_empty_string(&data, "permission_mode"), + message, + })) +} + +pub fn cache_codex(json_arg: Option<&str>, launch: &LaunchConfig) { + let input = match json_arg.filter(|value| !value.is_empty()) { + Some(json) => { + logging::debug(&format!("cache-codex: argv_len={}", json.len())); + json.to_string() + } + None => { + logging::debug("cache-codex: reading stdin..."); + match read_stdin() { + Some(input) => { + logging::debug(&format!("cache-codex: stdin_len={}", input.len())); + input + } + None => { + logging::log("cache-codex: empty stdin"); + return; + } + } + } + }; + + let payload = match parse_codex_payload(&input) { + Ok(Some(payload)) => payload, + Ok(None) => { + logging::debug("cache-codex: unsupported event, skip"); + return; + } + Err(error) => { + logging::log(&format!("cache-codex: {error}")); return; } }; logging::log(&format!( - "cache-codex: thread_id={} msg_len={}", - thread_id, - message.len() + "cache-codex: source={} session_id={} turn_id={:?} permission_mode={:?} msg_len={}", + payload.source.as_str(), + payload.session_id, + payload.turn_id, + payload.permission_mode, + payload.message.len() )); let codex_home = std::env::var("CODEX_HOME") @@ -369,29 +540,228 @@ pub fn cache_codex(json_arg: &str, launch: &LaunchConfig) { let (owner_pid, owner_started_at) = owner_fields(&owner_identity); let cache_path = cache_dir.join(format!("{}.md", codex_pid)); - atomic_write_cache(&cache_path, message); + atomic_write_cache(&cache_path, &payload.message); write_cache_meta( &cache_path, &CacheMeta { source: "pid".to_string(), key: codex_pid.to_string(), agent: "codex".to_string(), - real_session_id: Some(thread_id.to_string()), + real_session_id: Some(payload.session_id.clone()), pid: Some(codex_pid), owner_pid, owner_started_at, cached_at: now_cache_timestamp(), - size_bytes: message.len(), + size_bytes: payload.message.len(), }, ); // Clean up legacy thread-id keyed artifacts now that Codex caches are pid-keyed. - let legacy_path = cache_dir.join(format!("{}.md", thread_id)); + let legacy_path = cache_dir.join(format!("{}.md", payload.session_id)); if legacy_path != cache_path { remove_cache_artifacts(&legacy_path); } } +#[cfg(test)] +mod codex_tests { + use super::{agent_process_name_matches, parse_codex_payload, CodexPayloadSource}; + use std::fs; + + #[test] + fn parses_legacy_notify_payload() { + let payload = parse_codex_payload( + r##"{"type":"agent-turn-complete","thread-id":"thread-1","turn-id":"turn-1","last-assistant-message":"# Reply"}"##, + ) + .unwrap() + .unwrap(); + + assert_eq!(payload.source, CodexPayloadSource::Notify); + assert_eq!(payload.session_id, "thread-1"); + assert_eq!(payload.turn_id.as_deref(), Some("turn-1")); + assert_eq!(payload.permission_mode, None); + assert_eq!(payload.message, "# Reply"); + } + + #[test] + fn parses_plan_mode_stop_payload_and_removes_outer_envelope() { + let payload = parse_codex_payload( + r#"{"hook_event_name":"Stop","session_id":"thread-2","turn_id":"turn-2","permission_mode":"plan","last_assistant_message":"\n# Plan\n\n- Step\n"}"#, + ) + .unwrap() + .unwrap(); + + assert_eq!(payload.source, CodexPayloadSource::Stop); + assert_eq!(payload.session_id, "thread-2"); + assert_eq!(payload.turn_id.as_deref(), Some("turn-2")); + assert_eq!(payload.permission_mode.as_deref(), Some("plan")); + assert_eq!(payload.message, "# Plan\n\n- Step"); + } + + #[test] + fn recovers_plan_from_same_session_and_turn_when_stop_message_is_null() { + let temp = tempfile::tempdir().unwrap(); + let transcript_path = temp.path().join("rollout.jsonl"); + let transcript = [ + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "item_completed", + "thread_id": "thread-2", + "turn_id": "older-turn", + "item": {"type": "Plan", "text": "# Older plan"} + } + }), + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "item_completed", + "thread_id": "thread-2", + "turn_id": "plan-turn", + "item": {"type": "Plan", "text": "# Current plan\n\n- Review this"} + } + }), + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "plan-turn", + "last_agent_message": null + } + }), + ] + .into_iter() + .map(|event| event.to_string()) + .collect::>() + .join("\n"); + fs::write(&transcript_path, transcript).unwrap(); + + let input = serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "thread-2", + "turn_id": "plan-turn", + "permission_mode": "plan", + "transcript_path": transcript_path, + "last_assistant_message": null + }) + .to_string(); + let payload = parse_codex_payload(&input).unwrap().unwrap(); + + assert_eq!(payload.source, CodexPayloadSource::Stop); + assert_eq!(payload.message, "# Current plan\n\n- Review this"); + } + + #[test] + fn transcript_fallback_rejects_plan_from_another_turn() { + let temp = tempfile::tempdir().unwrap(); + let transcript_path = temp.path().join("rollout.jsonl"); + fs::write( + &transcript_path, + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "item_completed", + "thread_id": "thread-2", + "turn_id": "other-turn", + "item": {"type": "Plan", "text": "# Unrelated plan"} + } + }) + .to_string(), + ) + .unwrap(); + let input = serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "thread-2", + "turn_id": "plan-turn", + "permission_mode": "plan", + "transcript_path": transcript_path, + "last_assistant_message": null + }) + .to_string(); + + assert!(parse_codex_payload(&input).is_err()); + } + + #[test] + fn accepts_non_plan_stop_payload_without_rewriting_message() { + let message = " embedded trailing "; + let input = serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "thread-3", + "permission_mode": "default", + "last_assistant_message": message + }) + .to_string(); + + let payload = parse_codex_payload(&input).unwrap().unwrap(); + + assert_eq!(payload.source, CodexPayloadSource::Stop); + assert_eq!(payload.message, message); + } + + #[test] + fn skips_unsupported_events() { + let payload = parse_codex_payload(r#"{"hook_event_name":"SessionStart"}"#).unwrap(); + assert_eq!(payload, None); + } + + #[test] + fn rejects_invalid_or_empty_payloads() { + assert!(parse_codex_payload("not-json").is_err()); + assert!(parse_codex_payload( + r#"{"hook_event_name":"Stop","session_id":"thread-4","last_assistant_message":null}"# + ) + .is_err()); + assert!(parse_codex_payload( + r#"{"hook_event_name":"Stop","session_id":"thread-4","last_assistant_message":" "}"# + ) + .is_err()); + } + + #[test] + fn preserves_embedded_proposed_plan_examples() { + let message = "Example: `text`"; + let input = serde_json::json!({ + "type": "agent-turn-complete", + "thread-id": "thread-5", + "last-assistant-message": message + }) + .to_string(); + + let payload = parse_codex_payload(&input).unwrap().unwrap(); + assert_eq!(payload.message, message); + } + + #[test] + fn agent_process_matching_uses_path_components_not_substrings() { + assert!(agent_process_name_matches("/usr/bin/codex", "codex")); + assert!(agent_process_name_matches( + "/usr/bin/codex-x86_64-pc-windows-msvc.exe", + "codex" + )); + assert!(agent_process_name_matches( + "/opt/node_modules/@openai/codex/bin/cli.js", + "codex" + )); + assert!(agent_process_name_matches( + "/opt/node_modules/@anthropic-ai/claude-code/cli.js", + "claude" + )); + assert!(agent_process_name_matches( + "/opt/node_modules/@google/gemini-cli/dist/index.js", + "gemini" + )); + assert!(!agent_process_name_matches( + "/tmp/fix--codex-plan-review/anti_crosstalk", + "codex" + )); + assert!(!agent_process_name_matches( + "/tmp/my-codex-wrapper", + "codex" + )); + } +} + // ─── Claude ─────────────────────────────────────────────── pub fn cache_claude(launch: &LaunchConfig) { diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 0a3c1ec..4ee5eb8 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -9,8 +9,8 @@ use serde::Serialize; pub enum CliMode { /// Launch the Tauri GUI (default). Gui, - /// Cache a Codex reply from notify hook: `cliv cache-codex ''` - CacheCodex(String), + /// Cache a Codex reply from notify argv or Stop-hook stdin. + CacheCodex(Option), /// Cache a Claude reply from Stop hook (stdin): `cliv cache-claude` CacheClaude, /// Cache a Gemini reply from AfterAgent hook (stdin): `cliv cache-gemini` @@ -50,7 +50,7 @@ impl CliParsed { logging::log("═══════════════════════════════════════════════════"); logging::log(&format!("cliV started PID={}", std::process::id())); - logging::log(&format!(" argv={:?}", argv)); + logging::log(&format!(" argv={:?}", redact_cache_payload(&argv))); logging::debug(&format!( " CWD={}", std::env::current_dir() @@ -78,8 +78,14 @@ impl CliParsed { if argv.len() >= 2 { match argv[1].as_str() { "cache-codex" => { - let json = argv.get(2).cloned().unwrap_or_default(); - logging::log(&format!(" mode=cache-codex json_len={}", json.len())); + let json = argv.get(2).cloned().filter(|value| !value.is_empty()); + match json.as_ref() { + Some(value) => logging::log(&format!( + " mode=cache-codex transport=argv json_len={}", + value.len() + )), + None => logging::log(" mode=cache-codex transport=stdin"), + } return CliParsed { mode: CliMode::CacheCodex(json), args: CliArgs::default(), @@ -142,6 +148,16 @@ impl CliParsed { } } +fn redact_cache_payload(argv: &[String]) -> Vec { + let mut redacted = argv.to_vec(); + if argv.get(1).map(String::as_str) == Some("cache-codex") { + if let Some(payload) = redacted.get_mut(2) { + *payload = format!("", payload.len()); + } + } + redacted +} + /// Parse GUI-mode arguments from an argv slice (excluding the binary name). fn parse_gui_args( argv: &[String], @@ -463,7 +479,7 @@ fn match_agent_name(comm: &str) -> Option<&'static str> { mod tests { use super::{ detect_trusted_caller, find_agent_process, match_agent_name, parse_gui_args, - resolve_launch_paths, ParentProcess, + redact_cache_payload, resolve_launch_paths, ParentProcess, }; use crate::config::{AppConfig, LaunchConfig}; @@ -728,4 +744,21 @@ mod tests { let cmdline = "node /home/user/my-app/index.js"; assert_eq!(match_agent_name(cmdline), None); } + + #[test] + fn codex_cache_payload_is_redacted_from_logged_argv() { + let payload = r#"{"type":"agent-turn-complete","last-assistant-message":"secret"}"#; + let argv = vec![ + "/usr/bin/cliv".to_string(), + "cache-codex".to_string(), + payload.to_string(), + ]; + + let redacted = redact_cache_payload(&argv); + + assert_eq!(redacted[0], "/usr/bin/cliv"); + assert_eq!(redacted[1], "cache-codex"); + assert_eq!(redacted[2], format!("", payload.len())); + assert!(!redacted.join(" ").contains("secret")); + } } diff --git a/src-tauri/src/extract/codex.rs b/src-tauri/src/extract/codex.rs index fe5fb7f..994c965 100644 --- a/src-tauri/src/extract/codex.rs +++ b/src-tauri/src/extract/codex.rs @@ -4,7 +4,7 @@ use crate::logging; use std::path::Path; /// Read the cached Codex reply for a given cache key or thread-id. -/// The cache is populated by `cliv cache-codex` (called from Codex notify hook). +/// The cache is populated by `cliv cache-codex` from Codex notify or Stop hooks. /// Returns an explicit error if no lookup key is available or cache is missing. #[tauri::command] pub fn extract_codex_reply( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 631ce91..37f9d6b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,7 +14,7 @@ fn main() { cliv_lib::run_gui(parsed.args, app_config); } cliv_lib::CliMode::CacheCodex(ref json) => { - cliv_lib::cache::cache_codex(json, &app_config.launch) + cliv_lib::cache::cache_codex(json.as_deref(), &app_config.launch) } cliv_lib::CliMode::CacheClaude => cliv_lib::cache::cache_claude(&app_config.launch), cliv_lib::CliMode::CacheGemini => cliv_lib::cache::cache_gemini(&app_config.launch), diff --git a/src-tauri/tests/anti_crosstalk.rs b/src-tauri/tests/anti_crosstalk.rs index fd2c830..0a8929d 100644 --- a/src-tauri/tests/anti_crosstalk.rs +++ b/src-tauri/tests/anti_crosstalk.rs @@ -182,6 +182,135 @@ fn codex_thread_lookup_is_stable_when_cached_at_ties() { assert_eq!(result.unwrap(), "Tie reply B"); } +#[cfg(target_os = "linux")] +#[test] +fn cache_codex_notify_then_plan_stop_returns_current_plan_from_same_pid() { + let tmp = TempDir::new().unwrap(); + let fake_home = tmp.path().join("home"); + let codex_home = fake_home.join(".codex"); + let wrapper_path = tmp.path().join("codex"); + let transcript_path = codex_home.join("sessions").join("rollout.jsonl"); + let notify_payload = serde_json::json!({ + "type": "agent-turn-complete", + "thread-id": "fixture-thread", + "turn-id": "ordinary-turn", + "last-assistant-message": "# Ordinary reply\n\nThis must be replaced.\n" + }) + .to_string(); + let stop_payload = serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "fixture-thread", + "turn_id": "plan-turn", + "permission_mode": "plan", + "transcript_path": transcript_path, + "last_assistant_message": null + }) + .to_string(); + let transcript = [ + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "item_completed", + "thread_id": "fixture-thread", + "turn_id": "ordinary-turn", + "item": {"type": "AgentMessage", "text": "# Ordinary reply"} + } + }), + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "item_completed", + "thread_id": "fixture-thread", + "turn_id": "plan-turn", + "item": { + "type": "Plan", + "text": "# Current plan\n\n- Review this step" + } + } + }), + serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "plan-turn", + "last_agent_message": null + } + }), + ] + .into_iter() + .map(|event| event.to_string()) + .collect::>() + .join("\n"); + let script = format!( + "#!/usr/bin/env bash\nset -euo pipefail\nexport HOME=\"{home}\"\nexport CODEX_HOME=\"{codex_home}\"\n\"{cliv}\" cache-codex '{notify}'\nprintf '%s' '{stop}' | \"{cliv}\" cache-codex\n", + home = fake_home.display(), + codex_home = codex_home.display(), + cliv = env!("CARGO_BIN_EXE_cliv"), + notify = notify_payload, + stop = stop_payload, + ); + + fs::create_dir_all(&fake_home).unwrap(); + fs::create_dir_all(transcript_path.parent().unwrap()).unwrap(); + fs::write(&transcript_path, transcript).unwrap(); + fs::write(&wrapper_path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&wrapper_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&wrapper_path, perms).unwrap(); + } + + let status = Command::new("/bin/bash") + .arg(&wrapper_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + + let cache_dir = codex_home.join("reply_cache"); + let entries = fs::read_dir(&cache_dir) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries.len(), 2, "expected one md + meta cache pair"); + let cache_path = entries + .iter() + .map(|entry| entry.path()) + .find(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) + .expect("expected PID-keyed Codex cache"); + let codex_pid = cache_path + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let meta_path = cache_path.with_extension("meta.json"); + let cached = fs::read_to_string(&cache_path).unwrap(); + let metadata = fs::read_to_string(&meta_path).unwrap(); + let log = fs::read_to_string(fake_home.join(".cliv").join("cliv.log")).unwrap(); + + assert_eq!(cached, "# Current plan\n\n- Review this step"); + assert!(!cached.contains("Ordinary reply")); + assert!(!cached.contains("proposed_plan")); + assert!(metadata.contains("\"real_session_id\": \"fixture-thread\"")); + assert!(metadata.contains(&format!("\"pid\": {}", codex_pid))); + assert!(!log.contains("Ordinary reply")); + assert!(!log.contains("Current plan")); + + let by_pid = + cliv_lib::extract::codex::extract_codex_reply_from(&codex_home, Some(codex_pid)).unwrap(); + let by_session = cliv_lib::extract::codex::extract_codex_reply_from( + &codex_home, + Some("fixture-thread".to_string()), + ) + .unwrap(); + assert_eq!(by_pid, cached); + assert_eq!(by_session, cached); +} + // ═══════════════════════════════════════════════════════════ // Error cases // ═══════════════════════════════════════════════════════════ diff --git a/src/app/components/PersonalizationPanelTabs.tsx b/src/app/components/PersonalizationPanelTabs.tsx index e45c0b3..4d8a125 100644 --- a/src/app/components/PersonalizationPanelTabs.tsx +++ b/src/app/components/PersonalizationPanelTabs.tsx @@ -398,7 +398,8 @@ export function IntegrationsTab({ t }: { t: TranslateFn }) { {t("settings.integrations.agentBoundaryDesc")}

-
Codex: `~/.codex/config.toml`
+
{t("settings.integrations.codexFiles")}
+
{t("settings.integrations.codexHookTrust")}
Claude: `~/.claude/settings.json`
Gemini: `~/.gemini/settings.json`
diff --git a/src/app/components/__tests__/PersonalizationPanel.test.tsx b/src/app/components/__tests__/PersonalizationPanel.test.tsx index 72344c4..3b6876a 100644 --- a/src/app/components/__tests__/PersonalizationPanel.test.tsx +++ b/src/app/components/__tests__/PersonalizationPanel.test.tsx @@ -171,7 +171,16 @@ describe("PersonalizationPanel", () => { ), ).toBeInTheDocument(); expect(screen.getByText("External agent hook boundary")).toBeInTheDocument(); - expect(screen.getByText("Codex: `~/.codex/config.toml`")).toBeInTheDocument(); + expect( + screen.getByText( + "Codex: `~/.codex/config.toml` + `~/.codex/hooks.json`", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Plan Review capture requires a trusted Stop hook; review it with `/hooks` in Codex.", + ), + ).toBeInTheDocument(); expect( screen.getByText("Claude: `~/.claude/settings.json`"), ).toBeInTheDocument(); diff --git a/src/lib/locales.ts b/src/lib/locales.ts index 04f012d..eda59c7 100644 --- a/src/lib/locales.ts +++ b/src/lib/locales.ts @@ -157,6 +157,8 @@ const zh = { "settings.integrations.uiFromLegacy": "当前仍兼容旧 localStorage 偏好;一旦保存设置,就会迁移到统一 config。", "settings.integrations.agentBoundaryTitle": "外部 Agent Hook 边界", "settings.integrations.agentBoundaryDesc": "下面这些文件仍由各自的 agent CLI 管理。cliV 只展示边界,不会在这里直接改写它们。", + "settings.integrations.codexFiles": "Codex:`~/.codex/config.toml` + `~/.codex/hooks.json`", + "settings.integrations.codexHookTrust": "Plan Review 内容捕获需要已信任的 Stop Hook;请在 Codex 中使用 `/hooks` 完成审核。", // ── Annotation: Card ── "ann.edit": "编辑", @@ -388,6 +390,8 @@ const en: Record = { "settings.integrations.uiFromLegacy": "Legacy localStorage preferences are still honored for compatibility until the first save migrates them into the unified config.", "settings.integrations.agentBoundaryTitle": "External agent hook boundary", "settings.integrations.agentBoundaryDesc": "These files are still owned by their respective agent CLIs. cliV shows the boundary here but does not rewrite them directly.", + "settings.integrations.codexFiles": "Codex: `~/.codex/config.toml` + `~/.codex/hooks.json`", + "settings.integrations.codexHookTrust": "Plan Review capture requires a trusted Stop hook; review it with `/hooks` in Codex.", // ── Annotation: Card ── "ann.edit": "Edit",