[BUG] 2.1.286 idle compaction silently discards working context in long-running sessions; no opt-out, logged as "manual"

Status Fixed / completed
Reported on v2.1.286
Maintainer reply ✓ Yes — blois
Activity 23 comments · opened Oct 1, 2026 · closed Oct 6, 2026
💡 Likely answer: A maintainer (blois, collaborator) responded on this thread — see the highlighted reply below.

Summary

Since 2.1.286, idle sessions are compacted automatically before the prompt cache expires ("Compacted while idle, before the prompt cache expired"). There is no opt-out and no warning. For long-running working sessions this discards the grounding the session was built on. I'd like to suggest that compaction stay user-initiated, or at least opt-in or announced in advance, except in the genuine emergency of hitting the context limit.

Environment

  • Claude Code 2.1.286, native install, macOS
  • Not seen on 2.1.285 or earlier

What happened

I keep roughly 20 Claude Code sessions open in parallel on long-running projects. Each one accumulates detailed working context over hours: the exact instructions I've given, decisions and their reasons, the methods we settled on, and in-flight state.

Overnight after updating to 2.1.286, 7 of those sessions were compacted while idle. None was close to the context limit. When I came back, each had a summary in place of its working context: my exact instructions, the reasoning behind decisions, and the methods we'd agreed on were gone from what the session actually sees. I had to rebuild the grounding by hand from the transcripts on disk.

I understand the cost logic (compacting before the cache expires avoids paying to reprocess a long uncached context on the next turn), and I can see it was requested in #66115. But for this way of working, the trade is backwards: the context is the valuable part, and a cache re-read is a cost I'd gladly pay to keep it.

Expected behavior

  • Compaction should never happen silently while the user is away, unless the session is at the hard context limit and has no other option.
  • If the cost-saving behavior stays, it should be opt-in, or at least announced (for example "this session will compact in N minutes unless you interact") with a one-keystroke way to decline.

Additional issues found

  1. Mislabelled trigger. These idle compactions are recorded in the transcript as compactMetadata.trigger: "manual", although I never ran /compact. That makes them indistinguishable from user-initiated compactions, which makes the behavior hard to diagnose.
  2. No documented hook. I couldn't find documentation confirming that PreCompact fires for idle compaction, which would let users save state first.

Suggested fixes

  1. A setting to disable idle compaction, for example "idleCompaction": false in settings.json, or an env var.
  2. A distinct trigger value (for example "idle") in compactMetadata.
  3. Documented PreCompact and SessionStart (compact) hook support for idle compaction, so state can be saved before it runs and restored after.

Related: #66115 (the feature request), #71254, #91836.

Thanks. The full transcripts remained on disk, which made recovery possible, and I appreciate that.

View original on GitHub ↗

23 Comments

jakob-thoms-sq1 · 12 days ago

+1, I'm seeing the same behaviour on 2.1.287 (macOS). I left a long-running working session idle over lunch and came back to "Compacted while idle, before the prompt cache expired". I never ran /compact and was nowhere near my compaction threshold, yet the transcript records it as trigger: "manual", exactly as described above.

I can understand why some users would want this to save on cache re-reads. But discarding a session's working context is a significant, invasive action, and it shouldn't happen silently while the user is away. Please make it opt-in, or at the very least provide a setting to disable it.

robmunger · 12 days ago

I had the same experience on Windows 11 with Claude 2.1.287. Having context history disappear may feel to some like a violation of an unwritten contract, especially if it's not clear how to recover it (Claude was able to recover my session by rebuilding the transcript, but it had to write a python script and the auto-mode classifier rightly blocked it for attempting to rewrite history).

blois collaborator · 12 days ago

Appreciate the feedback, setting is coming. Currently it's doing this only for larger contexts (200k+). Curious if there's a limit where you think this may be useful.

Also we need to get to the place where a compaction is less disruptive.

jakob-thoms-sq1 · 12 days ago

@blois Thanks, glad to hear a setting is coming.

Currently it's doing this only for larger contexts (200k+). Curious if there's a limit where you think this may be useful.

Suggest making this user-configurable, like the existing autoCompactWindow setting. There's likely no "one-size-fits-all" value that everyone would agree on.

John-Rood · 11 days ago

The hook question has a direct answer, tested on 2.1.287: idle compaction does fire PreCompact, and it fires as trigger: "manual". PostCompact gets "manual" too. That is the same missing value behind your finding 1, not a second bug.

