SLOPSHOPPER

context-relay

Hands the work to a fresh session before a long context degrades the model, and carries on there.

newguardcommandtoaststatusmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-relay
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /relay ⎿ context-relay: Relaying at 49% / 97k tokens… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-relay

A Claude Code mod that hands your work to a fresh session before a long context starts degrading the model, then keeps going there on its own.

Models get worse deep into a long context: they forget early instructions, mix up files and invent details. Auto-compact steps in late and summarizes in place. context-relay acts earlier and starts clean.

turn ends at 62% ─┐
                  ├─ 1. the model writes a handoff note from its own transcript   ($.model.fork, prompt-cached)
                  ├─ 2. the note is saved to ~/.claude/relay/<old-session-id>.md
                  ├─ 3. /clear  → new session id, empty context
                  └─ 4. the note is submitted as the first prompt → work continues

The handoff note always has the same sections: Goal · Done so far · Current state · Decisions and findings · Next steps · Key files. It also starts with a status line:

  • STATUS: CONTINUE: work is unfinished, so the new session picks up from Next steps without waiting for you.
  • STATUS: WAIT: the task is done or needs your answer. The new session reads the note, says where things stand in one line, and waits.

The old session is never lost. The handoff includes claude --resume <old-id>.

When it relays

TriggerDefaultSetting
A turn ends with the context at least this full60 % of the windowthreshold
…or holding at least this many tokens, whatever the window size (useful on 1M-token models)300 000 (0 = off)tokenCeiling
Mid-turn: after a tool call finishes with the context at least this full, the running turn is stopped and relayed80 % (0 = off)hardThreshold
By hand/relay

Subagent turns are ignored. Only the main conversation is measured.

Install

This is a mod, a plugin of function hooks. That's an early-access Claude Code feature (built and tested on 2.1.286).

git clone https://github.com/retrocodes12/context-relay ~/.claude/mods/context-relay

# one session:
claude --plugin-dir ~/.claude/mods/context-relay

# every session: add to ~/.claude/settings.json
#   "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/context-relay" }

Change the thresholds in /config (each userConfig field shows up as a row there), or in settings.json:

"pluginConfigs": { "context-relay": { "options": { "threshold": 50, "tokenCeiling": 200000, "hardThreshold": 75 } } }

Develop

claude plugin validate .   # manifest + what the module hooks and calls
claude plugin test .       # tests/relay.test.ts against the engine's own test kit

To type-check, run /plugin-types in a session to write the engine's declarations into .claude/types, then npx -p typescript tsc -p ..

Caveats

  • Tested with the engine's test kit, not yet in a long live session. The 6 tests stub the engine underneath the mod: context usage, the fork, /clear and the prompt submit. They cover the continue path, the wait path, the token ceiling, the mid-turn stop and ignoring subagents.
  • The mid-turn stop interrupts the model between tool calls. The handoff is written from the transcript at that point, so an edit sequence can be cut in the middle. The note's Current state section is there to record that. Set hardThreshold to 0 if you only want relays between turns.
  • The handoff costs one extra model call over the old context. It reuses the session's prompt cache, so it costs about what one more turn would.
  • The function-hooks API is early access and can change between Claude Code releases.

License

MIT

