Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.7.1] - 2026-07-01

### Fixed

- **Codex hook installs now use Codex's root `hooks` object.** `omind setup --agent
codex` writes `~/.codex/hooks.json` as `{"hooks": {...}}`, matching current Codex
CLI parsing. It also migrates the brief 3.7.0-era Claude-style root event map
(`PreToolUse`, `PermissionRequest`, etc.) into the new object while preserving
user-authored hook groups and remaining idempotent.

## [3.7.0] - 2026-07-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "omind"
version = "3.7.0"
version = "3.7.1"
description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/omind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright 2026 Aaron K. Clark
"""omind — OMI/Obsidian memory tooling for AI agents."""

__version__ = "3.7.0"
__version__ = "3.7.1"
57 changes: 48 additions & 9 deletions src/omind/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ def codex_config_path() -> Path:
#: Substring identifying omind's own Codex guard hook command, so a re-run finds
#: and replaces only our entry (and never duplicates) inside the user's hooks.json.
CODEX_GUARD_MARKER = "guard adapter --harness codex"
CODEX_HOOK_EVENTS = (
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"SubagentStart",
"SubagentStop",
)


def gemini_config_dir() -> Path:
Expand Down Expand Up @@ -925,10 +935,10 @@ def install_guard(self) -> None:
class CodexProvisioner(AgentProvisioner):
"""Wire OpenAI Codex CLI into both the OMI guard and the ``omi`` MCP server.

Codex (>= 0.117) adopted the Claude-Code hook schema: ``PreToolUse`` /
``PermissionRequest`` command hooks loaded from ``~/.codex/hooks.json`` (the
``hooks`` feature is stable and on by default — no ``config.toml`` change
needed). omind mounts ``omind guard adapter --harness codex`` on BOTH events
Codex (>= 0.117) adopted Claude-style ``PreToolUse`` / ``PermissionRequest``
command hooks under the top-level ``hooks`` key in ``~/.codex/hooks.json``
(the ``hooks`` feature is stable and on by default — no ``config.toml``
change needed). omind mounts ``omind guard adapter --harness codex`` on BOTH events
(PreToolUse blocks at the tool call; PermissionRequest is the approval-path
backstop); on a hard-rule deny the adapter emits Codex's
``permissionDecision: deny`` / ``decision.behavior: deny`` shape. Codex's
Expand Down Expand Up @@ -973,16 +983,38 @@ def _guard_hook_group(self) -> dict[str, Any]:
]
}

def _read_hooks_file(self) -> tuple[dict[str, Any], dict[str, Any]]:
"""Return ``(root, hooks)`` for Codex's current hooks file.

omind 3.7.0 briefly wrote Claude's event map at the file root. Codex
rejects that shape with "unknown field `PreToolUse`, expected `hooks`".
Read both forms so setup can migrate affected installs without losing
user-authored hook groups.
"""
root = self._read_settings(codex_hooks_path())
raw_hooks = root.get("hooks")
hooks = raw_hooks if isinstance(raw_hooks, dict) else {}
for event in CODEX_HOOK_EVENTS:
if event not in root:
continue
legacy = root[event]
current = hooks.get(event)
if current is None:
hooks[event] = legacy
elif isinstance(current, list) and isinstance(legacy, list):
hooks[event] = current + [g for g in legacy if g not in current]
return root, hooks

def install_guard(self) -> None:
"""Merge omind's guard hook into ``~/.codex/hooks.json`` for ``PreToolUse``
and ``PermissionRequest``, replacing only our own entry (by
:data:`CODEX_GUARD_MARKER`) so user-authored hooks are preserved."""
path = codex_hooks_path()
data = self._read_settings(path)
data, hooks_cfg = self._read_hooks_file()
desired = self._guard_hook_group()
changed = False
for event in ("PreToolUse", "PermissionRequest"):
groups = data.get(event)
groups = hooks_cfg.get(event)
existing = groups if isinstance(groups, list) else []
kept = [
g
Expand All @@ -991,8 +1023,15 @@ def install_guard(self) -> None:
]
merged = kept + [desired]
if merged != existing:
data[event] = merged
hooks_cfg[event] = merged
changed = True
for event in CODEX_HOOK_EVENTS:
if event in data:
del data[event]
changed = True
if data.get("hooks") != hooks_cfg:
data["hooks"] = hooks_cfg
changed = True
if changed or self.config.force:
self._record(
f"install OMI guard hooks (PreToolUse + PermissionRequest) in {path}"
Expand All @@ -1005,13 +1044,13 @@ def install_guard(self) -> None:

def _guard_wired(self) -> bool:
try:
data = self._read_settings(codex_hooks_path())
_root, hooks_cfg = self._read_hooks_file()
except ProvisionError:
return False
return all(
any(
isinstance(g, dict) and CODEX_GUARD_MARKER in json.dumps(g)
for g in (data.get(event) or [])
for g in (hooks_cfg.get(event) or [])
)
for event in ("PreToolUse", "PermissionRequest")
)
Expand Down
26 changes: 22 additions & 4 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,10 @@ def test_codex_setup_installs_guard_hooks(tmp_path: Path) -> None:
run_setup_for(config, log=_quiet)

data = json.loads(agents.codex_hooks_path().read_text(encoding="utf-8"))
assert set(data) == {"hooks"}
hooks = data["hooks"]
for event in ("PreToolUse", "PermissionRequest"):
groups = data[event]
groups = hooks[event]
assert len(groups) == 1
handler = groups[0]["hooks"][0]
assert handler["type"] == "command"
Expand All @@ -485,7 +487,13 @@ def test_codex_guard_preserves_user_hooks_and_is_idempotent(tmp_path: Path) -> N
hooks_path.write_text(
json.dumps(
{
"hooks": {
"PostToolUse": [
{"hooks": [{"type": "command", "command": "user-posttool"}]}
]
},
"PreToolUse": [{"hooks": [{"type": "command", "command": "my-own-hook"}]}],
"PostCompact": [{"hooks": [{"type": "command", "command": "user-compact"}]}],
"SessionStart": [{"hooks": [{"type": "command", "command": "user-start"}]}],
}
),
Expand All @@ -495,14 +503,24 @@ def test_codex_guard_preserves_user_hooks_and_is_idempotent(tmp_path: Path) -> N
run_setup_for(config, log=_quiet)

data = json.loads(hooks_path.read_text(encoding="utf-8"))
pre_cmds = [g["hooks"][0]["command"] for g in data["PreToolUse"]]
hooks = data["hooks"]
pre_cmds = [g["hooks"][0]["command"] for g in hooks["PreToolUse"]]
assert "my-own-hook" in pre_cmds # user's own PreToolUse hook preserved
assert any("--harness codex" in c for c in pre_cmds) # omind appended
assert data["SessionStart"][0]["hooks"][0]["command"] == "user-start" # other events untouched
assert hooks["PostToolUse"][0]["hooks"][0]["command"] == "user-posttool"
assert hooks["SessionStart"][0]["hooks"][0]["command"] == "user-start" # other events untouched
assert hooks["PostCompact"][0]["hooks"][0]["command"] == "user-compact"
assert "PreToolUse" not in data # migrated to Codex's root `hooks` schema
assert "PostToolUse" not in data
assert "SessionStart" not in data
assert "PostCompact" not in data

run_setup_for(config, log=_quiet) # second run must not duplicate
data2 = json.loads(hooks_path.read_text(encoding="utf-8"))
omind_groups = [g for g in data2["PreToolUse"] if "--harness codex" in g["hooks"][0]["command"]]
omind_groups = [
g for g in data2["hooks"]["PreToolUse"]
if "--harness codex" in g["hooks"][0]["command"]
]
assert len(omind_groups) == 1


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.