SLOPSHOPPER

loop-boundary-probe

Measures whether a compaction called from inside a prompt.submit hook is accepted while the session is idle, and what origin a /loop wakeup's prompt carries

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

loop-boundary-probe

Measures where a plugin may compact the conversation and what origin a /loop wakeup's prompt carries, which is what loop-boundary rests on. One row per submitted prompt (origin, whether a turn was running) and one per compaction attempt (accepted, skipped or rejected; whether this plugin's own session.compact hook saw the compaction it triggered; context before and after). It compacts at the end of every main-loop turn but the first.

python3 mods/loop-boundary-probe/run.py [model]                      # three prompts over claude -p stream-json
claude --bg --plugin-dir ~/.claude/mods/loop-boundary-probe --model sonnet --effort low --permission-mode auto \
  -n loop-boundary-probe "/loop 1m Reply with exactly the one word: TICK"   # a real loop; stop it with claude stop <id>

Result, 2026-10-09 (Claude Code 2.1.295)

where the compaction was calledsessionresult
prompt.submit hook, idleplugin test harnessrefused by the host check: "called from a prompt.submit hook, it would compact under the turn this hook is holding; call it from a later event (turn.complete)"
turn.complete, main loopclaude -p --input-format stream-jsonrejected: "not available in a headless (-p / SDK) session yet"
turn.complete, main loopclaude --bg /loop 1m, sonnetaccepted: 72,602 → 6,939 tokens, then 55,889 → 6,934; own hook saw its trigger
turn.complete, main loopclaude --bg /loop 1m, opus[1m]accepted: 72,313 → 6,776, then a steady 55,1xx → 6,8xx over 21 ticks; own hook saw its trigger

Every /loop wakeup's prompt arrived with origin scheduled-trigger and no running turn; the session's first prompt (claude --bg "<prompt>") arrived as composer. The engine's summarizer answered these compactions (two messages left), which is what loop-boundary replaces with its one hand-written message.

Checks

claude plugin validate mods/loop-boundary-probe && claude plugin test mods/loop-boundary-probe && mods/typecheck.sh mods/loop-boundary-probe
Source 1 files
hooks/register.ts 61 lines
1import type { Register } from 'claude-code'
2
3// Measures the premises loop-boundary rests on. The first attempt called $.session.compact() from the prompt.submit
4// hook of the next prompt; the engine's host check refuses that ("it would compact under the turn this hook is holding;
5// call it from a later event (turn.complete)", measured 2026-10-09 in the plugin test harness), so the compaction is
6// called from the main loop's turn.complete instead, after every turn but the first. One row per submitted prompt (its
7// origin and whether a turn was running) and one per compaction attempt (accepted, skipped or rejected; whether this
8// plugin's own session.compact hook saw the compaction it triggered; context before and after).
9type Row = Record<string, unknown>
10
11const rows: Row[] = []
12let outPath = ''
13let prompts = 0
14let compacting = false
15let ownHookSaw = false
16
17export const register: Register = (on) => {
18  on('session.start', async ($, e, next) => {
19    const out = await $.env.get('LOOP_PROBE_OUT')
20    outPath = out ?? `${await $.session.cwd()}/tmp/loop-boundary-probe-${await $.session.id()}.jsonl`
21    return next(e)
22  })
23
24  on('session.compact', async ($, e, next) => {
25    if (e.trigger === 'plugin' && compacting) ownHookSaw = true
26    // The engine always hands a transcript; the plugin test harness hands none for a compaction a hook started.
27    return next(Array.isArray(e.messages) && e.messages.length > 0 ? e : { ...e, messages: [{ role: 'user', text: '(probe)', toolUses: [] }] })
28  })
29
30  on('prompt.submit', async ($, e, next) => {
31    prompts += 1
32    rows.push({ ts: new Date().toISOString(), event: 'prompt', index: prompts - 1, origin: e.origin, turnRunning: e.turnId !== undefined, text: e.text.slice(0, 80) })
33    await $.fs.write(outPath, rows.map((r) => JSON.stringify(r)).join('\n') + '\n')
34    return next(e)
35  })
36
37  on('turn.complete', async ($, e, next) => {
38    const result = await next(e)
39    if (e.agentId !== undefined || prompts < 2) return result
40    const row: Row = { ts: new Date().toISOString(), event: 'compact', turnId: e.turnId, reason: e.reason, answer: e.answer.slice(0, 80) }
41    row.contextBefore = (await $.session.usage()).context
42    compacting = true
43    ownHookSaw = false
44    try {
45      const compacted = await $.session.compact({ instructions: 'Keep one line: this conversation is a compaction probe.' })
46      row.compact =
47        compacted.skip !== undefined
48          ? { skipped: compacted.skip }
49          : { accepted: true, messages: compacted.messages.length, tokensBefore: compacted.tokensBefore ?? null, tokensAfter: compacted.tokensAfter ?? null }
50    } catch (err) {
51      row.compact = { rejected: String(err) }
52    }
53    compacting = false
54    row.ownHookSaw = ownHookSaw
55    row.contextAfter = (await $.session.usage()).context
56    rows.push(row)
57    await $.fs.write(outPath, rows.map((r) => JSON.stringify(r)).join('\n') + '\n')
58    return result
59  })
60}
61