/relay: update stale docs, save a handoff note to a temp file, draft a continuation prompt, compact, and put the prompt in the box.

A Claude Code mod (function-hook plugin) that turns the checkpoint-compact-continue routine into one command, /relay.
When a session's context is getting full, /relay:
<handoff-note> tags. The note covers what's done, what's in progress, decisions, lessons, gotchas, workarounds, next steps and the files touched. The plugin writes the note to a temp file, $TMPDIR/claude-relay/<project>-<UTC time>.md (/tmp when TMPDIR is unset), so nothing new lands in the repo. The model doesn't write that file itself, so there's no permission prompt for a path outside the project.macOS clears $TMPDIR on reboot and removes files left unused for about 3 days. That's fine for the next session; set noteDir if you want notes kept longer.
$.model.fork, the cache-served, tool-less side question /btw asks. The fork replays the last request, which ends before the save turn's final message, so the note is quoted into the question. The answer is a copy/paste-ready prompt that points at the files to read (@path, the temp note first) and adds only what they don't say. The fork doesn't add the question or the answer to the conversation. The prompt is saved to the plugin's store and, by default, copied to the clipboard./compact does, with your instructions (default: keep what's helpful and not written to a durable location).@ mentions; a plugin's submitted prompt doesn't.If any step fails, the run stops before the next one and logs why:
/relay paste./relay paste. A rejected compaction is retried twice, 2 s apart, in case the save turn is still winding down.Headless sessions (-p, SDK) can't compact from a plugin on Claude Code 2.1.294, so there /relay saves and drafts the prompt, then stops with the prompt saved.
Tested end to end in an interactive session on Claude Code 2.1.294: save turn, note written to $TMPDIR, fork, compaction (35k → 3k tokens) and fill. Pressing Enter on the filled prompt attached the temp note through its @ path with no permission prompt.
/relay [focus] run it; optional focus is appended to the save prompt
/relay status idle, or which phase and for how long
/relay cancel stop a run (anything already written or compacted stays)
/relay paste fill the prompt box with the last continuation prompt, from any session
status, cancel and paste are matched whole and ignore case. Any other text is the focus.
Use /relay mid-session to free up context and keep going: the save turn only records where things stand, and the work continues after you press Enter on the filled prompt. The save prompt tells the model not to start new work, since anything started then would be compacted half-done. So use the focus to say what the note should cover, including what you'll do next, not to give the model a task:
/relay next I'm wiring the retry logic into the uploader; make sure the note covers what that needs
To give the next session an instruction, type it under the filled prompt before pressing Enter; the prompt is placed ahead of anything you'd already typed.
Set in /config (or pluginConfigs.relay.options in settings):
| Option | Default | |
|---|---|---|
deliver | fill | fill puts the prompt in the box. submit sends it at once and tells the model to read the @ files, since a plugin's prompt doesn't attach them. |
compactInstructions | Keep any information that's helpful to the agent and not written to a durable location. | What the compaction summary keeps. |
noteDir | empty | Directory for handoff notes. Empty: $TMPDIR/claude-relay (else /tmp/claude-relay). |
copyToClipboard | true | Also copy the continuation prompt to the clipboard. |
The save and continuation prompts live in hooks/prompts.ts.
This repo is its own marketplace. Install from GitHub:
claude plugin marketplace add chrisvaillancourt/claude-relay
claude plugin install relay@relay --scope user
The install may say the userConfig options aren't set yet; until you set them, the defaults below apply. A GitHub install runs a copy. It updates only when version in plugin.json changes: claude plugin marketplace update relay, then claude plugin update relay@relay.
Or from a local clone:
claude plugin marketplace add /path/to/claude-relay
claude plugin install relay@relay --scope user
A folder marketplace is read from the clone itself. After an edit, run /reload-plugins; no version bump or reinstall is needed. claude plugin list shows Read from: <clone>.
claude plugin validate .
claude plugin test .
To try it without installing: claude --plugin-dir ..
MIT.
hooks/register.ts 319 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import {
5 cleanContinuation,
6 continuationPrompt,
7 extractNote,
8 forSubmit,
9 isCanceled,
10 notePathFor,
11 parseArgs,
12 SAVE_MARKER,
13 savePrompt,
14} from './prompts'
15import type { Config, Run, SavedPrompt } from '../types'
16
17/** A compaction right after the save turn may still see it running; retry briefly, then give up. */
18const COMPACT_RETRY_MS = 2000
19const COMPACT_ATTEMPTS = 3
20/** How long after the save prompt's turn started to expect its turn.start to have been matched. */
21const SAVE_TURN_WATCHDOG_MS = 5000
22const DEFAULT_COMPACT_INSTRUCTIONS =
23 "Keep any information that's helpful to the agent and not written to a durable location."
24
25const IDLE: Run = { phase: 'idle', runId: 0, saveTurnId: null, startedAt: null, notePath: null }
26
27const runAtom = atom({ plugin: 'relay', key: 'run' } as const, IDLE)
28
29// Module variables reset on a hot reload; `$.state` survives one. A run left
30// `continuing` by a reload has lost its timer, so session.start resets it.
31let config: Config = configFrom({})
32let timer: Timer | null = null
33let isInteractive = true
34/** Whether a main-loop turn is running; subagents raise no turn.start. */
35let isTurnRunning = false
36
37function configFrom(options: PluginOptions): Config {
38 const instructions = typeof options.compactInstructions === 'string' ? options.compactInstructions.trim() : ''
39 return {
40 deliver: options.deliver === 'submit' ? 'submit' : 'fill',
41 compactInstructions: instructions || DEFAULT_COMPACT_INSTRUCTIONS,
42 copyToClipboard: options.copyToClipboard !== false,
43 noteDir: typeof options.noteDir === 'string' ? options.noteDir.trim() : '',
44 }
45}
46
47function cancelTimer() {
48 timer?.cancel()
49 timer = null
50}
51
52function errorText(err: unknown) {
53 return err instanceof Error ? err.message : String(err)
54}
55
56function toast($: EngineInterface, text: string) {
57 try {
58 $.ui.toast(text)
59 } catch {
60 // Best effort.
61 }
62}
63
64/** Ends run `runId` if it is still the live one, and says why; never throws. */
65async function finish($: EngineInterface, runId: number, message: string | null) {
66 try {
67 const run = await read($, runAtom)
68 if (run.runId !== runId || run.phase === 'idle') return
69 await update($, runAtom, cur => (cur.runId === runId ? { ...IDLE, runId } : cur))
70 $.ui.status(undefined)
71 if (message) $.ui.log(`relay: ${message}`)
72 } catch {
73 // Reporting must never throw out of a hook or timer.
74 }
75}
76
77async function isCurrent($: EngineInterface, runId: number) {
78 const run = await read($, runAtom)
79 return run.runId === runId && run.phase === 'continuing'
80}
81
82/** Puts `text` in the prompt box ahead of any draft the person has typed. */
83async function fill($: EngineInterface, text: string): Promise<boolean> {
84 const draft = (await $.prompt.read()).text.trim()
85 const filled = await $.prompt.fill({ text: draft ? `${text}\n\n${draft}` : text })
86 return filled.isFilled
87}
88
89/** Compacts, retrying briefly if the save turn hasn't finished tearing down. */
90async function compact($: EngineInterface, runId: number, attempt = 1): Promise<void> {
91 if (!(await isCurrent($, runId))) return
92 if (isTurnRunning) {
93 await finish(
94 $,
95 runId,
96 'stopped: a new turn started before compaction. The continuation prompt is saved: /compact, then /relay paste.',
97 )
98 return
99 }
100 let result
101 try {
102 result = await $.session.compact({ instructions: config.compactInstructions })
103 } catch (err) {
104 $.ui.log(`relay: compaction attempt ${attempt} rejected: ${errorText(err)}`, { to: 'debug' })
105 // The person pressing Esc or Ctrl+C cancels compaction; that's a decision, not a hiccup.
106 if (isCanceled(errorText(err))) {
107 await finish($, runId, 'compaction was canceled. The continuation prompt is saved: /relay paste.')
108 return
109 }
110 if (isInteractive && attempt < COMPACT_ATTEMPTS && (await isCurrent($, runId))) {
111 timer = $.clock.after(COMPACT_RETRY_MS, () => {
112 compact($, runId, attempt + 1).catch(e => finish($, runId, `stopped: ${errorText(e)}`))
113 })
114 return
115 }
116 await finish(
117 $,
118 runId,
119 `couldn't compact (${errorText(err)}). The continuation prompt is saved: run /compact yourself, then /relay paste.`,
120 )
121 return
122 }
123 if (result.skip !== undefined) {
124 await finish($, runId, `compaction was skipped (${result.skip}). The continuation prompt is saved: /relay paste.`)
125 return
126 }
127 await deliver($, runId)
128}
129
130async function deliver($: EngineInterface, runId: number) {
131 if (!(await isCurrent($, runId))) return
132 const saved = (await $.store.get('lastPrompt')) as SavedPrompt | undefined
133 const text = saved?.text ?? ''
134 if (config.deliver === 'submit') {
135 await finish($, runId, null)
136 void $.prompt
137 .submit({ text: forSubmit(text), asUser: true })
138 .then(r => {
139 if (r.drop !== undefined) $.ui.log(`relay: the continuation prompt was dropped (${r.drop}); /relay paste to retry.`)
140 })
141 .catch(err => $.ui.log(`relay: couldn't submit the continuation prompt (${errorText(err)}); /relay paste to retry.`))
142 toast($, 'compacted; sending the continuation prompt')
143 return
144 }
145 const isFilled = await fill($, text).catch(() => false)
146 await finish($, runId, isFilled ? null : `couldn't fill the prompt box. Continuation prompt:\n\n${text}`)
147 if (isFilled) toast($, 'compacted; review the prompt and press Enter')
148}
149
150/** After the save turn: write its note, fork the continuation prompt, keep it, then compact and deliver. */
151async function continueRun($: EngineInterface, runId: number, answer: string, notePath: string) {
152 const note = extractNote(answer)
153 try {
154 if (!(await isCurrent($, runId))) return
155 if (!note) {
156 await finish($, runId, 'stopped: the save turn ended with no handoff note. Nothing was compacted.')
157 return
158 }
159 try {
160 await $.fs.write(notePath, `${note}\n`)
161 } catch (err) {
162 await finish($, runId, `stopped: couldn't write the handoff note to ${notePath} (${errorText(err)}). Nothing was compacted.`)
163 return
164 }
165 $.ui.status('writing the continuation prompt…')
166 // The fork replays the last request, which ends before the note, so the note rides along.
167 const reply = await $.model.fork({ prompt: continuationPrompt(note, notePath) })
168 if (!(await isCurrent($, runId))) return
169 if (!reply.isAnswered) {
170 await finish($, runId, `stopped: the continuation prompt failed (${reply.reason}). Nothing was compacted.`)
171 return
172 }
173 const text = cleanContinuation(reply.text)
174 if (!text) {
175 await finish($, runId, 'stopped: the continuation prompt came back empty. Nothing was compacted.')
176 return
177 }
178 const saved: SavedPrompt = { text, at: await $.clock.now(), sessionId: await $.session.id() }
179 await $.store.set('lastPrompt', saved)
180 if (config.copyToClipboard) await $.ui.copy({ text }).catch(() => undefined)
181 if (!(await isCurrent($, runId))) return
182 $.ui.status('compacting…')
183 await compact($, runId)
184 } catch (err) {
185 await finish($, runId, `stopped: ${errorText(err)}`)
186 }
187}
188
189/** Submits the save prompt; called from a timer, since command.run can't submit. */
190async function submitSave($: EngineInterface, runId: number, text: string) {
191 const run = await read($, runAtom)
192 if (run.runId !== runId || run.phase !== 'saving') return
193 const r = await $.prompt.submit({ text, asUser: true })
194 if (r.drop !== undefined) {
195 await finish($, runId, `stopped: the save prompt was dropped (${r.drop}).`)
196 return
197 }
198 // The submit resolves as the save turn starts. If turn.start didn't match the
199 // marker (another plugin rewrote the prompt), don't wait in `saving` forever.
200 $.clock.after(SAVE_TURN_WATCHDOG_MS, () => {
201 void read($, runAtom)
202 .then(cur => {
203 if (cur.runId === runId && cur.phase === 'saving' && cur.saveTurnId === null) {
204 return finish($, runId, "stopped: couldn't find the save turn. Nothing was compacted; /relay to retry.")
205 }
206 })
207 .catch(() => undefined)
208 })
209}
210
211export const register: Register = (on, options) => {
212 config = configFrom(options)
213 timer = null
214 isTurnRunning = false
215
216 on('session.start', async ($, e, next) => {
217 const result = await next(e)
218 isInteractive = e.isInteractive
219 await $.command.register({
220 name: 'relay',
221 description: 'Save state to durable notes, draft a continuation prompt, compact, and queue the prompt',
222 argumentHint: '[focus | status | cancel | paste]',
223 })
224 // A `saving` run holds no timer, so its turn.complete can still continue it.
225 const run = await read($, runAtom)
226 if (run.phase === 'continuing') {
227 await update($, runAtom, cur => ({ ...IDLE, runId: cur.runId + 1 }))
228 $.ui.log(
229 'relay: a reload interrupted the run after the save turn. If a continuation prompt was drafted, /relay paste has it; run /compact yourself first.',
230 )
231 }
232 return result
233 })
234
235 on('turn.start', async ($, e, next) => {
236 isTurnRunning = true
237 const run = await read($, runAtom)
238 if (run.phase === 'saving' && run.saveTurnId === null && e.text.includes(SAVE_MARKER)) {
239 await update($, runAtom, cur => (cur.runId === run.runId ? { ...cur, saveTurnId: e.turnId } : cur))
240 }
241 return next(e)
242 })
243
244 on('turn.complete', async ($, e, next) => {
245 const result = await next(e)
246 if (e.agentId !== undefined) return result
247 isTurnRunning = false
248 const run = await read($, runAtom)
249 if (run.phase !== 'saving' || run.saveTurnId !== e.turnId) return result
250 if (e.reason !== 'answer') {
251 await finish($, run.runId, `stopped: the save turn ended (${e.reason}). Nothing was compacted.`)
252 return result
253 }
254 const now = await update($, runAtom, cur =>
255 cur.runId === run.runId && cur.phase === 'saving' ? { ...cur, phase: 'continuing' as const } : cur,
256 )
257 if (now.runId !== run.runId || now.phase !== 'continuing' || now.notePath === null) return result
258 const notePath = now.notePath
259 // Compaction rejects while a turn runs, so continue once this one has ended.
260 cancelTimer()
261 timer = $.clock.after(0, () => void continueRun($, run.runId, e.answer, notePath))
262 return result
263 })
264
265 on('command.run', { command: 'relay' }, async ($, e) => {
266 const cmd = parseArgs(e.args)
267 const run = await read($, runAtom)
268
269 switch (cmd.kind) {
270 case 'status': {
271 if (run.phase === 'idle') return { text: 'Idle.' }
272 const minutes = run.startedAt === null ? 0 : Math.round(((await $.clock.now()) - run.startedAt) / 60_000)
273 return { text: `${run.phase === 'saving' ? 'Saving' : 'Continuing'}, started ${minutes} min ago. /relay cancel to stop.` }
274 }
275
276 case 'cancel': {
277 if (run.phase === 'idle') return { text: 'Nothing to cancel.' }
278 cancelTimer()
279 await update($, runAtom, cur => ({ ...IDLE, runId: cur.runId + 1 }))
280 $.ui.status(undefined)
281 return { text: 'Cancelled. Anything already saved or compacted stays.' }
282 }
283
284 case 'paste': {
285 const saved = (await $.store.get('lastPrompt')) as SavedPrompt | undefined
286 if (!saved?.text) return { text: 'No saved continuation prompt.' }
287 const isFilled = await fill($, saved.text).catch(() => false)
288 const when = new Date(saved.at).toISOString()
289 return {
290 text: isFilled
291 ? `Filled the continuation prompt saved ${when} (session ${saved.sessionId}).`
292 : `Couldn't fill the prompt box. Saved ${when} (session ${saved.sessionId}):\n\n${saved.text}`,
293 }
294 }
295
296 case 'start': {
297 if (run.phase !== 'idle') return { text: `Already ${run.phase}. /relay status, or /relay cancel to reset.` }
298 if ((await $.session.turns()) === 0) return { text: 'Nothing to hand off yet.' }
299 const runId = run.runId + 1
300 const startedAt = await $.clock.now()
301 const noteDir = config.noteDir || `${(await $.env.get('TMPDIR')) || '/tmp'}`.replace(/\/+$/, '') + '/claude-relay'
302 const notePath = notePathFor(noteDir, await $.session.root(), startedAt)
303 await update($, runAtom, () => ({ phase: 'saving' as const, runId, saveTurnId: null, startedAt, notePath }))
304 $.ui.status('saving state…')
305 // The host refuses a submit from inside command.run (it would wait on
306 // the turn this hook holds), so submit once the command has returned.
307 const text = savePrompt(cmd.focus, notePath)
308 cancelTimer()
309 timer = $.clock.after(0, () => {
310 submitSave($, runId, text).catch(err =>
311 finish($, runId, `stopped: couldn't submit the save prompt (${errorText(err)}).`),
312 )
313 })
314 return { text: 'Saving state, then drafting a continuation prompt and compacting.' }
315 }
316 }
317 })
318}
319hooks/prompts.ts 99 lines1import type { Command } from '../types'
2
3/** Leads the save prompt, so the save turn can be told apart by its text. */
4export const SAVE_MARKER = '[relay] Checkpoint before compaction.'
5
6/**
7 * Where the handoff note goes: `dir` (default: the system temp directory's
8 * claude-relay/), named for the project and the UTC time the run started.
9 */
10export function notePathFor(dir: string, projectRoot: string, nowMs: number): string {
11 const project = (projectRoot.replace(/\/+$/, '').split('/').pop() || 'session').replace(/[^A-Za-z0-9._-]/g, '-')
12 const stamp = new Date(nowMs).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
13 return `${dir.replace(/\/+$/, '')}/${project}-${stamp}.md`
14}
15
16/** The save turn's prompt: update stale docs, then end with the handoff note, which the plugin saves. */
17export function savePrompt(focus: string, notePath: string): string {
18 const lines = [
19 `${SAVE_MARKER} The conversation will be compacted after this turn, and the next session starts from what you leave now.`,
20 '',
21 "1. Update the project's docs and notes that this session's work made stale, in place, following CLAUDE.md / AGENTS.md. Don't create a handoff note in the project.",
22 `2. Then, after your last tool call, end with the handoff note inside <handoff-note> and </handoff-note> tags, each on its own line. What's inside the tags is saved to ${notePath} for the next session; don't write that file yourself.`,
23 '3. In the note, record what is done; what is in progress, exactly where it stands (branches, uncommitted changes, running jobs, open PRs); decisions and why; what you learned; issues, gotchas and workarounds; next steps, each with a disposition (do / skip / defer); and the files you wrote or updated this session.',
24 "4. Link to what's already durable instead of repeating it.",
25 '',
26 "Don't start new work.",
27 ]
28 if (focus) lines.push('', `Focus: ${focus}`)
29 return lines.join('\n')
30}
31
32/** The side question asked over the transcript once the save turn ends (what `/btw` would be asked). */
33export const CONTINUATION_PROMPT =
34 'Give me a concise, copy/paste-ready prompt to continue in the next session. ' +
35 'Point to the files the next agent should read immediately, each path prefixed with `@`. ' +
36 "Add anything else they need to know that those files don't say, without repeating what they do. " +
37 'Output only the prompt: no preamble, no wrapper formatting, no code fence.'
38
39/** The note inside the save turn's <handoff-note> tags, or the whole message when it has none. */
40export function extractNote(answer: string): string {
41 const tagged = /<handoff-note>([\s\S]*?)<\/handoff-note>/.exec(answer)
42 return (tagged ? (tagged[1] ?? '') : answer).trim()
43}
44
45/** Longest stretch of the note quoted into the fork prompt. */
46const NOTE_LIMIT = 8000
47
48/**
49 * The fork replays the main thread's last request, which ends before the save
50 * turn's final message (the handoff note), so the note rides along.
51 */
52export function continuationPrompt(note: string, notePath: string): string {
53 const trimmed = note.trim()
54 const quoted = trimmed.length > NOTE_LIMIT ? `${trimmed.slice(0, NOTE_LIMIT)}\n[…]` : trimmed
55 return (
56 `The checkpoint turn's final message is the handoff note, saved to ${notePath}:\n\n` +
57 `<handoff-note path="${notePath}">\n${quoted}\n</handoff-note>\n\n` +
58 `List @${notePath} first among the files to read.\n\n${CONTINUATION_PROMPT}`
59 )
60}
61
62/** Drops one code fence wrapping the whole reply, and surrounding whitespace. */
63export function cleanContinuation(text: string): string {
64 const trimmed = text.trim()
65 const fenced = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n?\1$/.exec(trimmed)
66 if (!fenced) return trimmed
67 const [, fence = '', inner = ''] = fenced
68 // A line opening the same fence inside means several blocks, not one wrapper.
69 const opensAgain = inner.split('\n').some(line => line.trimStart().startsWith(fence))
70 return opensAgain ? trimmed : inner.trim()
71}
72
73/** A plugin's submitted prompt doesn't expand `@` mentions, so the model is told to read them. */
74export function forSubmit(text: string): string {
75 return `Read every file below whose path is prefixed with @ before doing anything else (they are not attached automatically), then continue.\n\n${text}`
76}
77
78/**
79 * Whether a compaction rejection means the person cancelled it (Esc or
80 * Ctrl+C). Claude Code 2.1.294 rejects with "Compaction canceled.".
81 */
82export function isCanceled(message: string): boolean {
83 return /\bcancel(?:l?ed)?\b/i.test(message)
84}
85
86export function parseArgs(args: string): Command {
87 const trimmed = args.trim()
88 switch (trimmed.toLowerCase()) {
89 case 'status':
90 return { kind: 'status' }
91 case 'cancel':
92 return { kind: 'cancel' }
93 case 'paste':
94 return { kind: 'paste' }
95 default:
96 return { kind: 'start', focus: trimmed }
97 }
98}
99types/index.d.ts 46 lines1export type Deliver = 'fill' | 'submit'
2
3export type Config = {
4 deliver: Deliver
5 compactInstructions: string
6 copyToClipboard: boolean
7 /** Where handoff notes go; empty for the system temp directory's claude-relay/. */
8 noteDir: string
9}
10
11/**
12 * idle: nothing running. saving: the save turn was submitted and hasn't
13 * ended. continuing: the save turn ended; forking, compacting, delivering.
14 */
15export type Phase = 'idle' | 'saving' | 'continuing'
16
17export type Run = {
18 phase: Phase
19 /** Bumped on every start and cancel, so a stale continuation stops itself. */
20 runId: number
21 /** The save turn's id, once its turn.start is seen. */
22 saveTurnId: string | null
23 startedAt: number | null
24 /** Where the save turn's final message (the handoff note) is written. */
25 notePath: string | null
26}
27
28/** The last continuation prompt, kept in $.store across sessions for /relay paste. */
29export type SavedPrompt = {
30 text: string
31 at: number
32 sessionId: string
33}
34
35export type Command =
36 | { kind: 'start'; focus: string }
37 | { kind: 'status' }
38 | { kind: 'cancel' }
39 | { kind: 'paste' }
40
41declare module 'claude-code' {
42 interface PluginState {
43 relay: { run: Run }
44 }
45}
46