Mechanism: when the idle timer decides to compact, it calls the same routine a /compact uses and passes no trigger, and that routine defaults the trigger to manual before it builds the transcript boundary and the hook input. Confirmed in a local 2.1.287 run of that routine: PreCompact arrived as {"trigger":"manual","custom_instructions":null}, SessionStart fired with "source":"compact", PostCompact arrived with "trigger":"manual" and the full summary, and the boundary landed with "compactMetadata":{"trigger":"manual","preTokens":21280,...}. So a PreCompact hook written today will see idle compactions. It cannot tell them apart from your own /compact.

The hooks reference defines the PreCompact matchers as manual = /compact and auto = auto-compact window. Idle compaction matches neither definition and uses manual. So ask 3 is smaller than it looks: the hooks do fire, and the transcript boundary, the hook payloads, and the metadata are all wrong in the same direction.

On when it fires, from the 2.1.287 binary: the idle timer only arms when the session's prompt cache TTL is 1 hour, and it fires at 90 percent of the way from your last activity to the cache expiry, so roughly 54 minutes of quiet. It considers contexts of 200k tokens or more (the server can lower that, floor is 100k). It skips when the fire is more than 60 seconds late, when the cache went cold, when a newer request landed, when another process is holding the session, or when rate-limit state is not allowed. It ships behind a server-side gate with off, log and compact modes, which is why there is no release note and no setting yet.

Save and restore, run end to end on 2.1.287. In .claude/settings.json:

