headspace's companion: answers the compactions headspace starts, replacing the marked tool results with one line each and summarizing nothing.

headspace's companion. Tested with Claude Code 2.1.287.
When you take large tool results out of the conversation in headspace, headspace starts a compaction and this plugin answers it: each marked result becomes one line, every other message stays exactly as it was, and nothing is summarized. No model call is made.
[headspace removed the result of Read src/data.json (9.8k tokens); run it again if you need it]
Claude Code never shows a plugin the compaction that plugin started itself; the engine would run its normal summary instead. So the answer lives here, and headspace lists headspace-cut under its dependencies: installing headspace installs this too. You do not install it on its own.
Only a compaction whose instructions start with headspace-cut:, started by headspace (trigger plugin) or typed by you after /compact (trigger manual). That second way is the fallback when the pane is not at hand:
/compact headspace-cut:{"marks":[{"id":"toolu_01AbC","label":"Read src/data.json (9.8k tokens)"}]}
id is the tool call's tool_use_id; label is what the stub line names.
Every other compaction, including an automatic one, passes on untouched to Claude Code's own summary.
When it cannot answer (the instructions do not parse past the prefix, or a marked result is no longer in the conversation), it skips the compaction and says why: the conversation stays as it is. It never falls back to a summary nobody asked for.
There is no undo: a removed result is gone from what Claude reads. Your prompts and Claude's answers are never touched; only tool results are.
You do not install it by hand: installing headspace installs it.
/plugin marketplace add Xabilimon1/emcc-claude-code-mods
/plugin install headspace@emcc
To try headspace without installing, load both folders: claude --plugin-dir mods/headspace --plugin-dir mods/headspace-cut. With headspace alone, the pane does not offer to remove results.
session.start (tells headspace it is there), turn.complete (a heartbeat at every main-loop turn end, so headspace stops offering removals if headspace-cut is turned off during the session), session.compact (only to answer headspace-cut: compactions; every other one passes on untouched)$.state.set, $.clock.nowclaude plugin test mods/headspace-cut
18 tests: reading the instructions and rebuilding the conversation (8), and in a simulated session: which compactions it answers, which it passes on, and when it skips, and the session start and heartbeat it leaves for headspace (10). Each one was seen failing before it passed.
hooks/register.ts 78 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { CUT_PREFIX, cutMessages, parseCut } from './cut'
4
5/**
6 * headspace-cut answers the compactions headspace starts (trigger `plugin`)
7 * or the person types (`/compact headspace-cut:{...}`, trigger `manual`): the
8 * marked tool results become one line each, every other message stays as the
9 * engine has it, and nothing is summarized. Anything else passes beneath.
10 *
11 * It never falls through to `next` once a request is recognised: that would
12 * summarize a conversation nobody asked to summarize. When it cannot answer,
13 * it skips, and the conversation stays as it is.
14 */
15/**
16 * The heartbeat: when headspace-cut last ran. headspace offers a removal only
17 * when it is no older than the last turn's start, so a headspace-cut disabled
18 * during the session stops being offered after the next turn.
19 */
20async function beat($: EngineInterface): Promise<void> {
21 try {
22 await $.state.set({ plugin: 'headspace-cut', key: 'seenAt' }, await $.clock.now())
23 } catch {
24 // headspace then reads the heartbeat as stale and does not offer to remove results.
25 }
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 try {
31 await $.state.set({ plugin: 'headspace-cut', key: 'ready' }, true)
32 } catch {
33 // headspace then reads it as not installed and does not offer to remove results.
34 }
35 await beat($)
36
37 return next(e)
38 })
39
40 on('turn.complete', async ($, e, next) => {
41 // A subagent's turn end is not the main loop's: headspace compares with the main loop's turns alone.
42 if (e.agentId === undefined) {
43 await beat($)
44 }
45
46 return next(e)
47 })
48
49 on('session.compact', async (_$, e, next) => {
50 const isOurs = (e.trigger === 'plugin' || e.trigger === 'manual') && (e.instructions ?? '').trim().startsWith(CUT_PREFIX)
51 if (!isOurs) {
52 return next(e)
53 }
54 const request = parseCut(e.instructions)
55 if (request === null) {
56 return { skip: `headspace-cut: the instructions after ${CUT_PREFIX} do not parse` }
57 }
58 if (request.marks.length === 0) {
59 // Every message as it was would still install a compaction boundary that changes nothing.
60 return { skip: 'headspace-cut: nothing marked' }
61 }
62 try {
63 // e.messages is the live conversation: the types do not say whether it opens at the last compaction,
64 // but on 2.1.287 a second /compact was handed the first one's summary first and nothing from before it.
65 const { messages, missing } = cutMessages(e.messages, request)
66 if (missing.length > 0) {
67 const count = missing.length === 1 ? '1 marked result is' : `${missing.length} marked results are`
68
69 return { skip: `headspace-cut: ${count} no longer in the conversation` }
70 }
71
72 return { messages }
73 } catch (error) {
74 return { skip: `headspace-cut: could not read the conversation (${error instanceof Error ? error.message : String(error)})` }
75 }
76 })
77}
78hooks/cut.ts 89 lines1/**
2 * The pure half of headspace-cut: reading the instructions headspace sends
3 * and rebuilding the conversation with the marked tool results stubbed.
4 */
5
6export const CUT_PREFIX = 'headspace-cut:'
7
8/** What headspace sends: the results to stub, each with the label its stub names. */
9export type CutRequest = { marks: { id: string; label: string }[] }
10
11/** One tool_result block as `session.compact` hands it, and as a rebuilt message carries it. */
12type ResultBlock = { tool_use_id: string; text: string; isError: boolean }
13
14/** Reads `instructions`; null when they are not a headspace-cut request or do not parse. */
15export function parseCut(instructions: string | undefined): CutRequest | null {
16 if (instructions === undefined) {
17 return null
18 }
19 const text = instructions.trim()
20 if (!text.startsWith(CUT_PREFIX)) {
21 return null
22 }
23 let parsed: unknown
24 try {
25 parsed = JSON.parse(text.slice(CUT_PREFIX.length))
26 } catch {
27 return null
28 }
29 if (typeof parsed !== 'object' || parsed === null) {
30 return null
31 }
32 const marks = (parsed as { marks?: unknown }).marks
33 if (!Array.isArray(marks)) {
34 return null
35 }
36 const out: CutRequest['marks'] = []
37 for (const mark of marks as unknown[]) {
38 if (typeof mark !== 'object' || mark === null) {
39 return null
40 }
41 const { id, label } = mark as { id?: unknown; label?: unknown }
42 if (typeof id !== 'string' || typeof label !== 'string') {
43 return null
44 }
45 out.push({ id, label })
46 }
47
48 return { marks: out }
49}
50
51/** The stub a removed result becomes. */
52export function stubText(label: string): string {
53 return `[headspace removed the result of ${label}; run it again if you need it]`
54}
55
56/**
57 * Every message as it was (same object, handle kept) except user messages
58 * holding a marked result: rebuilt without `handle` as { role, text, toolUses,
59 * toolResults }, the marked results' text replaced by the stub, the others kept.
60 * `missing` lists marked ids found in no message.
61 */
62export function cutMessages<M extends { role: string; text: string; toolUses: unknown[]; toolResults?: ResultBlock[]; handle?: string }>(
63 messages: readonly M[],
64 request: CutRequest,
65): { messages: (M | Omit<M, 'handle'>)[]; missing: string[] } {
66 const labels = new Map(request.marks.map(mark => [mark.id, mark.label]))
67 const found = new Set<string>()
68 const out = messages.map((message): M | Omit<M, 'handle'> => {
69 const results = message.toolResults
70 if (results === undefined || !results.some(one => labels.has(one.tool_use_id))) {
71 return message
72 }
73 const toolResults = results.map(one => {
74 const label = labels.get(one.tool_use_id)
75 if (label === undefined) {
76 return one
77 }
78 found.add(one.tool_use_id)
79
80 return { tool_use_id: one.tool_use_id, text: stubText(label), isError: one.isError }
81 })
82 const { handle: _handle, ...rest } = message
83
84 return { ...rest, toolResults }
85 })
86
87 return { messages: out, missing: request.marks.map(mark => mark.id).filter(id => !found.has(id)) }
88}
89types/index.d.ts 24 lines1/**
2 * headspace-cut's session state: whether it loaded, and when it last saw a
3 * turn end, so headspace offers to remove results only while this plugin is
4 * there to answer the compaction.
5 */
6
7/** What headspace reads to know headspace-cut is loaded and still running. */
8export type HeadspaceCutState = { ready: boolean; seenAt: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'headspace-cut': {
13 /** Written true at session start; unset means headspace-cut is not loaded. */
14 ready: boolean
15 /**
16 * Its heartbeat: `$.clock.now()` at session start and at every main-loop
17 * turn end. A value older than the last turn's start means it stopped
18 * running (disabled during the session), whatever `ready` says.
19 */
20 seenAt: number
21 }
22 }
23}
24