SLOPSHOPPER

loop-boundary

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…

newprompt
★ 1v0.1.0MITupdated 2026-10-09alienfast/claude/mods/loop-boundary
A shopper browsing a rack in a slop shop
README

loop-boundary

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.

When it compacts

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.

Measured, 2026-10-09

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.

Configuration

userConfig: enabled (kill switch), loop_prefixes (comma-separated, default /auto), min_context_percent (below it a boundary is logged, not compacted; default 0), ledger.

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.

Loading

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.

Checks

claude plugin validate mods/loop-boundary && claude plugin test mods/loop-boundary && mods/typecheck.sh mods/loop-boundary
Source 2 files
hooks/register.ts 141 lines
1import 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}
141
hooks/boundary.ts 81 lines
1export 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