SLOPSHOPPER

relay

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

newcommandtoaststatusmodeltimer
v0.2.0MITupdated 2026-10-08chrisvaillancourt/claude-relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ⎿ relay: Saving state, then drafting a continuation prompt and compacting. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ relay: saving state…
README

relay

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:

  1. Saves. It submits a save turn. The model updates the project's docs and notes that the session made stale, in place, then ends the turn with a handoff note inside <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.

  1. Drafts the continuation prompt. Once the save turn ends, it asks a side question over the transcript with $.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.
  2. Compacts. It runs the same compaction /compact does, with your instructions (default: keep what's helpful and not written to a durable location).
  3. Delivers. It fills the prompt box with the continuation prompt, ahead of anything you'd typed there. Review it and press Enter. A typed prompt expands @ mentions; a plugin's submitted prompt doesn't.

If any step fails, the run stops before the next one and logs why:

  • A failed or interrupted save turn, a missing note, a note that can't be written, or a failed fork never compacts.
  • If you cancel the compaction (Esc or Ctrl+C), the run stops; the prompt stays saved for /relay paste.
  • If you start another turn before compaction (say, a prompt you queued during the save turn), the run stops rather than compacting over it.
  • If compaction fails or is skipped, nothing is filled; the prompt stays saved for /relay paste. A rejected compaction is retried twice, 2 s apart, in case the save turn is still winding down.
  • If the save turn can't be identified within 5 s of starting (another plugin rewrote the prompt), the run stops.

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.

Command

/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.

Options

Set in /config (or pluginConfigs.relay.options in settings):

OptionDefault
deliverfillfill 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.
compactInstructionsKeep any information that's helpful to the agent and not written to a durable location.What the compaction summary keeps.
noteDiremptyDirectory for handoff notes. Empty: $TMPDIR/claude-relay (else /tmp/claude-relay).
copyToClipboardtrueAlso copy the continuation prompt to the clipboard.

The save and continuation prompts live in hooks/prompts.ts.

Install

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>.

Develop

claude plugin validate .
claude plugin test .

To try it without installing: claude --plugin-dir ..

License

MIT.

Source 3 files
hooks/register.ts 319 lines
1import { 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}
319
hooks/prompts.ts 99 lines
1import 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}
99
types/index.d.ts 46 lines
1export 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