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>
3 Comments
Likely related observability gap for #28736.
#28736 has concrete repro showing duplicate compact events can later surface as stray
SessionStart(source=compact)hook firings on turns where no new compaction happened. Better compaction hook receipts would make this much easier to diagnose, because operators could correlate each delivered compact hook with exactly one real compaction event.One narrower implementation path: correlate the existing hook notifications with the compaction item/window that already exists, instead of adding a separate receipt stream.
Current main already has most of the pieces:
run_compact_task_innerrunsPreCompactbefore compaction andPostCompactonly after a successful compact.hook_runtimeemitsEventMsg::HookStartedandEventMsg::HookCompletedfor compact hooks.hook/startedandhook/completed.CompactedItemwithwindow_id,first_window_id, andprevious_window_id.ThreadItem::ContextCompaction { id }as the stable item whose started/completed ids must match.The missing piece looks like correlation plus replay durability.
HookRunSummaryhas run id, event name, source, status, timestamps, duration, and entries, but no compaction item/window correlation. Andthread_history.rsreconstructsContextCompactedwhile explicitly ignoringHookStarted/HookCompleted, so live notifications alone do not give durable inspection after resume/import.I would add one optional correlation object on compact hook runs, for example
compactionItemIdand/orwindowId, populated only forPreCompact/PostCompact. Then teach thread-history reconstruction to preserve/replay those hook events next to the compaction item.Regression shape: extend the existing v2 compaction test, configure one matching
PreCompacthook, trigger auto-compaction, assert oneContextCompactionitem and one hook receipt share the same correlation id, then restart/resume and assert the receipt is still reconstructable.---
_Generated with ax._
I have a stronger black-box result for the native dispatch path: configured
PreCompactwas not merely unobservable; it was not invoked by manual native compaction in two bounded runs.Environment at capture: installed Codex app-server
0.144.2, Apple Silicon macOS, temporaryCODEX_HOME, loopback-only Responses provider, and OS-enforced denial of non-loopback egress.For the corrected run, Codex started with
--dangerously-bypass-hook-trust, and nativehooks/listconfirmed enabledpreCompactandpostCompacthooks. Thenthread/compact/startemitted:item/startedforcontextCompactionitem/completedfor the same itemturn/completedfor the compaction turnBut it emitted no
hook/startedorhook/completedevent and created no compact-hook log. The run timed out once at the event predicate awaitingpreCompact. An independent second fixture reproduced the same lifecycle and absence, after which testing stopped.The owning contract should invoke configured
PreCompactbefore creatingcontextCompaction;continue:falsemust prevent mutation and preserve pre-cut history. Ifthread/compact/startintentionally bypasses hooks, the app-server should expose the exact token-budget/new-window entry point that honors the same current-receipt gate.A regression should prove hook-before-compaction ordering, a successful new-window cut, and a stale receipt yielding
continue:falsewith zero compaction item and unchanged history.