Source 1 files
hooks/register.ts 152 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type $ = EngineInterface
4
5type Options = { threshold?: number; tokenCeiling?: number; hardThreshold?: number }
6
7// What the old session's model is asked, over its own transcript, before it is cleared.
8const HANDOFF_PROMPT = `Your context window is filling up and this conversation is about to be cleared.
9A fresh session of you will continue the work, and the note you write now is ALL it will know.
10
11Write the handoff note. Be concrete and complete; no preamble.
12
13The FIRST line must be exactly one of:
14STATUS: CONTINUE   (work is unfinished and the next step needs no input from the user)
15STATUS: WAIT       (the task is done, or you are waiting on the user's answer or decision)
16
17Then these sections:
18## Goal
19What the user asked for, in their own words where it matters, and every constraint or preference they stated.
20## Done so far
21What has been completed, with file paths, commands, commits, URLs.
22## Current state
23Where things stand right now: what was in progress when you stopped, anything half-applied, what is running.
24## Decisions and findings
25Choices already made and why, dead ends already tried, facts discovered that are expensive to rediscover.
26## Next steps
27The exact next actions in order. If waiting on the user, the question they need to answer.
28## Key files
29Paths worth reading first.`
30
31type Usage = { percent?: number; tokens?: number }
32
33let relaying = false
34
35const describe = (u: Usage) =>
36  `${u.percent ?? '?'}% / ${Math.round((u.tokens ?? 0) / 1000)}k tokens`
37
38async function relay($: $, reason: string) {
39  if (relaying) return
40  relaying = true
41  try {
42    $.ui.status('relay: writing handoff…')
43    const fork = await $.model.fork({ prompt: HANDOFF_PROMPT })
44    if (!fork.isAnswered) {
45      $.ui.toast(`relay: no handoff (${fork.reason}); staying in this session`)
46      return
47    }
48
49    const note = fork.text.trim()
50    const shouldContinue = /^STATUS:\s*CONTINUE/i.test(note)
51    const body = note.replace(/^STATUS:.*\n?/i, '').trim()
52
53    const oldId = await $.session.id()
54    const home = await $.env.get('HOME')
55    const file = `${home ?? '.'}/.claude/relay/${oldId}.md`
56    await $.fs.write(file, `<!-- relayed from session ${oldId}: ${reason} -->\n${note}\n`)
57    const hops = Number((await $.store.get('hops')) ?? 0) + 1
58    await $.store.set('hops', hops)
59    await $.store.set('last', { from: oldId, file, reason, shouldContinue })
60
61    $.ui.status('relay: starting fresh session…')
62    await $.command.run({ command: 'clear', args: '' })
63
64    const handoff =
65      `[context-relay] You are continuing work from a previous session that was cleared ` +
66      `because its context was filling up (${reason}). The full previous transcript is ` +
67      `resumable with \`claude --resume ${oldId}\`; the note below is saved at ${file}.\n\n` +
68      body
69
70    await $.prompt.submit({
71      text: shouldContinue
72        ? `${handoff}\n\nContinue the work from "Next steps". Do not redo what is done.`
73        : `${handoff}\n\nDo not start any work. Reply with one short line saying where things stand, then wait for the user.`,
74    })
75    $.ui.toast(
76      shouldContinue
77        ? `relay #${hops}: fresh session, carrying on`
78        : `relay #${hops}: fresh session, handoff loaded, waiting for you`,
79    )
80  } catch (err) {
81    $.ui.toast(`relay failed: ${err instanceof Error ? err.message : String(err)}`)
82  } finally {
83    $.ui.status(undefined)
84    relaying = false
85  }
86}
87
88export const register: Register = (on, options) => {
89  const opts = (options ?? {}) as Options
90  const threshold = opts.threshold ?? 60
91  const tokenCeiling = opts.tokenCeiling ?? 300_000
92  const hardThreshold = opts.hardThreshold ?? 80
93
94  let turnId: string | undefined
95  let stoppedForRelay = false
96
97  const over = (u: Usage, percent: number) =>
98    (percent > 0 && (u.percent ?? 0) >= percent) ||
99    (tokenCeiling > 0 && (u.tokens ?? 0) >= tokenCeiling)
100
101  on('session.start', async ($, e, next) => {
102    await $.command.register({
103      name: 'relay',
104      description: 'Hand this work to a fresh session now (context-relay)',
105    })
106    return next(e)
107  })
108
109  on('command.run', { command: 'relay' }, async $ => {
110    const u = (await $.session.usage()).context
111    // A command's own dispatch is one the session waits on: relay from a timer instead.
112    $.clock.after(0, () => void relay($, `asked by hand at ${describe(u)}`))
113    return { text: `Relaying at ${describe(u)}…` }
114  })
115
116  on('turn.start', ($, e, next) => {
117    turnId = e.turnId
118    stoppedForRelay = false
119    return next(e)
120  })
121
122  // Mid-turn: a long autonomous turn can blow far past the threshold before it ends.
123  on('tool.call', async ($, e, next) => {
124    const result = await next(e)
125    if (e.agentId !== undefined || relaying || stoppedForRelay || !turnId || hardThreshold <= 0) {
126      return result
127    }
128    const u = (await $.session.usage()).context
129    if ((u.percent ?? 0) >= hardThreshold) {
130      stoppedForRelay = true
131      const id = turnId
132      $.clock.after(0, () => void $.turn.abort({ turnId: id }).catch(() => {}))
133    }
134    return result
135  })
136
137  on('turn.complete', async ($, e, next) => {
138    const result = await next(e)
139    if (e.agentId !== undefined || relaying) return result
140
141    const u = (await $.session.usage()).context
142    const reason = stoppedForRelay
143      ? `stopped mid-turn at ${describe(u)}`
144      : e.reason === 'answer' && over(u, threshold)
145        ? `turn ended at ${describe(u)}`
146        : undefined
147    stoppedForRelay = false
148    if (reason) $.clock.after(0, () => void relay($, reason))
149    return result
150  })
151}
152