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

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>
| where the compaction was called | session | result |
|---|---|---|
prompt.submit hook, idle | plugin test harness | refused 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 loop | claude -p --input-format stream-json | rejected: "not available in a headless (-p / SDK) session yet" |
turn.complete, main loop | claude --bg /loop 1m, sonnet | accepted: 72,602 → 6,939 tokens, then 55,889 → 6,934; own hook saw its trigger |
turn.complete, main loop | claude --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.
claude plugin validate mods/loop-boundary-probe && claude plugin test mods/loop-boundary-probe && mods/typecheck.sh mods/loop-boundary-probehooks/register.ts 61 lines1import 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