[BUG] session.compact mod hook no longer raised in 2.1.299 (manual and automatic compaction)

Status Open
Reported on v2.1.299
Maintainer reply ✓ Yes — 7487
Activity 3 comments · opened Oct 14, 2026
💡 Likely answer: A maintainer (7487, contributor) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Since 2.1.299, a mod's session.compact hook never runs. Neither /compact nor automatic compaction raises it, but the compaction itself still happens: the classic SessionStart hook with source: "compact" still fires when it ends. Other hooks in the same mod (turn.start, command.run) still run normally.

The 2.1.299 changelog doesn't mention any change to session.compact, so this looks like a regression.

Versions

  • Broken: 2.1.299
  • Last known working: 2.1.296. On that version session.compact ran before every manual and automatic compaction, with trigger set to manual or auto.
  • macOS (Darwin 25), terminal, fullscreen TUI

What Should Happen?

Expected (as on 2.1.296): a session.compact post with trigger: "manual" before the compaction.

Actual (2.1.299): the screen shows ⎿ Compacted (ctrl+o to see full summary), but the listener prints only:

listening on /tmp/compactprobe.sock
18:17:08 {"event":"turn.start"}
18:17:33 {"event":"command.run"}

turn.start comes from the prompt and command.run from /compact. session.compact never arrives.

Automatic compaction behaves the same way. Forcing it early with CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=5 compacted three times in one turn, with three classic SessionStart source=compact events and no session.compact.

Impact

Tools that read a session's state from hooks lose the start of a compaction. An automatic compaction inside a turn now shows up only as SessionStart source=compact, so the session looks idle while it is still in the middle of a turn. Mods that customise compaction through this hook (see #101879, #101096, #95328) would presumably stop running too.

Error Messages/Logs

Steps to Reproduce

Repro

A plugin with a hooks module that reports three events to a local unix socket:

.claude-plugin/plugin.json

{ "name": "compactprobe", "version": "0.0.1", "description": "probe" }

hooks/hooks.json

{ "modules": ["./register.js"] }

hooks/register.js

async function post($, data) {
  await $.http.fetch('http://probe/', {
    method: 'POST',
    socketPath: '/tmp/compactprobe.sock',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(data),
  })
}

export function register(on) {
  on('turn.start', async ($, e, next) => { await post($, { event: 'turn.start' }); return next(e) })
  on('command.run', async ($, e, next) => { await post($, { event: 'command.run' }); return next(e) })
  on('session.compact', async ($, e, next) => { await post($, { event: 'session.compact', trigger: e.trigger }); return next(e) })
}

listen.mjs, a listener that prints each post and answers it:

import http from 'node:http'
import fs from 'node:fs'
const sock = '/tmp/compactprobe.sock'
fs.rmSync(sock, { force: true })
http.createServer((req, res) => {
  let body = ''
  req.on('data', (c) => (body += c))
  req.on('end', () => { console.log(new Date().toISOString().slice(11, 19), body); res.end('ok') })
}).listen(sock, () => console.log('listening on', sock))
  1. In one terminal: node listen.mjs
  2. In another: claude --plugin-dir <path-to-plugin> --model haiku
  3. Send one prompt (Reply with one word: hello), then run /compact.

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.296

Claude Code Version

2.1.299

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

3 Comments

7487 contributor · 8 hours ago

There is an intentional user-tier bypass worth checking before classifying this as an event-emission regression.

In the public sec-default implementation at commit 94506ff0d3bd9185f1752da36e968775021fb48f, session.compact continues directly to the append tier:

on('session.compact', ($, e, next) => next.to(e, 'append'))

The event policy says that user-tier compaction hooks do not run when this built-in is seated. It protects the resulting conversation and summary. turn.* and command.run still pass, so your other two probe events firing does not rule this out. Even a listener that only observes session.compact is skipped by this rule.

The seating conditions include managed settings / managed MCP configuration, or a Team/Enterprise organization, subject to the managed prepend-plugin configuration.

Could you confirm whether sec-default@builtin is active in the affected session, and whether the 2.1.296 and 2.1.299 runs used the same organization and managed configuration? A sanitized indication of the loaded built-ins would help; credentials and configuration contents are unnecessary.

This is a source-level diagnostic, not an independent runtime reproduction. It does not establish why the behavior changed between those versions. If the built-in is absent, the engine's dispatch path still needs investigation; if it is present, distinguishing policy suppression from a missing event would make the diagnosis clearer.

RegiByte · 7 hours ago

Thanks, that was it. sec-default is seated in the affected sessions, so this is the policy hold and not a dispatch regression.

Seating. The account is in a Team/Enterprise organization with server-delivered managed settings. There is no managed settings file on the machine and no prependPlugins. The 2.1.296 and 2.1.299 runs used the same machine and sign-in.

Runtime check (2.1.300, same repro). I added user-tier hooks on two more events the README says sec-default holds (settings.read, session.append), plus classic PreCompact/SessionStart hooks in the same plugin's hooks.json. One prompt, then /compact:

SessionStart        source=startup   (classic)
session.append      door=prompt
turn.start
session.append      door=response
session.append      door=notice
session.append      door=note
command.run
PreCompact          trigger=manual   (classic)
SessionStart        source=compact   (classic)

The compaction happened, but the user hook received no session.compact, no session.append from the compaction door, and no settings.read at all. Rows from the prompt, response, notice and note doors still reached it. That matches the README's rows for a seated sec-default.

Why it changed between versions. The session.compact row (register.ts L58 and its README row) first appears in mods/ in 3deb873687 (2026-10-11), after v2.1.296 (2026-10-09) and before v2.1.299 (2026-10-14). The seat didn't change; the hold arrived with it.

What's left, if useful:

  1. The 2.1.299 changelog doesn't mention it. For anyone in an organization, a mod's session.compact hook silently stops running, and that includes hooks that only observe.
  2. Could an observe-only hook still run, with its answer ignored? The hold protects what a compaction leaves behind. Seeing that a compaction started doesn't threaten that.
  3. For anyone else who lands here: the classic PreCompact hook still fires with trigger, so we're moving our start-of-compaction signal to it.

Happy to close this as intended behaviour if (2) isn't on the table. Thanks again for the help!

7487 contributor · 5 hours ago

Thanks for checking the active built-ins and adding the 2.1.300 trace. That resolves the dispatch question for the tested configuration.

I independently checked the public source history: v2.1.296 has no session.compact hold; 3deb873687 adds it, and v2.1.299 line 58 contains it. The commit's parent is the 2.1.296 tag. This source comparison supports your version boundary; the runtime confirmation is yours.

Your classic PreCompact route matches the public input contract for a manual/auto start signal. It should not be assumed to cover every session.compact trigger, which also includes plugin and precompute.

For the remaining observation request, a separate lifecycle notification carrying trigger and agentId, without the conversation or a replacement result, would be narrower than running the existing replacement hook and discarding its answer. Discarding a return value alone does not restrict the handler's API calls. The existing summary/conversation hold should remain intact; the dispatcher would need to provide the observation contract.

A concrete release-note addition for maintainer consideration:

When sec-default is active, user-tier session.compact hooks are skipped, including listeners. For manual/auto start notifications, use classic PreCompact hooks.

I have not independently rerun compaction or implemented a new observation event. Whether to add that event or change the release notes remains a maintainer decision.