PreCompact/PostCompact hook receipts are not observable around compaction events
Type: Enhancement / observability gap for hook lifecycle reliability.
Summary
I would like Codex to expose an auditable receipt for PreCompact and PostCompact hooks whenever a real compaction event occurs.
The practical gap is not just whether the hook surface exists. In a long-running Codex Desktop session with hooks enabled and trusted, I can see context_compacted events in the raw rollout trace, but I cannot find a machine-readable record that the configured PreCompact hooks ran, skipped, failed, or injected/spilled output for those compactions.
That makes it hard to use compaction hooks for durable context recovery. If a hook is configured incorrectly, untrusted, skipped by a compaction path, times out, emits invalid JSON, spills output, or succeeds, the operator needs a deterministic way to tell which one happened.
Environment
- Codex:
codex-cli 0.140.0-alpha.19 - Variant: Codex Desktop / app-server-backed session
- Platform: macOS
- Hooks enabled:
features.hooks = true - Hook config: trusted
PreCompactentries were present for three command hooks in<codex-home>/hooks.json - Related configured events in the same file:
SessionStart,PreToolUse,PostToolUse,UserPromptSubmit, andSubagentStart
Reproduction / evidence
- Configure and trust
PreCompacthooks in<codex-home>/hooks.json. - Confirm the hook commands are executable by running each command directly with a minimal JSON stdin payload. In my case the commands returned valid JSON with a
systemMessagefield and exit code0. - Run a long Codex Desktop session that performs real compactions.
- Inspect the raw rollout JSONL and count only actual Codex event payloads, not plain text inside assistant messages or tool output.
The raw rollout contained:
context_compacted events: 57
actual hook event payloads in rollout: 0
compactions with nearby actual hook event payloads: 0
To avoid overstating old history, I also checked the window after the current hook trust state was present. In that window, the raw rollout contained three real compactions:
2026-06-16T23:31:59Z
2026-06-17T00:48:07Z
2026-06-17T00:55:39Z
For those compactions, I still found no nearby actual hook event payloads in the rollout.
I also checked local structured app-server hook notifications. Hook telemetry exists for other hook events, such as preToolUse, but I did not find a structured preCompact / postCompact hook receipt that could be tied to the observed compactions.
Observed vs expected
Observed:
- Real
context_compactedevents are recorded. - Trusted
PreCompacthook config exists. - The configured hook commands are executable when invoked directly.
- Hook telemetry exists for at least some other hook events.
- I cannot determine from the rollout or structured hook-event records whether the configured
PreCompacthooks ran, skipped, failed, emitted output, or had output spilled for specific compaction events.
Expected:
- Each real compaction should produce an auditable hook receipt for every configured
PreCompactandPostCompacthook. - If a hook does not run, Codex should record why: disabled hooks, untrusted hook, matcher miss, unsupported compaction path, timeout, invalid output, command failure, or another explicit reason.
- If a hook runs, Codex should record the event name, hook source, command/display identifier, trust status, start/end timestamps, duration, exit status, output action, output size, and any spill reference.
- The receipt should be visible in the same durable session trace/event stream that records
context_compacted, or in another documented machine-readable location linked by compaction event id.
Suggested fix
Add a first-class compaction hook receipt model and associate it with the compaction event.
One possible shape:
{
"type": "hook_completed",
"event_name": "preCompact",
"compaction_event_id": "<stable-id>",
"hook_run_id": "<stable-id>",
"source_path": "<redacted-or-config-relative-source>",
"display_order": 0,
"status": "completed",
"skip_reason": null,
"duration_ms": 123,
"exit_code": 0,
"output": {
"action": "additionalContext",
"bytes": 2048,
"spilled": false,
"spill_reference": null
}
}
The exact schema can be different, but it should let an external tool answer these questions without scraping UI text:
- Did this configured compaction hook run for this compaction?
- If not, why not?
- If it ran, did Codex accept, reject, truncate, or spill the output?
- Was the output included in the model-visible compaction or post-compaction context?
Verification
An automated test could configure a known PreCompact hook that emits a small valid additionalContext payload, trigger a real compaction, and assert that:
- the trace contains one
context_compactedevent; - the trace contains a
preCompacthook receipt associated with that compaction; - the receipt records success/failure/skipped status explicitly;
- a failing or untrusted hook produces a receipt with a specific skip or failure reason.
Related issues
- #17148 tracks
PreCompact/PostCompactlifecycle hooks. - #23153 asks for hooks to request real compaction and also points at the need for receipts around compaction actions.
- #22861 covers hook
additionalContextspilling, which should be reflected in the proposed receipt model.
Current workaround
There is no durable workaround for automatic compaction observability. I can manually run hook commands before compaction, or inspect local logs opportunistically, but neither proves that Codex ran a specific configured hook for a specific compaction event.
Session scanner
<details>
<summary>scan_session_compaction_hooks.py</summary>
#!/usr/bin/env python3
"""Scan a Codex session JSONL file for compaction hook evidence.
The scanner prints bounded summaries only. It does not dump raw session payloads.
It accepts either raw rollout `.jsonl`, extracted `.jsonl`, or gzipped
`.jsonl.gz` files.
"""
from __future__ import annotations
import gzip
import json
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
MARKERS = {
"hook_started_method": "hook/started",
"hook_completed_method": "hook/completed",
"pre_compact_camel": "preCompact",
"pre_compact_title": "PreCompact",
"post_compact_camel": "postCompact",
"post_compact_title": "PostCompact",
"hook_precompact_command": "hook-precompact",
"jcodemunch_snapshot": "## Session Snapshot (jCodemunch)",
"jcodemunch_snapshot_alt": "## Session Snapshot (jCodeMunch)",
"jdocmunch_snapshot": "## jDocMunch Session Snapshot",
"jcodemunch_digest": "## jCodemunch digest",
"jcodemunch_repo_briefing": "## jCodemunch Repo Briefing",
"codex_status_jcodemunch": "Capturing jCodeMunch session snapshot",
"codex_status_jdocmunch": "Capturing jDocMunch session snapshot",
"system_message_key": "systemMessage",
}
@dataclass(frozen=True)
class ParsedRow:
line_number: int
session_line: int | None
timestamp: str | None
event: str
payload_type: str | None
text: str
def iter_lines(path: Path) -> Iterable[tuple[int, str]]:
opener = gzip.open if path.suffix == ".gz" else open
with opener(path, "rt", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, start=1):
yield line_number, line.rstrip("\n")
def event_name(obj: Any) -> str:
if not isinstance(obj, dict):
return "<non-object>"
wrapped_event = obj.get("event")
if isinstance(wrapped_event, dict):
value = wrapped_event.get("type")
if isinstance(value, str):
return value
for key in ("type", "event", "kind", "name"):
value = obj.get(key)
if isinstance(value, str):
return value
payload = obj.get("payload")
if isinstance(payload, dict):
for key in ("type", "event", "kind", "name"):
value = payload.get(key)
if isinstance(value, str):
return value
return "<unknown>"
def timestamp(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
value = obj.get("timestamp")
return value if isinstance(value, str) else None
def payload_type(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
payload = obj.get("payload")
if not isinstance(payload, dict):
return None
value = payload.get("type")
return value if isinstance(value, str) else None
def is_actual_hook_event(obj: Any) -> bool:
if not isinstance(obj, dict) or obj.get("type") != "event_msg":
return False
payload = obj.get("payload")
if not isinstance(payload, dict):
return False
payload_kind = str(payload.get("type") or "").lower()
if "hook" in payload_kind:
return True
method = payload.get("method")
return method in {"hook/started", "hook/completed"}
def is_actual_context_compacted(obj: Any, name: str) -> bool:
if name == "context_compacted":
return True
if not isinstance(obj, dict):
return False
payload = obj.get("payload")
return isinstance(payload, dict) and payload.get("type") == "context_compacted"
def compact_text(obj: Any) -> str:
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
def scan(path: Path, *, include_compactions: bool = False) -> dict[str, Any]:
marker_counts: Counter[str] = Counter()
event_counts: Counter[str] = Counter()
payload_type_counts: Counter[str] = Counter()
compactions: list[dict[str, Any]] = []
marker_hits: list[dict[str, Any]] = []
actual_hook_events: list[dict[str, Any]] = []
parsed_rows: list[ParsedRow] = []
for line_number, line in iter_lines(path):
obj: Any | None = None
try:
obj = json.loads(line)
except json.JSONDecodeError:
text = line
name = "<json-error>"
session_line = None
else:
text = compact_text(obj)
name = event_name(obj)
raw_session_line = obj.get("session_line")
session_line = raw_session_line if isinstance(raw_session_line, int) else None
row_timestamp = timestamp(obj)
row_payload_type = payload_type(obj)
event_counts[name] += 1
if row_payload_type:
payload_type_counts[row_payload_type] += 1
parsed_rows.append(
ParsedRow(
line_number=line_number,
session_line=session_line,
timestamp=row_timestamp,
event=name,
payload_type=row_payload_type,
text=text,
)
)
if is_actual_hook_event(obj):
actual_hook_events.append(
{
"line": line_number,
"session_line": session_line,
"timestamp": row_timestamp,
"event": name,
"payload_type": row_payload_type,
}
)
for marker_name, marker in MARKERS.items():
if marker in text:
marker_counts[marker_name] += 1
marker_hits.append(
{
"marker": marker_name,
"line": line_number,
"session_line": session_line,
"timestamp": row_timestamp,
"event": name,
}
)
if obj is not None and is_actual_context_compacted(obj, name):
compactions.append(
{
"line": line_number,
"session_line": session_line,
"timestamp": row_timestamp,
"event": name,
"markers_on_line": [
marker_name
for marker_name, marker in MARKERS.items()
if marker in text
],
}
)
line_to_index = {
row.line_number: index
for index, row in enumerate(parsed_rows)
}
for item in compactions:
index = line_to_index[item["line"]]
start = max(0, index - 25)
end = min(len(parsed_rows), index + 26)
window = parsed_rows[start:end]
markerful_window = []
hook_window = []
for row in window:
markers = [
marker_name
for marker_name, marker in MARKERS.items()
if marker in row.text
]
if markers or row.event in {"context_compacted", "hook_started", "hook_completed"}:
markerful_window.append(
{
"line": row.line_number,
"session_line": row.session_line,
"timestamp": row.timestamp,
"event": row.event,
"payload_type": row.payload_type,
"markers": markers,
}
)
if row.payload_type and "hook" in row.payload_type.lower():
hook_window.append(
{
"line": row.line_number,
"session_line": row.session_line,
"timestamp": row.timestamp,
"event": row.event,
"payload_type": row.payload_type,
}
)
item["nearby_actual_hook_events"] = hook_window
item["nearby_marker_events"] = markerful_window
compactions_with_actual_hooks = [
item for item in compactions if item["nearby_actual_hook_events"]
]
compactions_with_markers = [
item for item in compactions if item["nearby_marker_events"]
]
result: dict[str, Any] = {
"path": str(path),
"line_count": len(parsed_rows),
"event_counts_top": event_counts.most_common(20),
"payload_type_counts_top": payload_type_counts.most_common(30),
"actual_hook_event_count": len(actual_hook_events),
"actual_hook_events": actual_hook_events[:50],
"compaction_count": len(compactions),
"compactions_with_nearby_actual_hooks": len(compactions_with_actual_hooks),
"compactions_with_nearby_text_markers": len(compactions_with_markers),
"compaction_samples": [
{
"line": item["line"],
"timestamp": item["timestamp"],
"nearby_actual_hook_events": item["nearby_actual_hook_events"],
"nearby_marker_event_count": len(item["nearby_marker_events"]),
}
for item in compactions[:10]
],
"marker_counts": dict(marker_counts),
"marker_hit_count": len(marker_hits),
"marker_hits_sample": marker_hits[:10],
}
if include_compactions:
result["compactions"] = compactions
return result
def main(argv: list[str]) -> int:
include_compactions = "--include-compactions" in argv
positional = [arg for arg in argv[1:] if arg != "--include-compactions"]
if len(positional) != 1:
print(
"usage: scan_session_compaction_hooks.py [--include-compactions] <raw-session.jsonl[.gz]>",
file=sys.stderr,
)
return 2
path = Path(positional[0])
if not path.exists():
print(f"not found: {path}", file=sys.stderr)
return 2
print(json.dumps(scan(path, include_compactions=include_compactions), indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
</details>
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