PreCompact/PostCompact hook receipts are not observable around compaction events

Open 💬 3 comments Opened Jun 17, 2026 by mmashwani

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 PreCompact entries were present for three command hooks in <codex-home>/hooks.json
  • Related configured events in the same file: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, and SubagentStart

Reproduction / evidence

  1. Configure and trust PreCompact hooks in <codex-home>/hooks.json.
  2. 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 systemMessage field and exit code 0.
  3. Run a long Codex Desktop session that performs real compactions.
  4. 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_compacted events are recorded.
  • Trusted PreCompact hook 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 PreCompact hooks 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 PreCompact and PostCompact hook.
  • 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_compacted event;
  • the trace contains a preCompact hook 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 / PostCompact lifecycle hooks.
  • #23153 asks for hooks to request real compaction and also points at the need for receipts around compaction actions.
  • #22861 covers hook additionalContext spilling, 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>

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