{
  "hooks": {
    "PreCompact": [
      {"hooks": [{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/save-working-context.sh", "timeout": 15}]}
    ],
    "SessionStart": [
      {"matcher": "compact", "hooks": [{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/restore-working-context.sh", "timeout": 15}]}
    ]
  }
}

.claude/hooks/save-working-context.sh (needs jq):

#!/bin/bash
# PreCompact: snapshot working context before any compaction.
set -u
IN=$(cat)
TP=$(printf '%s' "$IN" | jq -r '.transcript_path // empty')
OUT="${TMPDIR:-/tmp}/cc-handoff.md"
[ -n "$TP" ] && [ -f "$TP" ] || exit 0
{
  echo "# Working context snapshot ($(date -u +%Y-%m-%dT%H:%MZ), trigger: $(printf '%s' "$IN" | jq -r '.trigger'))"
  echo
  echo "## Recent user prompts"
  jq -rs '[.[] | select(.type=="user") | .message.content
           | if type=="string" then . else ([.[] | select(.type=="text") | .text] | join(" ")) end
           | select(length>0 and (startswith("<") | not) and (startswith("This session is being continued") | not))]
          | .[-20:] | .[] | "- " + (.[0:400])' "$TP" 2>/dev/null
  echo
  echo "## Files edited this session"
  jq -rs '[.[] | select(.type=="assistant") | .message.content
           | if type=="array" then .[] else empty end
           | select(.type=="tool_use" and (.name=="Edit" or .name=="Write" or .name=="NotebookEdit"))
           | .input.file_path] | unique | .[] | "- " + .' "$TP" 2>/dev/null
} > "$OUT"

.claude/hooks/restore-working-context.sh:

#!/bin/bash
# SessionStart (matcher: compact): re-inject the snapshot.
set -u
cat >/dev/null
OUT="${TMPDIR:-/tmp}/cc-handoff.md"
[ -s "$OUT" ] || exit 0
CTX=$(head -c 8000 "$OUT")
jq -cn --arg c "$CTX" '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: ("Handoff snapshot saved at the last compaction:\n" + $c)}}'

What I measured: PreCompact wrote the snapshot, the compaction ran, SessionStart handed it back, and the transcript shows it entering context as a hook_additional_context attachment, so it is in the model's view on the next turn. The restore output sits under the 10,000 character hook-output cap (the script caps at 8,000). The pair is trigger-agnostic, which is what you want while the label is wrong.

For diagnosing the ones that already happened, this flags every boundary that no /compact command explains, and prints how quiet the session was:

#!/usr/bin/env python3
"""python3 idle-scan.py <transcript.jsonl>: flag compactions with no /compact marker."""
import json, sys
from datetime import datetime

def text_of(m):
    c = (m.get("message") or {}).get("content")
    if isinstance(c, str): return c
    if isinstance(c, list): return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
    return ""

path = sys.argv[1]
rows = [json.loads(l) for l in open(path, errors="ignore") if l.strip()]
for i, d in enumerate(rows):
    if d.get("subtype") != "compact_boundary":
        continue
    near = "\n".join(text_of(m) for m in rows[max(0, i-5):i+25])
    meta = d.get("compactMetadata") or {}
    prev = next((m for m in reversed(rows[:i]) if m.get("timestamp")), None)
    gap = 0.0
    if prev:
        t1 = datetime.fromisoformat(d["timestamp"].replace("Z", "+00:00"))
        t2 = datetime.fromisoformat(prev["timestamp"].replace("Z", "+00:00"))
        gap = (t1 - t2).total_seconds() / 60
    label = ("user /compact" if "<command-name>/compact" in near
             else "IDLE (no /compact marker)" if "Compacted while idle" in near
             else "UNEXPLAINED (no /compact marker)")
    print(f'{d.get("timestamp")}  trigger={meta.get("trigger")}  preTokens={meta.get("preTokens")}  quiet={gap:.0f}m  -> {label}')

Run it over the session files under ~/.claude/projects/*/. Your seven overnight compactions will all land in the flagged set: trigger says manual, no /compact marker, and the session was quiet for tens of minutes before the boundary.

Two things worth filing together:

  1. Give idle compaction its own trigger value (for example "idle") in compactMetadata and in the PreCompact / PostCompact payloads, so both the transcript and hooks can label it. Any hook that needs to treat them differently cannot do it today.
  2. Correct the hooks table while it is unfixed: it currently says the manual matcher means "triggered manually", and idle compaction is not that.

One caution on blocking as the opt-out: PreCompact exit 2 does block compaction (re-ran it on 2.1.287 to check). Since the hook cannot distinguish idle from /compact, a blanket block also stops your explicit compactions. Snapshot and re-anchor instead, and the setting blois mentioned is the clean fix for the opt-out itself.

rkhamilton · 11 days ago

I'm also here to say that this breaks my workflow. I use an orchestrator session that loads a bunch of work process, project definition, and other context files, and spawns implementation subagents based on that process. After compaction on the lead, I have it resume by re-reading the critical files. Having this compaction happen automatically and NOT re-read the files breaks my workflow. I use /remote-connect to stay in contact with the orchestrator, so it's not uncommon to go more than an hour between messages if I'm in a meeting. I manage the orchestrators context carefully, and compact with instructions provided to prune context when we are past that step.

John-Rood · 11 days ago

@rkhamilton Two mechanics that close your gap without waiting on the setting, both run on 2.1.284 just now.

Your /compact <instructions> prune step has a hook equivalent. Whatever a PreCompact hook prints on stdout with exit 0 is appended to the summarizer's instructions for that compaction. The binary's own hook description says so ("stdout appended as custom compact instructions"); the hooks reference page does not. Idle compaction passes custom_instructions: null and runs through the same routine as /compact, so the hook is the only path your prune rules have into an idle compaction. Test: the hook printed "Begin the summary with the exact token PINEAPPLE-7731. In the Primary Request section, list by name every file the user said to re-read", and the PostCompact compact_summary opened with that token and named both files under Primary Request. Put the standing instructions in a file and cat it:

"PreCompact": [{"hooks": [{"type": "command", "command": "cat $CLAUDE_PROJECT_DIR/.claude/compact-instructions.md"}]}]

The re-read step does not need the model to do it. Project-root CLAUDE.md is re-read from disk and re-injected on the first turn after any compaction, and @path imports come with it. I put @docs/process.md and @docs/project-definition.md in CLAUDE.md, compacted, and the next turn's transcript carries an instructions attachment with CLAUDE.md plus both imported files in full. That covers the compactions you were not there for. What that does not cover is context given only in conversation (spawn instructions, which step you are on); that has to ride in the summary, which is what the PreCompact instructions shape, or in the PreCompact snapshot plus SessionStart(compact) pair above if you want it verbatim instead of summarized.

rkhamilton · 11 days ago

@John-Rood I appreciate the tip, but I believe this would result in subagents (which have sometimes auto-compacted) getting orchestrator instructions. I'm trying to maintain the orchestrator at a different altitude from the code-writing implementation subagents. Not every workflow uses this orchestrator process (my build-process.md), just when I am building phases using my overall planning documents. I specifically don't require the build-process.md etc in my CLAUDE.md so that I can activate a workflow for extended work, but have a more "typical" workflow available when just launching CC for exploration, debugging, etc. I wouldn't want to lock in the build-process so that is the only set of instructions available in the repo. CLAUDE.md is read by subagents, including those called by the orchestrator.

I could imagine that the PreCompact hook could theoretically specify "IF you have read project-definition.md [or other high altitude orchestrator files] you must retain them in full after compaction". If that were viable, then subagents would not be asked to read files outside their concerns, and orchestrators would not forget their core concerns, but I've never tried that. My assumption was that no file like that can be full retained intact after compaction.

robmunger · 11 days ago

This has hit me twice more. It's pretty intolerable. Very intrusive and paternalistic by Anthropic. Sometimes we come back to projects after an hour and we don't want to see our work destroyed by "compaction." I'm pretty surprised this was rolled out without an opt-in. Disrespectful to developers.

rkhamilton · 11 days ago
This has hit me twice more. It's pretty intolerable. Very intrusive and paternalistic by Anthropic. Sometimes we come back to projects after an hour and we don't want to see our work destroyed by "compaction." I'm pretty surprised this was rolled out without an opt-in. Disrespectful to developers.

I've just disabled auto-compaction entirely, which seems to prevent the behavior. I agree that it's intolerable. I can't work with this enabled.

John-Rood · 10 days ago

You're right on CLAUDE.md. It loads into every subagent except Explore and Plan, so the @import route puts build-process.md in front of the implementers too.

The PreCompact gate isn't available either. In the 2.1.284 binary the PreCompact input is built from the session alone: session_id, transcript_path, cwd, trigger, custom_instructions. The agent_id field that other hook events carry inside a subagent is not set on this one, so a hook cannot tell a subagent's compaction from the lead's. And a compaction instruction is guidance to the summarizer, not a retention channel. Nothing makes a file survive compaction verbatim; the post-compact re-read covers at most five recently touched files and swaps anything over 5,000 tokens for a path reference.

The layer that holds your altitude split is the system prompt. claude --append-system-prompt-file build-process.md adds the orchestrator rules to the lead's system prompt for that launch only; plain claude stays your typical workflow. The system prompt is not part of what gets summarized, so it is intact after every compaction, including the idle one. Subagents never see it: a subagent's system prompt is its own definition plus environment details, not the Claude Code system prompt, and they still get CLAUDE.md, which is where you want it.

One catch. The prompt is recorded on the first request and reused until the next compaction, after which the current launch's flags win. Pass the flag on every launch of a build session, --resume included, or the rules drop out at the first compaction after a resume without it.

jimmc414 · 9 days ago

+1, still happening on 2.1.289 (native install, WSL2 Ubuntu, Max subscription, so the 1-hour TTL). A finished session I had walked away from came back with "Compacted while idle, before the prompt cache expired".

My ask is specifically a separate toggle from autoCompactEnabled. In the 2.1.289 binary the idle path's pre-fire check reads autoCompactEnabled (its refusal reason is compaction_off), so today the only way to stop idle compaction is to turn off threshold auto-compaction too. Those are two different features bundled under one setting:

  • Threshold auto-compact, when the context approaches the limit: I want this on. Hitting the wall mid-task is worse than a summary.
  • Idle compaction, timed to the cache TTL: I want this off. If I stepped away, the context is the valuable part and a cold cache read is a price I'll pay.

Something like "idleCompactEnabled": false next to the existing autoCompactEnabled, with autoCompactEnabled continuing to mean only the token-threshold trigger. A configurable minimum context size would be welcome too, but it isn't a substitute for the toggle.

blois collaborator · 9 days ago

Version 2.1.290 now has a idleCompaction setting to allow opting out, and the trigger is now 'auto'.

John-Rood · 9 days ago

Read the 2.1.290 binary to see what the setting does, since the people above asked for specifics.

"idleCompaction": false in any settings.json (user, project, local or managed) turns the idle path off on its own; the pre-fire check returns idle_compaction_off. autoCompactEnabled is still checked first, so false there turns idle off as well, but the combination @jimmc414 wants works: autoCompactEnabled true, idleCompaction false, threshold compaction stays on and idle never fires. Setting idleCompaction to true does nothing extra; the server-side gate still decides whether the feature is on at all.

The minimum context size is settable in 2.1.290 too, which nothing in the thread mentions yet: CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS. Whole number, default 200000, floor 100000 (lower values are raised to it). The 90 percent fire point and the 1 hour TTL requirement are unchanged.

One thing the trigger change does not give you. The idle path now enters the compaction routine as auto, so the PreCompact and PostCompact payloads and compactMetadata.trigger all read auto, the same value threshold auto-compaction uses. A hook can now tell idle from /compact, and the auto matcher catches it, but it still cannot tell idle from a context-limit compaction, and the on-screen "Compacted while idle" notice is not part of any hook input. A distinct idle value remains the only way a hook gets that.

natefosterwarner · 8 days ago

Thanks @blois, confirmed "idleCompaction": false on 2.1.291 fixes this for me, so I'm closing as completed. Thanks also @John-Rood for the binary-level breakdown. The remaining ask (a distinct idle trigger value so hooks can tell idle from threshold compaction) seems best tracked as its own issue if anyone wants to pick it up.

ashtonthomas · 7 days ago

I hope we don't see more features and defaults like this rolled out - that has been very disruptive and frustrating

tylerlaprade · 7 days ago

@blois, I like this feature a lot, and I had been planning on building it asa plugin or something, so thank you! I'd like to configure the threshold, though. You asked at what limit we'd find this useful - for me it'd be 500K-600K, rather than 200K. I would want to turn it on but not for smaller conversations.

jlacroix42 · 6 days ago

Its incomprehensible for me how you can ship and enable a destructive feature like this, by default, to your end users. Without asking for any consent and ask if it even fits their use case.

AxentisLabs · 5 days ago

+1 from a heavy daily user. We measured 9 of 9 auto-compactions at exactly 55–56 min after the last response, independent of fill level. "idleCompaction": false is the right escape hatch, thanks for that.

But for power users a stable, full context is the core requirement. Opt-in is fine; opt-out discovered through an issue thread is not. Please (1) make idle compaction opt-in, or at least announce it in the changelog and docs, (2) document idleCompaction and CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, and (3) give hooks a distinct idle trigger.

Please stay the reliable partner you've been — we love Claude Code.

theblinkingusb · 5 days ago

+1 agree with @AxentisLabs
More generally - there's so many different conditions I feel it's time for settings profiles or some hierarchical organization. If I was a "hands-on" profile user this idleCompaction would never be a problem for me, it would have defaulted false. If I was "default" or "new-developer" this could be default on. One is sort of able to do this with settings files but it still gets weird when CC behavior changes or settings get added. It would be nice if CC bucketed settings up front for those profiles. Then my settings file could be something like, profiles: ["hands-on", "privacy", {overrides: [override_1]}] (last one wins).

We're at 50k words in the settings reference, 3.5 hrs of reading lol. idleCompaction isn't in those docs. Claude platform docs don't mention it either (at least in the ask docs feature). Over the last few weeks, we're spending more time debugging CC set ups due to changes, and the AI itself misguides behavior on keys, their existence and how to set them.

saevarb · 4 days ago

Seriously - why does this keep happening? Why do you guys keep shipping completely whack features that make destructive or disruptive changes to people's workflows, enabled by default, and often without an option to disable it - until maybe weeks later? Who is making product decisions at Anthropic? Does anybody at anthropic even know what they are shipping to their users?

EDIT: And without documentation, too. There's still no documentation for it.

jonstewart · 2 days ago

I'm glad to discover there's an option, but this should never have been opt-out by default, certainly not without a warning period of a few weeks with reminders. It's happened to several sessions now, just insanely disruptive.

kengulick · 1 day ago

+1 on this. I'm running Opus 4.6 on 1M context sessions specifically so I have room for long, complex work -- multi-file framework maintenance, Excel workbook manipulation, portal generation, and browser validation all in one session. The whole point of paying for 1M context is having that space available.

I started seeing "Compacted while idle, before the prompt cache expired" today -- twice in the same day. I stepped away from active sessions (lunch, meetings) and came back to find my context had been silently compressed. No warning, no prompt, no opt-out.

This is the wrong tradeoff for users on large context windows. If I'm at 25% of a 1M window, there's no pressure to compact -- I have 750K tokens of headroom. The cost savings from compacting on warm cache vs. cold are negligible compared to the value of preserving full working context for when I come back.

What I'd like to see:

  1. A separate toggle for idle compaction, independent of threshold-based auto-compact
  2. At minimum, respect a "disabled" auto-compact setting -- if I've turned it off, idle compaction should also be off
  3. Consider context utilization before triggering -- compacting a session at 25% usage on a 1M window makes no sense

I shouldn't have to race back to my terminal to prevent my session from being summarized while I'm getting coffee.