fix(codex): restore plugin hooks on current runtimes

This commit is contained in:
huangsen 2026-09-30 15:06:03 +08:00
parent d0c7d2bc32
commit cd0351c8ad
8 changed files with 226 additions and 59 deletions

View file

@ -41,10 +41,16 @@ codex plugin marketplace add /absolute/path/to/ReMe/integrations/codex
codex plugin add reme@reme-codex
```
Start Codex and review and trust the installed plugin's hooks in `/hooks`, then open a new conversation.
Start Codex and review and trust the four ReMe hooks (`SessionStart`, `SessionEnd`,
`UserPromptSubmit`, and `Stop`) in `/hooks`, then open a new conversation.
The installation loads the bundled hooks, MCP connection, and skill together. After plugin updates,
reinstall it and review any changed hook definitions.
This package uses the supported `.codex-plugin/plugin.json` and `.mcp.json` layout.
Codex 0.159.2 and desktop runtime 0.158.0-alpha.2.1 omit this plugin's hooks when a root
`plugin.json` selects the portable loader, even with an explicit hook declaration. Keep the
Codex layout until native hook discovery is verified with the portable loader.
## 3. Use memory in Codex
In a new conversation, ask “Check whether ReMe is available.” The installed plugin's tools check
@ -127,7 +133,9 @@ Clients without that extension can still use tools and hooks, but have no native
## Verify and troubleshoot
1. In Codex, ask the installed ReMe plugin to check the connection; confirm its status in `/mcp`.
1. Check that `/hooks` lists all four ReMe hooks and that each is enabled and trusted. Then use
**Check connection** in the MCP settings. A healthy result verifies the service connection;
it does not verify hook loading or execution.
2. For a quick test, set `memory_interval` to `1`, then start a new conversation and ask the agent to
remember a synthetic fact, such as “Project Juniper reviews are on Thursday.”
3. Wait for the write and inspect `reme/hooks.log` for `memory_saved` and the ReMe workspace's `daily/`.
@ -135,10 +143,13 @@ Clients without that extension can still use tools and hooks, but have no native
The installed plugin provides both automatic memory and explicit search/read and health checks.
If its tools are missing, check that the plugin is installed and enabled, the service is running,
and `service.jobs` exposes the required jobs. If its hooks do not load, use a host version supporting
the plugin requirements and reinstall or reload the plugin.
and `service.jobs` exposes the required jobs. If ReMe is missing from `/hooks`, reinstall the current
plugin and restart the host. If listed but awaiting review, trust its current definitions.
If trusted hooks fail, check that the hook environment's `python3` is Python 3.11+ with FastMCP installed.
Capture currently reads Codex rollout JSONL `event_msg` records (`user_message` and final `agent_message`). The hook requires
Capture reads Codex rollout JSONL `event_msg` records: legacy `user_message` / final `agent_message`,
and newer `item_completed` events containing `UserMessage` / final `AgentMessage` text blocks.
`response_item` records are excluded, including injected context and duplicate messages. The hook requires
`transcript_path` and `last_assistant_message`. Missing or unsupported transcripts are skipped rather
than guessed. Codex transcript formats are not a stable public interface. Check `capture_skipped` or
`hook_failed` events when recall works but recording does not.
@ -150,5 +161,8 @@ specific call. Trusted automatic hooks act as MCP clients themselves; they do no
## Source and validation
Run `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v` from the ReMe checkout. Tests isolate host
state and mock service calls. Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks).
state and mock service calls. With a runtime supporting `hooks/list`, run
`REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py -v`
to install into an isolated profile and verify hook discovery before trust, without model calls or hook execution.
Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks).
ReMe is developed at [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) under Apache-2.0.

View file

@ -39,9 +39,15 @@ codex plugin marketplace add /absolute/path/to/ReMe/integrations/codex
codex plugin add reme@reme-codex
```
启动 Codex,在 `/hooks` 中检查并信任已安装插件的 Hook,然后新建会话。
启动 Codex,在 `/hooks` 中检查并信任 ReMe 的四个 Hook(`SessionStart`、`SessionEnd`、
`UserPromptSubmit`、`Stop`),然后新建会话。
插件安装会一并加载 Hook、MCP 连接和内置 Skill。更新后重新安装插件,并审核有变化的 Hook。
本插件使用受支持的 `.codex-plugin/plugin.json` 与 `.mcp.json` 布局。
在 Codex 0.159.2 和桌面运行时 0.158.0-alpha.2.1 中,根目录 `plugin.json` 会选择 portable
加载器,导致本插件的 Hook 被遗漏,显式声明 Hook 路径也不能解决。确认 portable 加载器能通过
原生 Hook 发现验证前,保留 Codex 布局。
## 3. 在 Codex 中使用记忆
在新会话中询问“检查 ReMe 是否可用”,由已安装插件的工具检查服务连接;也可通过 `/mcp` 查看插件连接状态。
@ -104,17 +110,20 @@ Codex 0.145.0 会跳过设置了 `async` 的 hook,因此本适配器使用同
## 验证与排查
1. 在 Codex 中让已安装的 ReMe 插件检查连接,通过 `/mcp` 确认插件连接状态。
1. 在 `/hooks` 中确认 ReMe 的四个 Hook 均已加载、启用且信任,再在 MCP 设置中点击
**Check connection**。健康结果只验证服务连接,不代表 Hook 已加载或执行。
2. 快速验证时把 `memory_interval` 设为 `1`,新建会话,要求记住一条合成事实,例如“Juniper 项目每周四评审”。
3. 等待写入,检查 `reme/hooks.log` 的 `memory_saved`,以及 ReMe workspace 的 `daily/` 笔记。
4. 再新建一个独立会话,询问该事实,并确认回答附有 ReMe 来源路径。
自动记忆和显式检索、阅读、健康检查均由已安装的插件提供。工具缺失时,检查插件是否已安装并启用、
ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。Hook 无法加载时,请使用满足插件要求的宿主版本,
再重新安装或加载插件。
ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。`/hooks` 中没有 ReMe 时,重新安装当前插件并
重启宿主;已列出但待审核时,信任当前 Hook 定义。已信任的 Hook 执行失败时,检查 Hook 环境中的
`python3` 是否为 Python 3.11+,并已安装 FastMCP。
记录要求 Hook 提供 `transcript_path` 和 `last_assistant_message`,读取 rollout 的 `event_msg`
(`user_message` 与最终 `agent_message`),不读取私有推理。
记录要求 Hook 提供 `transcript_path` 和 `last_assistant_message`,读取 rollout 的 `event_msg`:
旧版 `user_message` / 最终 `agent_message`,以及新版 `item_completed` 中的
`UserMessage` / 最终 `AgentMessage` 文本块。排除 `response_item` 中的注入上下文和重复消息,不读取私有推理。
Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过并记录状态,不猜测对话。
召回正常但未记录时,检查 `capture_skipped`、`hook_failed`。
@ -123,5 +132,8 @@ Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过
## 源码与验证
在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v`。测试使用隔离目录和模拟服务。
使用支持 `hooks/list` 的运行时,执行
`REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py -v`,
可在隔离配置目录安装插件,验证信任前的 Hook 发现行为,不调用模型或执行 Hook。
宿主行为以 [Codex Hook 文档](https://learn.chatgpt.com/docs/hooks) 为准。
ReMe 在 [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) 开发,采用 Apache-2.0 许可证。

View file

@ -1,6 +1,6 @@
{
"name": "reme",
"version": "0.1.0+codex.20260930055341",
"version": "0.1.0+codex.20260930070500",
"description": "MCP-native automatic memory and editable plugin settings for Codex.",
"author": {
"name": "EconML team of Alibaba Tongyi Lab",
@ -18,6 +18,7 @@
],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",
"interface": {
"displayName": "ReMe",
"shortDescription": "Long-term memory for agents",

View file

@ -47,7 +47,7 @@ def clean_text(value: str) -> str:
def transcript_messages(path: Path):
"""Read Codex rollout user/agent events, excluding reasoning and response-item duplicates."""
"""Read legacy and completed-item events, excluding reasoning and response-item duplicates."""
with path.open(encoding="utf-8") as handle:
for index, line in enumerate(handle):
if not line.endswith("\n"):
@ -64,10 +64,23 @@ def transcript_messages(path: Path):
return
if record.get("type") != "event_msg":
continue
role = {"user_message": "user", "agent_message": "assistant"}.get(payload.get("type"))
if role is None or role == "assistant" and payload.get("phase") not in {None, "final_answer"}:
if payload.get("type") == "item_completed":
item = payload.get("item")
if not isinstance(item, dict) or not isinstance(item.get("content"), list):
continue
role = {"UserMessage": "user", "AgentMessage": "assistant"}.get(item.get("type"))
phase = item.get("phase")
text_type = "text" if role == "user" else "Text"
content = "\n".join(
block["text"]
for block in item["content"]
if isinstance(block, dict) and block.get("type") == text_type and isinstance(block.get("text"), str)
)
else:
role = {"user_message": "user", "agent_message": "assistant"}.get(payload.get("type"))
phase, content = payload.get("phase"), payload.get("message")
if role is None or role == "assistant" and phase not in {None, "final_answer"}:
continue
content = payload.get("message")
if role == "user" and isinstance(content, str) and "## My request for Codex:" in content:
content = content.split("## My request for Codex:", 1)[1]
if not isinstance(content, str) or not (text := clean_text(content)):

View file

@ -1,13 +0,0 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"reme": {
"type": "stdio",
"command": "python3",
"args": [
"${PLUGIN_ROOT}/mcp_server.py"
],
"cwd": "${PLUGIN_ROOT}"
}
}
}

View file

@ -1,20 +0,0 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "reme",
"version": "0.1.0+codex.20260930055341",
"description": "MCP-native automatic memory and editable plugin settings for Codex.",
"author": {
"name": "EconML team of Alibaba Tongyi Lab",
"email": "jinli.yl@alibaba-inc.com"
},
"homepage": "https://reme.agentscope.io",
"repository": "https://github.com/agentscope-ai/ReMe",
"license": "Apache-2.0",
"keywords": [
"memory",
"reme",
"mcp",
"long-term-memory",
"agent"
]
}

View file

@ -0,0 +1,102 @@
"""Opt-in native plugin discovery regression, without model calls or hook execution.
Run with REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py.
The selected runtime must support the hooks/list app-server API.
"""
# pylint: disable=missing-function-docstring
import json
import os
import queue
import subprocess
import threading
from pathlib import Path
import pytest
MARKETPLACE = Path(__file__).resolve().parents[2] / "integrations/codex"
EVENTS = {"sessionStart", "sessionEnd", "userPromptSubmit", "stop"}
def test_installed_plugin_exposes_all_hooks_before_trust(tmp_path):
binary = os.environ.get("REME_CODEX_BIN")
if not binary:
pytest.skip("Set REME_CODEX_BIN to run the native Codex installation check")
home = tmp_path / "codex-home"
home.mkdir()
env = {**os.environ, "CODEX_HOME": str(home)}
for args in (
["plugin", "marketplace", "add", str(MARKETPLACE)],
["plugin", "add", "reme@reme-codex"],
):
result = subprocess.run([binary, *args], env=env, capture_output=True, text=True, check=False, timeout=60)
assert result.returncode == 0, result.stderr
with (
(tmp_path / "app-server.log").open("w", encoding="utf-8") as stderr,
subprocess.Popen(
[binary, "app-server"],
env=env,
cwd=tmp_path,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr,
text=True,
) as process,
):
responses = queue.Queue()
def receive():
for line in process.stdout:
responses.put(json.loads(line))
reader = threading.Thread(target=receive, daemon=True)
reader.start()
def request(request_id, method, params):
process.stdin.write(json.dumps({"id": request_id, "method": method, "params": params}) + "\n")
process.stdin.flush()
while True:
response = responses.get(timeout=30)
if response.get("id") == request_id:
assert "error" not in response, response
return response["result"]
try:
request(
0,
"initialize",
{
"clientInfo": {"name": "reme-install-test", "version": "1.0"},
"capabilities": {"experimentalApi": True},
},
)
process.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
process.stdin.flush()
details = request(
1,
"plugin/read",
{"marketplacePath": str(MARKETPLACE / ".agents/plugins/marketplace.json"), "pluginName": "reme"},
)["plugin"]
assert details["summary"]["installed"] and details["summary"]["enabled"]
assert details["mcpServers"] == ["reme"]
assert {hook["eventName"] for hook in details["hooks"]} == EVENTS
entries = request(2, "hooks/list", {"cwds": [str(tmp_path)]})["data"]
assert len(entries) == 1
assert not entries[0]["errors"]
hooks = [hook for hook in entries[0]["hooks"] if hook.get("pluginId") == "reme@reme-codex"]
assert len(hooks) == 4
assert {hook["eventName"] for hook in hooks} == EVENTS
assert all(hook["enabled"] and hook["trustStatus"] == "untrusted" for hook in hooks)
finally:
process.stdin.close()
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
reader.join(timeout=5)
process.stdout.close()

View file

@ -77,15 +77,7 @@ def test_native_manifests_resolve_within_their_own_plugin():
else:
assert mcp["args"] == ["mcp_server.py"]
assert (root / "mcp_server.py").is_file()
portable = json.loads((root / "plugin.json").read_text())
assert portable["name"] == manifest["name"]
assert portable["version"] == manifest["version"]
assert not {"skills", "mcpServers", "interface"} & portable.keys()
portable_mcp = json.loads((root / "mcp.json").read_text())["mcpServers"]["reme"]
assert portable_mcp["type"] == "stdio"
assert portable_mcp["args"] == ["${PLUGIN_ROOT}/mcp_server.py"]
assert portable_mcp["cwd"] == "${PLUGIN_ROOT}"
assert portable_mcp["command"] == mcp["command"]
assert (root / manifest["hooks"]).is_file()
assert mcp["cwd"] == "."
assert "CODEX_HOME" in mcp["env_vars"]
if host == "claude-code":
@ -238,6 +230,72 @@ def test_capture_excludes_tools_and_recalled_context(adapter, tmp_path):
assert all(message["id"].startswith(adapter.HOST + "-") for message in pair)
@pytest.mark.parametrize("adapter", ["codex"], indirect=True)
def test_codex_completed_items_capture_only_final_text_and_deduplicate(adapter, tmp_path, monkeypatch):
path = tmp_path / "rollout.jsonl"
def completed(item):
return {
"timestamp": "2026-09-30T07:00:11.665Z",
"type": "event_msg",
"payload": {"type": "item_completed", "turn_id": "turn-one", "item": item},
}
rows = [
{"type": "session_meta", "payload": {"source": "cli"}},
{
"type": "response_item",
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "injected"}]},
},
completed(
{
"type": "UserMessage",
"content": [
{"type": "text", "text": "Remember Thursday.<reme-context>old</reme-context>", "text_elements": []},
{"type": "image", "text": "image payload"},
],
},
),
completed({"type": "AgentMessage", "phase": "commentary", "content": [{"type": "Text", "text": "Working"}]}),
completed({"type": "Reasoning", "content": [{"type": "Text", "text": "private"}]}),
completed({"type": "ToolCall", "content": [{"type": "Text", "text": "tool output"}]}),
completed({"type": "AgentMessage", "phase": "final_answer", "content": [{"type": "Text", "text": "Saved."}]}),
{
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"phase": "final_answer",
"content": [{"type": "output_text", "text": "Saved."}],
},
},
completed({"type": "UserMessage", "content": [{"type": "text", "text": "next turn"}]}),
completed({"type": "AgentMessage", "phase": "commentary", "content": [{"type": "Text", "text": "Saved."}]}),
completed(None),
completed({"type": "AgentMessage", "content": "unsupported"}),
]
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
payload = {
"hook_event_name": "Stop",
"session_id": "modern-session",
"transcript_path": str(path),
"last_assistant_message": "Saved.",
}
messages = adapter.completed_turn(payload, "session")
assert [message["content"][0]["text"] for message in messages] == ["Remember Thursday.", "Saved."]
call = Mock(return_value={"success": True, "answer": "daily/fact.md"})
monkeypatch.setattr(adapter, "call", call)
adapter.write_json(adapter.data_dir() / "config.json", {"memory_interval": 1})
adapter.handle_event(payload)
adapter.handle_event(payload)
call.assert_called_once()
assert [message["content"][0]["text"] for message in call.call_args.args[2]["messages"]] == [
"Remember Thursday.",
"Saved.",
]
assert len(list((adapter.data_dir() / "queue").rglob("*.done"))) == 1
def test_stop_matches_its_reply_when_next_turn_is_already_in_transcript(adapter, tmp_path):
path = tmp_path / "transcript.jsonl"
payload = transcript(adapter, path, [("first", "first answer"), ("second", "second answer")])