Compaction bookmark: git state, last prompts and /compact instructions kept in session state and appended to the compacted conversation

A compaction bookmark kept in session state instead of a file. It stands in for file-based PreCompact and SessionStart compact|resume hooks that write a bookmark file and read it back after compaction.
Nothing in it is specific to one repository: it reads git state and the conversation, and depends on no other mod.
manual, auto or plugin compaction of the main conversation, it records the branch, git log --oneline -5, git status --porcelain (first 40 entries, then a count of the rest), the last three prompts and the /compact instructions into $.state['session-bookmark'].bookmark. $.state outlives compactions and mod reloads.POST-COMPACT BOOKMARK (session-bookmark). The model reads it on the next request, so a mid-turn auto-compaction is covered too, and a resumed transcript already holds it.CLAUDE_PROJECT_DIR. Git runs beside the summarizer (GIT_OPTIONAL_LOCKS=0, 5 s timeout per command); when core reuses a precomputed summary, the git calls are the compaction's only wait. A repository with no commits yet still reports its branch and status. When neither folder is a repository, or git is missing, the git fields are null and the prompts are still recorded.| Name | Does |
|---|---|
/session-bookmark | Prints the bookmark held in this session's state, or says none is held. $.state is promised to survive a reload, not a resume, so a resumed session can answer none while its transcript still carries a bookmark message. |
$.state['session-bookmark'] (contract: types/index.d.ts):
| Key | Type | Meaning | |
|---|---|---|---|
bookmark | `Bookmark \ | null` | Latest compaction bookmark: time, trigger, root, branch, log, status, last prompts, instructions. |
$.state is per session: the bookmark reaches a new process only as the message already in the compacted transcript. Only the latest bookmark is kept; the old history.log has no equivalent.claude -p run there, ignores those settings and compacts with no bookmark.CLAUDE_CODE_PLUGIN_DIRS entry points at one checkout and replaces the marketplace copy, so with it set, which mod code loads follows that checkout's branch, not the session worktree's.claude plugin test runs in a Linux sandbox; Windows paths are passed to git as given.claude plugin validate <mod folder>
claude plugin test <mod folder>
tsc -p <mod folder> # once the engine has generated .claude-plugin/types/ by loading the mod
Install from the operator-straps marketplace:
claude plugin marketplace add t968rs/operator-straps
claude plugin install session-bookmark@operator-straps
For development, a CLAUDE_CODE_PLUGIN_DIRS entry in the env block of the user-level ~/.claude/settings.json (or claude --plugin-dir <folder> for one session) loads the folder with hot reload and replaces the marketplace copy of the same-named mod, so it never runs twice. The variable is one string, ;-separated on Windows: append to its existing value rather than replacing it. The project's settings are not read for this variable.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\path\\to\\operator-straps\\plugins\\session-bookmark;C:\\path\\to\\operator-straps\\plugins\\pr-review-watch;C:\\path\\to\\operator-straps\\plugins\\git-status"
}
}hooks/register.ts 8 lines1import type { Register } from 'claude-code'
2
3import { registerBookmark } from './bookmark'
4
5export const register: Register = on => {
6 registerBookmark(on)
7}
8hooks/bookmark.ts 207 lines1import type {
2 EngineInterface,
3 Register,
4 SessionCompactInput,
5 SessionCompactResult,
6 SessionMessage,
7} from 'claude-code'
8import { atom, read, update } from 'claude-code'
9
10import type { Bookmark } from '../types'
11
12/**
13 * Compaction bookmark kept in `$.state` instead of a bookmark file.
14 *
15 * Stands in for file-based `PreCompact` and `SessionStart` compact|resume bookmark hooks. The bookmark
16 * is written to `$.state`, which outlives compaction and
17 * module reloads, and is appended to the compacted conversation as its last message, so the model reads it
18 * on the very next request, mid-turn auto-compactions included, and a resumed transcript carries it.
19 * Every failure path degrades to the compaction as core answered it; the hook never throws past `next`.
20 */
21
22type Dollar = EngineInterface
23
24const bookmarkState = atom({ plugin: 'session-bookmark', key: 'bookmark' } as const, null)
25
26/** Opening line of every bookmark message; also how a later compaction recognises one. */
27export const BOOKMARK_HEADER = 'POST-COMPACT BOOKMARK (session-bookmark)'
28/** Longest stored prompt or instruction text, in characters. */
29export const MAX_MESSAGE_CHARS = 2000
30/** Most porcelain status entries kept; the rest are counted, not listed. */
31export const MAX_STATUS_LINES = 40
32/** The `/session-bookmark` reply when this session's state holds no bookmark. */
33export const NO_BOOKMARK_TEXT =
34 "No compaction bookmark in this session's state. A resumed session's transcript may still carry one."
35const MESSAGE_COUNT = 3
36const GIT_TIMEOUT_MS = 5000
37const COMMAND = 'session-bookmark'
38/** Triggers that install a compaction (precompute installs nothing). */
39const INSTALLING = { trigger: ['manual', 'auto', 'plugin'] } as const
40
41const RESUME_HINT =
42 'The content below is the authoritative resume point. Read the LAST user message recorded here and ' +
43 'continue from there, not from tool-result replays rehydrated above it. Run `git status` and ' +
44 '`git log -3` before acting: commits may have landed during compaction.'
45
46// ---------------------------------------------------------------------------
47// Pure
48
49/**
50 * Whether a message counts as a prompt: a user message with text, no tool results, not a bookmark. A
51 * previous compaction's summary is a user message too, so it counts.
52 */
53const isPrompt = (m: SessionMessage): boolean =>
54 m.role === 'user' &&
55 (m.toolResults ?? []).length === 0 &&
56 m.text.trim() !== '' &&
57 !m.text.startsWith(BOOKMARK_HEADER)
58
59/** The last `n` prompts (an earlier summary included), newest last. */
60export const lastUserTexts = (messages: readonly SessionMessage[], n = MESSAGE_COUNT): string[] =>
61 messages.filter(isPrompt).slice(-n).map(m => m.text.trim())
62
63/** Cuts text past MAX_MESSAGE_CHARS, keeping the start. */
64const truncate = (text: string): string =>
65 text.length <= MAX_MESSAGE_CHARS ? text : `${text.slice(0, MAX_MESSAGE_CHARS)}\n...(truncated)`
66
67/** Keeps the first MAX_STATUS_LINES porcelain entries and counts the rest. */
68const capStatus = (status: string): string => {
69 const lines = status.split('\n')
70 if (lines.length <= MAX_STATUS_LINES) return status
71 const rest = lines.length - MAX_STATUS_LINES
72 return [...lines.slice(0, MAX_STATUS_LINES), `... (${rest} more entries)`].join('\n')
73}
74
75/** A fenced block; four backticks so a prompt holding a triple fence stays inside. */
76const fence = (body: string): string[] => ['````', body, '````', '']
77
78/** The prompt section's lines. */
79const messageLines = (messages: readonly string[]): string[] => {
80 if (messages.length === 0) return ['(no user messages recovered)', '']
81 return messages.flatMap((m, i) => [`### Message ${i + 1}`, '', ...fence(truncate(m))])
82}
83
84/** The bookmark as the message the model reads after the compaction. */
85export const formatBookmark = (b: Bookmark): string => {
86 const lines = [
87 `${BOOKMARK_HEADER}. ${RESUME_HINT}`,
88 '',
89 `- Saved: ${new Date(b.savedAt).toISOString()}`,
90 `- Trigger: ${b.trigger}`,
91 `- Branch: ${b.branch === null ? '(unknown)' : `\`${b.branch}\``}`,
92 `- Root: ${b.root === null ? '(unknown)' : `\`${b.root}\``}`,
93 '',
94 '## Recent Commits',
95 '',
96 ...fence(b.log ?? '(git unavailable)'),
97 '## Working Tree (git status --porcelain)',
98 '',
99 ...fence(b.status === null ? '(git unavailable)' : b.status || '(clean)'),
100 '## Last User Messages (verbatim, newest last)',
101 '',
102 ...messageLines(b.lastMessages),
103 ]
104 if (b.instructions !== null) lines.push('## Compaction Instructions', '', ...fence(truncate(b.instructions)))
105 return lines.join('\n').trimEnd()
106}
107
108// ---------------------------------------------------------------------------
109// Host access (kept in this file: `$` is not passed across an import)
110
111/**
112 * Folders to read git from, in order: the session cwd (a worktree the session moved into), then
113 * CLAUDE_PROJECT_DIR; blanks and repeats dropped. Never throws.
114 */
115const rootCandidates = async ($: Dollar): Promise<string[]> => {
116 const cwd = await $.session.cwd().catch(() => undefined)
117 const env = await $.env.get('CLAUDE_PROJECT_DIR').catch(() => undefined)
118 const named = [cwd, env].filter((p): p is string => p !== undefined && p.trim() !== '')
119 return [...new Set(named)]
120}
121
122/** Stdout of one git command, trailing whitespace cut; null on any failure. */
123const gitText = async ($: Dollar, root: string, args: string[]): Promise<string | null> => {
124 const ran = await $.process
125 .run(['git', '-C', root, ...args], { timeoutMs: GIT_TIMEOUT_MS, env: { GIT_OPTIONAL_LOCKS: '0' } })
126 .catch(() => undefined)
127 // trimEnd: a porcelain line's leading space is its index column (" M a.py")
128 return ran === undefined || ran.exitCode !== 0 ? null : ran.stdout.trimEnd()
129}
130
131type GitFields = Pick<Bookmark, 'root' | 'branch' | 'log' | 'status'>
132
133const NO_GIT: GitFields = { root: null, branch: null, log: null, status: null }
134
135/** The checked-out branch; in a repository with no commits, HEAD's symbolic name. Null when neither answers. */
136const gitBranch = async ($: Dollar, root: string): Promise<string | null> =>
137 (await gitText($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])) ??
138 (await gitText($, root, ['symbolic-ref', '--short', 'HEAD']))
139
140/**
141 * Branch, recent commits and capped porcelain status from the first candidate that is a repository; all
142 * null when none is. Never throws.
143 */
144const captureGit = async ($: Dollar): Promise<GitFields> => {
145 for (const root of await rootCandidates($)) {
146 if ((await gitText($, root, ['rev-parse', '--show-toplevel'])) === null) continue
147 const [branch, log, status] = await Promise.all([
148 gitBranch($, root),
149 gitText($, root, ['log', '--oneline', '-5']),
150 gitText($, root, ['status', '--porcelain']),
151 ])
152 return { root, branch, log, status: status === null ? null : capStatus(status) }
153 }
154 return NO_GIT
155}
156
157/** Builds and stores the bookmark, and returns the result with the bookmark message appended. */
158const bookmarkResult = async (
159 $: Dollar,
160 e: SessionCompactInput,
161 trigger: Bookmark['trigger'],
162 git: GitFields,
163 result: SessionCompactResult & { skip?: undefined },
164): Promise<SessionCompactResult> => {
165 const b: Bookmark = {
166 savedAt: await $.clock.now(),
167 trigger,
168 ...git,
169 lastMessages: lastUserTexts(e.messages),
170 instructions: e.instructions?.trim() || null,
171 }
172 await update($, bookmarkState, () => b).catch(() => undefined)
173 const message: SessionMessage = { role: 'user', text: formatBookmark(b), toolUses: [] }
174 return { ...result, messages: [...result.messages, message] }
175}
176
177// ---------------------------------------------------------------------------
178// Registration
179
180/** Registers the compaction bookmark and the `/session-bookmark` command. */
181export const registerBookmark = (on: Parameters<Register>[0]): void => {
182 on('session.compact', INSTALLING, async ($, e, next) => {
183 if (e.agentId !== undefined) return next(e)
184 // git overlaps the summarizer; when core reuses a precomputed summary, git's own timeouts are the wait
185 const git = captureGit($)
186 const result = await next(e)
187 if (result.skip !== undefined) return result
188 try {
189 return await bookmarkResult($, e, e.trigger, await git, result)
190 } catch {
191 return result
192 }
193 })
194
195 on('session.start', async ($, e, next) => {
196 await $.command
197 .register({ name: COMMAND, description: 'Show the compaction bookmark saved in this session' })
198 .catch(() => undefined)
199 return next(e)
200 })
201
202 on('command.run', { command: COMMAND }, async $ => {
203 const b = await read($, bookmarkState)
204 return { text: b === null ? NO_BOOKMARK_TEXT : formatBookmark(b) }
205 }).catch(() => ({ text: 'session-bookmark: bookmark state unavailable.' }))
206}
207types/index.d.ts 28 lines1/** Session state captured as the main conversation compacts; stands in for a bookmark file. */
2export type Bookmark = {
3 /** Epoch ms when the compaction finished. */
4 savedAt: number
5 trigger: 'manual' | 'auto' | 'plugin'
6 /** The folder git answered from (session cwd, else CLAUDE_PROJECT_DIR); null when neither is a repo. */
7 root: string | null
8 /** `git rev-parse --abbrev-ref HEAD`, else `git symbolic-ref --short HEAD` (no commits yet); null when git was unavailable. */
9 branch: string | null
10 /** `git log --oneline -5`; null when git was unavailable or the repository has no commits. */
11 log: string | null
12 /** `git status --porcelain`, capped at MAX_STATUS_LINES entries plus a count line; null when git was unavailable, '' when clean. */
13 status: string | null
14 /** The last user prompts before the compaction, newest last; a previous compaction's summary can be one. */
15 lastMessages: string[]
16 /** The compaction's instructions (the text after `/compact`); null when none. */
17 instructions: string | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'session-bookmark': {
23 /** The latest compaction bookmark of this session; null before the first. */
24 bookmark: Bookmark | null
25 }
26 }
27}
28