Compacts the main conversation to a near-clear at each /loop /auto iteration boundary (a turn ending with an iteration outcome line), with one ledger row per…

A hooks-only mod that compacts the main conversation to a near-clear at each /loop /auto iteration boundary, so every iteration starts at the session's fixed floor instead of carrying the previous iterations' context. /auto keeps its cross-iteration state in tmp/auto-state-<runKey>.json and Linear, which is what makes the near-clear loss-free.
A boundary is a main-loop turn that ended with one of /auto's iteration outcome lines (SHIPPED-MERGE, SHIPPED-PR, DEFERRED-MERGE, SKIPPED-*, BLOCKED-ON-REVIEW, AUTO-CONTINUE, NO-CANDIDATES, AUTO-HALTED), in a session that is looping — a /loop prompt was typed or dispatched, a scheduled-trigger wakeup arrived, or a ScheduleWakeup call armed the next iteration — on a loop prompt that begins with a configured prefix (/auto).
Never compacted: a turn with no outcome line (the fallback heartbeat's status check with delegated work in flight, or a stalled turn), any turn in a session running no loop, the turn whose ScheduleWakeup ends the loop, an aborted or errored turn, and every subagent conversation. Each passed-through looping turn gets a pass ledger row with its reason.
The compaction is called from turn.complete, the one site the engine accepts it from (the probe's result is in mods/loop-boundary-probe/README.md); the mod's own session.compact hook answers that compaction with one message naming the outcome line, the run key and the run-state file, so no summarizer request is made.
Installed mod, claude --bg /loop 1m on sonnet with the loop prompt configured as a prefix, each reply ending in an outcome line: the first boundary took the next request from 72,791 to 45,813 tokens, and the second iteration's last request was 45,813 again — the floor held, nothing accumulated. In a fleet session the floor is the ~108k fixed overhead doc/compacting-investigation.md measured.
userConfig: enabled (kill switch), loop_prefixes (comma-separated, default /auto), min_context_percent (below it a boundary is logged, not compacted; default 0), ledger.
<session root>/tmp/loop-boundary-<sessionId>.jsonl:
{"ts":"...","session":"...","event":"boundary","accepted":true,"outcome":"SHIPPED-MERGE: BF-1","tokensBefore":480210,"tokensAfter":112340,"path":"turn-complete","nearClear":true,"turnId":"...","loopPrompt":"/auto"}
{"ts":"...","session":"...","event":"pass","reason":"no-outcome","turnId":"...","loopPrompt":"/auto"}
tokensBefore is the context of the last request before the boundary; tokensAfter is the context of the next iteration's first request, filled in when it answers (null until then, and on a session that ends first). The retro reads it: fleet-metrics.py reports a Boundary compactions line and boundary_compactions in its JSON.
Installed user-wide by update.sh from the repo's marketplace and loaded in place from this folder; fleet-launch.sh and fleet-sequence.sh refuse to dispatch where it is not enabled (scripts/mod-enabled.sh loop-boundary), since they no longer add an --autocompact cap. claude --plugin-dir ~/.claude/mods/loop-boundary loads it for one session on a machine without the install.
claude plugin validate mods/loop-boundary && claude plugin test mods/loop-boundary && mods/typecheck.sh mods/loop-boundaryhooks/register.ts 141 lines1import type { Register } from 'claude-code'
2import { boundaryMessage, loopPromptOf, matchesPrefix, outcomeLineOf, readOptions, requestTokensOf, wakeupPromptOf } from './boundary'
3
4let sessionId = ''
5let runKey = ''
6let root = ''
7let ledgerPath = ''
8let rows: string[] = []
9let loopPrompt: string | null = null
10let loopEnding = false
11let compacting: { outcome: string; tokensBefore: number | null } | null = null
12let nearClear = false
13let pendingAfter: number | null = null
14
15type Writer = { readonly fs: { readonly write: (path: string, text: string) => Promise<void> } }
16
17async function flush($: Writer): Promise<void> {
18 await $.fs.write(ledgerPath, rows.join('\n') + '\n')
19}
20
21async function record($: Writer, row: Record<string, unknown>, ledger: boolean): Promise<number> {
22 if (!ledger) return -1
23 rows.push(JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...row }))
24 await flush($)
25 return rows.length - 1
26}
27
28export const register: Register = (on, options) => {
29 const opts = readOptions(options)
30
31 on('session.start', async ($, e, next) => {
32 sessionId = await $.session.id()
33 runKey = sessionId.split('-')[0] ?? sessionId
34 root = await $.session.root()
35 if (opts.enabled && opts.ledger) {
36 ledgerPath = `${root}/tmp/loop-boundary-${sessionId}.jsonl`
37 if (await $.fs.exists(ledgerPath)) rows = (await $.fs.read(ledgerPath)).split('\n').filter((line) => line !== '')
38 }
39 return next(e)
40 })
41
42 // A session is looping once a /loop prompt is typed (or dispatched, as a fleet session's first prompt) or a loop
43 // wakeup arrives; the loop prompt is what the prefix list is matched against.
44 on('prompt.submit', async ($, e, next) => {
45 const typed = loopPromptOf(e.text)
46 if (typed !== null) loopPrompt = typed
47 else if (e.origin.kind === 'scheduled-trigger') loopPrompt = e.text.trim()
48 return next(e)
49 })
50
51 on('turn.start', async ($, e, next) => {
52 loopEnding = false
53 return next(e)
54 })
55
56 // The ScheduleWakeup call that arms the next iteration carries the loop prompt; stop:true ends the loop.
57 // The first request after a boundary measures what the compaction left: its context is the boundary's tokensAfter.
58 on('turn.step', async function* ($, e, next) {
59 if (e.agentId !== undefined || !opts.enabled) return yield* next(e)
60 const result = yield* next(e)
61 const wake = wakeupPromptOf(result.toolUses)
62 if (wake.stop) loopEnding = true
63 else if (wake.prompt !== null) loopPrompt = wake.prompt
64 if (pendingAfter !== null && opts.ledger) {
65 const after = requestTokensOf(result.usage)
66 const row = rows[pendingAfter]
67 if (row !== undefined && after !== null) {
68 rows[pendingAfter] = JSON.stringify({ ...(JSON.parse(row) as Record<string, unknown>), tokensAfter: after })
69 await flush($)
70 $.ui.log(`loop-boundary: next iteration's first request carried ${after.toLocaleString()} tokens`)
71 }
72 pendingAfter = null
73 }
74 return result
75 })
76
77 on('turn.complete', async ($, e, next) => {
78 const result = await next(e)
79 if (e.agentId !== undefined || !opts.enabled || loopPrompt === null) return result
80 const base = { turnId: e.turnId, loopPrompt }
81 if (!matchesPrefix(loopPrompt, opts.prefixes)) {
82 await record($, { event: 'pass', reason: 'prefix', ...base }, opts.ledger)
83 return result
84 }
85 if (e.reason !== 'answer') {
86 await record($, { event: 'pass', reason: e.reason, ...base }, opts.ledger)
87 return result
88 }
89 const outcome = outcomeLineOf(e.answer)
90 if (outcome === null) {
91 await record($, { event: 'pass', reason: 'no-outcome', ...base }, opts.ledger)
92 return result
93 }
94 if (loopEnding) {
95 await record($, { event: 'pass', reason: 'loop-ended', outcome, ...base }, opts.ledger)
96 return result
97 }
98 const context = (await $.session.usage()).context
99 if (opts.minContextPercent > 0 && (context.percent ?? 0) < opts.minContextPercent) {
100 await record($, { event: 'pass', reason: 'below-floor', outcome, contextPercent: context.percent ?? null, ...base }, opts.ledger)
101 return result
102 }
103 const tokensBefore = context.tokens ?? null
104 compacting = { outcome, tokensBefore }
105 nearClear = false
106 let row: Record<string, unknown>
107 try {
108 const compacted = await $.session.compact()
109 row =
110 compacted.skip !== undefined
111 ? { event: 'boundary', accepted: false, skipped: compacted.skip, outcome, tokensBefore, tokensAfter: null, path: 'turn-complete', nearClear, ...base }
112 : { event: 'boundary', accepted: true, outcome, tokensBefore: compacted.tokensBefore ?? tokensBefore, tokensAfter: null, path: 'turn-complete', nearClear, ...base }
113 } catch (err) {
114 row = { event: 'boundary', accepted: false, error: String(err), outcome, tokensBefore, tokensAfter: null, path: 'turn-complete', nearClear: false, ...base }
115 }
116 compacting = null
117 const index = await record($, row, opts.ledger)
118 if (row.accepted === true) {
119 pendingAfter = index >= 0 ? index : null
120 $.ui.log(`loop-boundary: compacted at the iteration boundary after \`${outcome}\` — the last request carried ${tokensBefore === null ? 'an unmeasured number of' : tokensBefore.toLocaleString()} tokens`)
121 } else {
122 $.ui.log(`loop-boundary: boundary after \`${outcome}\` not compacted (${String(row.skipped ?? row.error)})`)
123 }
124 return result
125 })
126
127 // The compaction this mod triggered is answered here with the one boundary message, so no summarizer runs. The
128 // in-flight flag is the key: it is set just before the call and cleared just after, and the engine stamps the event
129 // `trigger: plugin` (the plugin test harness stamps nothing). Every other compaction — the engine's threshold, a typed
130 // /compact, a subagent's — passes through untouched.
131 on('session.compact', async ($, e, next) => {
132 if (e.agentId !== undefined || compacting === null) return next(e)
133 nearClear = true
134 const statePath = `${root}/tmp/auto-state-${runKey}.json`
135 const text = boundaryMessage({ outcome: compacting.outcome, runKey, statePath, stateExists: await $.fs.exists(statePath) })
136 return compacting.tokensBefore === null
137 ? { messages: [{ role: 'user', text, toolUses: [] }] }
138 : { messages: [{ role: 'user', text, toolUses: [] }], tokensBefore: compacting.tokensBefore }
139 })
140}
141hooks/boundary.ts 81 lines1export type ToolUse = { readonly name: string; readonly input: unknown }
2
3// The lines /auto ends an iteration with (skills/auto/SKILL.md). A heartbeat status check and a stalled turn end with
4// none of them, which is what keeps an in-flight iteration's context intact.
5const OUTCOME_LINE = /^[\s>*_`#-]*(SHIPPED-MERGE|SHIPPED-PR|DEFERRED-MERGE|SKIPPED-[A-Z]+(?:-[A-Z]+)*|BLOCKED-ON-REVIEW|AUTO-CONTINUE|NO-CANDIDATES|AUTO-HALTED)\b.*$/m
6
7export function outcomeLineOf(answer: string): string | null {
8 const m = OUTCOME_LINE.exec(answer)
9 if (m === null) return null
10 return m[0].replace(/^[\s>*_`#-]+/, '').replace(/[*_`]+/g, '').trim().slice(0, 200)
11}
12
13// `/loop 5m /auto epic:BF-1` re-fires `/auto epic:BF-1`; `/loop /auto` re-fires `/auto`. Anything else is its own prompt.
14const LOOP_PROMPT = /^\/loop(?:\s+\d+(?:\.\d+)?\s*(?:s|m|h|d|sec|secs|min|mins|minutes?|seconds?|hours?|days?))?\s+([\s\S]*)$/i
15
16export function loopPromptOf(text: string): string | null {
17 const m = LOOP_PROMPT.exec(text.trim())
18 return m === null ? null : (m[1] ?? '').trim()
19}
20
21export function matchesPrefix(prompt: string, prefixes: readonly string[]): boolean {
22 const p = prompt.trim()
23 return prefixes.some((prefix) => p === prefix || p.startsWith(`${prefix} `))
24}
25
26function inputOf(use: ToolUse): Record<string, unknown> {
27 return typeof use.input === 'object' && use.input !== null ? (use.input as Record<string, unknown>) : {}
28}
29
30// The loop prompt a ScheduleWakeup call carries forward, or null when the call ends the loop or there is no such call.
31export function wakeupPromptOf(uses: readonly ToolUse[]): { prompt: string | null; stop: boolean } {
32 let prompt: string | null = null
33 let stop = false
34 for (const use of uses) {
35 if (use.name !== 'ScheduleWakeup') continue
36 const input = inputOf(use)
37 if (input.stop === true) stop = true
38 else if (typeof input.prompt === 'string') prompt = input.prompt
39 }
40 return { prompt, stop }
41}
42
43export function requestTokensOf(usage: unknown): number | null {
44 if (typeof usage !== 'object' || usage === null) return null
45 const u = usage as Record<string, unknown>
46 const parts = [u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens].filter((v): v is number => typeof v === 'number')
47 return parts.length === 0 ? null : parts.reduce((a, b) => a + b, 0)
48}
49
50export function boundaryMessage(args: { outcome: string; runKey: string; statePath: string; stateExists: boolean }): string {
51 const state = args.stateExists ? `run state in ${args.statePath}` : `no run-state file at ${args.statePath} yet`
52 return [
53 `Loop boundary: the previous /auto iteration ended with \`${args.outcome}\`.`,
54 `Run key ${args.runKey}; ${state}. The conversation before this point was compacted away at the iteration boundary by the loop-boundary mod; /auto re-reads its state from that file and from Linear, and nothing from the earlier iteration is needed here.`,
55 ].join('\n')
56}
57
58export type Options = {
59 readonly enabled: boolean
60 readonly prefixes: readonly string[]
61 readonly minContextPercent: number
62 readonly ledger: boolean
63}
64
65export function readOptions(raw: Readonly<Record<string, unknown>>): Options {
66 const prefixes =
67 typeof raw.loop_prefixes === 'string'
68 ? raw.loop_prefixes
69 .split(/[,\s]+/)
70 .map((p) => p.trim())
71 .filter((p) => p !== '')
72 : []
73 const min = raw.min_context_percent
74 return {
75 enabled: raw.enabled !== false,
76 prefixes: prefixes.length > 0 ? prefixes : ['/auto'],
77 minContextPercent: typeof min === 'number' && min > 0 ? Math.min(100, min) : 0,
78 ledger: raw.ledger !== false,
79 }
80}
81