●COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devices●M365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint files●MCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first login●CODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hook●GOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environment●LIMITS — Claude Code weekly usage limits are increased by 50% through July 13●COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devices●M365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint files●MCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first login●CODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hook●GOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environment●LIMITS — Claude Code weekly usage limits are increased by 50% through July 13
Carrying Decisions Across Compaction with PreCompact and SessionEnd Hooks
Auto-compaction does not delete your conversation. It deletes the reasons behind it. Here is a working PreCompact / SessionEnd / SessionStart hook pipeline that rescues decisions to disk and hands them to the next session, with real code and measurements.
At 2 a.m. one of my unattended runs came back from a compaction and cheerfully proposed a design I had rejected two hours earlier.
The conversation had survived. The single line explaining why it was rejected had not.
Compaction trims for length, not for importance. What survives is what happened. What evaporates is why it happened. As an indie developer running four sites on overnight automation, that missing line turns into rework the next morning.
So I started rescuing decisions to disk right before compaction fires, and reading them back when the next session opens. Three Claude Code hooks do the whole job: PreCompact, SessionEnd, and SessionStart.
What compaction actually throws away
Auto-compaction fires as the context approaches its ceiling. It runs the same machinery as a manual /compact, except unattended runs do not get to choose the moment.
A six-hour generation session across four sites produced a 14.2 MB transcript here — roughly 3,900 JSONL lines. The summary left in context afterward was about 4,100 characters. That compression ratio is reasonable. What it did not include was the constraint "articles.json carries metadata only, because the Cloudflare Workers bundle ceiling is 62 MiB."
The summarizer was not at fault. I had simply never told anyone what had to survive.
Rather than negotiate with the summary, extract the decisions yourself and park them outside the context. That is the whole design.
What a PreCompact hook receives
PreCompact fires immediately before compaction runs, whether automatic or manual. Your script gets one JSON object on stdin.
trigger is either "auto" or "manual". It only reads "manual" when a human typed /compact. Unattended work is almost always "auto".
transcript_path is the absolute path to the session's JSONL history — one message per line, still in its pre-compaction state. This is the point of the exercise: miss PreCompact and the raw history is gone for good.
custom_instructions carries whatever you passed to a manual compact. It is an empty string on auto.
Hook
Fires when
Role in this pipeline
PreCompact
Just before compaction
Mine the raw transcript for decisions
SessionEnd
Session terminates
Take the final snapshot
SessionStart
Session opens or resumes
Inject the rescued decisions
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If long sessions keep forgetting constraints after every compaction, you can now rescue just the decisions with a PreCompact hook
✦You get complete Python for safely reading the transcript_path JSONL and restoring it through SessionStart additionalContext
✦You can sidestep the three traps I hit in production: the 60-second timeout, stdout leaking into context, and the trigger value everyone guesses wrong
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Copying the whole transcript somewhere else just relocates 14.2 MB. It has to shrink to something you can afford to re-inject.
My approach is deliberately naive: keep assistant lines that contain decision, constraint, or rejection markers. No embeddings, no extra model call. Hooks carry a 60-second budget, and reaching for a network round trip inside one is asking for trouble.
#!/usr/bin/env python3"""PreCompact: mine the transcript for decisions and append them to a handoff file.stdin: hook JSON (session_id, transcript_path, trigger, cwd)out: .claude/handoff/<session_id>.md"""import jsonimport reimport sysfrom pathlib import Path# Markers that signal a judgment call. Widening this set bloats the handoff,# so it stays intentionally narrow.MARKERS = re.compile( r"(decided|decision|constraint|because|instead of|do not|rejected|we will not)", re.IGNORECASE,)MAX_LINES = 60 # ceiling on what we re-inject laterMAX_CHARS_PER_LINE = 240def iter_assistant_text(transcript: Path): """Read JSONL line by line. Skip torn lines: the file is written while we read.""" with transcript.open(encoding="utf-8", errors="replace") as f: for raw in f: raw = raw.strip() if not raw: continue try: rec = json.loads(raw) except json.JSONDecodeError: continue if rec.get("type") != "assistant": continue content = rec.get("message", {}).get("content", []) if isinstance(content, str): yield content continue for block in content: if isinstance(block, dict) and block.get("type") == "text": yield block.get("text", "")def extract(transcript: Path) -> list[str]: seen, picked = set(), [] for text in iter_assistant_text(transcript): for line in text.splitlines(): line = line.strip(" -*#\t") if len(line) < 12 or not MARKERS.search(line): continue line = line[:MAX_CHARS_PER_LINE] if line in seen: # drop restatements of the same decision continue seen.add(line) picked.append(line) return picked[-MAX_LINES:] # newest decisions windef main() -> int: payload = json.load(sys.stdin) transcript = Path(payload["transcript_path"]) if not transcript.exists(): return 0 # nothing to rescue; exit quietly lines = extract(transcript) if not lines: return 0 out_dir = Path(payload.get("cwd", ".")) / ".claude" / "handoff" out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / f"{payload['session_id']}.md" with out.open("a", encoding="utf-8") as f: f.write(f"\n## compact ({payload.get('trigger', 'auto')})\n") for line in lines: f.write(f"- {line}\n") # stderr reaches the human, never the model's context. print(f"[handoff] {len(lines)} decisions -> {out}", file=sys.stderr) return 0if __name__ == "__main__": sys.exit(main())
The expected output in .claude/handoff/0b8f2c14-....md looks like this:
## compact (auto)- Decision: articles.json holds metadata only; body HTML is split into public/content/- Constraint: the Cloudflare Workers bundle ceiling is 62 MiB, so generated HTML cannot ship inside it- We will not inline article bodies into articles.json again; that was reverted once already
That 14.2 MB transcript became 5.8 KB. Re-injecting it costs almost nothing.
SessionEnd catches the short sessions
PreCompact never fires if compaction never happens. Short sessions would leave nothing behind, so the same script runs again on exit.
SessionEnd input carries a reason field: "clear", "logout", "prompt_input_exit", or "other".
Setting matcher to "auto" on PreCompact limits it to automatic compaction. When I type /compact myself I already know what I am discarding, so I skip the capture. SessionEnd takes no matcher.
Matcher comparison became strict for hyphenated strings in v2.1.195. If your hooks ever went silent after an update, read why hyphenated matchers stopped firing in v2.1.195 before you debug anything else.
Reading it back on SessionStart
The restore side returns a string through hookSpecificOutput.additionalContext, which is appended to the context of the next turn.
#!/usr/bin/env python3"""SessionStart: read the newest handoff and inject it as additionalContext."""import jsonimport sysfrom pathlib import PathMAX_BYTES = 8_000 # inject more than this and you are spending budget, not buying claritydef main() -> int: payload = json.load(sys.stdin) hand = Path(payload.get("cwd", ".")) / ".claude" / "handoff" files = sorted(hand.glob("*.md"), key=lambda p: p.stat().st_mtime) if not files: return 0 text = files[-1].read_text(encoding="utf-8")[-MAX_BYTES:] # JSON printed to stdout is interpreted by Claude Code. print(json.dumps({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": ( "Decisions confirmed in the previous session. " "Check here before proposing anything that contradicts them.\n\n" + text ), } })) return 0if __name__ == "__main__": sys.exit(main())
One warning. For SessionStart and UserPromptSubmit, plain text on stdout is also injected into context. Leave a stray print() in and every session opens with your debug noise. Send diagnostics to stderr. I once fed the model [debug] loaded 12 files at the top of every session for three days before noticing.
What actually changed
I ran the same six-hour, four-site job five times with the hooks and five times without.
Metric
Without hooks
With hooks
Runs where a constraint was lost after compaction (of 5)
4
0
Extra tokens to restore
—
~1,600
PreCompact hook runtime (median)
—
0.42 s
Handoff file size
—
5.8 KB
I scored "constraint was lost" by reviewing the morning logs for previously rejected designs resurfacing. It is not a rigorous benchmark, but it matches the disappearance of my rework.
The cost is roughly 1,600 tokens per session — well under a cent at Sonnet input pricing. Cheaper than thirty minutes of undoing things.
Three traps I fell into
Hooks time out at 60 seconds by default. My first version called a summarization API to condense the transcript. On a slow night it blew past the limit, the hook was killed, and that run's decisions vanished entirely. Now the hook does nothing a regular expression cannot do.
The transcript is read while it is being written. Roughly one run in twenty hits a torn final line and a json.JSONDecodeError. That is exactly why the code above swallows bad lines instead of raising. An unhandled exception exits non-zero and buys you a warning you do not need.
I assumed trigger was "compact". It is "auto" or "manual". A matcher of "compact" simply never matches, silently, with no error anywhere. When a hook does nothing, the fastest move is cat > /tmp/hook.json as the command and read the real payload. For exit codes and blocking semantics generally, see running hooks as a quality gate without breaking your pipeline.
The discipline is subtraction
Once this was running, I kept wanting to save more. Adding words to MARKERS felt free. Past about 200 lines the handoff drifts back toward the density of the summary it was meant to replace.
Keep only what the next session could not have re-derived. Anything discoverable by looking at the code can go. The reason a design was rejected can never go. That single test is what keeps a handoff from degrading into a worse copy of the summary.
Start smaller than the code above. Register one PreCompact hook whose command is cat > /tmp/precompact.json, wait for the next auto-compaction, and look at the payload yourself. Everything after that is shorter than you expect.
It took me several repeated mornings to arrive at this. If it saves someone else a few of them, that is enough. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.