A side panel and an Open relay button that summarize the conversation and continue it in a fresh session, when you choose.

Hands a long conversation over to a fresh Claude Desktop session, in one click, when you choose.
In a Claude Code session:
/plugin install context-relay --marketplace Nelahia/claude-kit
Answer y to add the marketplace, then pick the user scope. Or from a terminal:
claude plugin marketplace add Nelahia/claude-kit
claude plugin install context-relay@nelahia-kit
Mods load when a conversation starts, so open a new conversation to see the Relay button above the prompt. Windows and the Claude Desktop app are required.
<img src="docs/demo.gif" width="720" alt="Demo: click Relay, the five steps run, a new session opens with the summary and sends the first message">
<img src="docs/button.png" width="520" alt="The Relay button above the prompt, beside the usage bars">
A long session gets slow, expensive and fuzzy: the context fills up, old details drown the useful ones, and /compact loses the thread. Starting over loses everything. Relay keeps the thread and drops the weight.
You are halfway through a refactor. Instead of compacting and hoping, click Relay. A few seconds later a clean session opens that already knows:
register.tsx into hooks and services".saveHandoff, then run the tests".try/catch per handler, one global error handler" (so it does not reopen the debate).You keep working at 5% context instead of 80%, and the 5h and 7d bars thank you.
Relay is also a clean pause button. The summary is saved as relais-<folder>.md; the next session opens on it, with the end of your last two messages, so the open question is the first thing it sees. No "where were we?".
Switch model, switch effort, or just escape a session that went down a wrong path. The new session gets the decisions, not the dead ends.
The summary points to specs, issues and commits instead of copying them. Secrets (API keys, tokens, password=…, private keys) and emails are masked in the summary and in the copied messages.
/context-relay).<img src="docs/panel.png" width="360" alt="The side panel showing the five steps of a handoff">
A side panel shows each step while it runs, and the time it took.
/context-relay: run the handoff from the prompt.openInFolder (default true): open the new session in the same folder as the current one. The app may ask to trust the folder, which slows the first message. Set it to false and the new session opens in the app's default folder instead.Windows only: the first message is sent with a PowerShell UI Automation script. The deep link carries at most about 7000 encoded characters: the summary is cut first (Shipped goes first), then the last messages.
hooks/register.tsx 186 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { RelayRun, RelayStep } from '../types'
5import { registerRelayPanel } from './relay-panel'
6import { RELAY_FILE, RELAY_PROMPT, buildResume } from './relay-prompt'
7import { redact } from './relay-redact'
8import { STEPS, safeName, secs } from './relay-steps'
9import { sendScript } from './send-script'
10
11// The handoff runs on demand: the panel and the "Open relay" button are the only entry points.
12// Helpers that receive `$` live in this entry file; the panel and the bar only draw.
13const PANE = 'context-relay'
14const steps = atom({ plugin: 'context-relay', key: 'steps' } as const, [])
15const runState = atom({ plugin: 'context-relay', key: 'run' } as const, null)
16const isBusy = atom({ plugin: 'context-relay', key: 'isBusy' } as const, false)
17const tempDir = atom({ plugin: 'context-relay', key: 'tempDir' } as const, null)
18
19/** Temporary diagnostic: leaves a file in the temp folder each time a hook really runs in the app. */
20async function trace($: any, label: string): Promise<void> {
21 try {
22 await $.fs.write(`C:/Users/nelah/AppData/Local/Temp/relay-trace-${label}.txt`, `${await $.clock.now()}\n`)
23 } catch {
24 // A trace must never break the hook.
25 }
26}
27
28/** Reads the OS temp folder (once per session; the handoff starts it before the summary). */
29async function warmTemp($: any): Promise<void> {
30 try {
31 const out = await $.process.run(['powershell', '-NoProfile', '-Command', '[IO.Path]::GetTempPath()'], { timeoutMs: 8000 })
32 const dir = String(out.stdout).trim().replace(/[\\/]+$/, '')
33 if (out.exitCode === 0 && dir !== '') await update($, tempDir, () => dir.replace(/\\/g, '/'))
34 } catch {
35 // The handoff falls back to the project folder and says so.
36 }
37}
38
39/** Writes the handoff in the OS temp folder; says so in the step when it had to use the project. */
40async function saveHandoff($: any, cwd: string, handoff: string): Promise<{ file: string; note: string }> {
41 const dir = await read($, tempDir)
42 if (dir) {
43 const file = `${dir}/relais-${safeName(cwd)}.md`
44 try {
45 await $.fs.write(file, `${handoff}\n`)
46
47 return { file, note: '' }
48 } catch {
49 // Falls through to the project folder.
50 }
51 }
52 const file = `${cwd}/${RELAY_FILE}`
53 await $.fs.write(file, `${handoff}\n`)
54
55 return { file, note: ' (temp folder unavailable, saved in the project)' }
56}
57
58async function mark($: any, id: string, status: RelayStep['status'], detail?: string) {
59 const now: number = await $.clock.now()
60 await update($, steps, (list: RelayStep[]) =>
61 list.map(s =>
62 s.id !== id
63 ? s
64 : { ...s, status, detail, startedAt: status === 'run' ? now : s.startedAt, endedAt: status === 'run' ? undefined : now },
65 ),
66 )
67}
68
69async function patchRun($: any, patch: Partial<RelayRun>) {
70 await update($, runState, (cur: RelayRun | null) => ({ ...(cur ?? { startedAt: 0 }), ...patch }))
71}
72
73async function finish($: any, ok: boolean, result: string): Promise<string> {
74 await patchRun($, { ok, result, endedAt: await $.clock.now() })
75
76 return result
77}
78
79/** Live handoff: summary, file, then a new session started from the app's deep link. */
80async function relay($: any, withFolder: boolean): Promise<string> {
81 if (await read($, isBusy)) return 'A handoff is already running.'
82 await update($, isBusy, () => true)
83 await update($, steps, () => STEPS)
84 await update($, runState, () => ({ startedAt: 0 }))
85 await patchRun($, { startedAt: await $.clock.now() })
86 await $.ui.open({ id: PANE, title: 'Context relay' })
87 try {
88 await mark($, 'read', 'run')
89 const cwd: string = await $.session.cwd()
90 const model: string = await $.session.model()
91 const { context } = await $.session.usage()
92 await patchRun($, { cwd, model, percent: context.percent ?? 0 })
93 await mark($, 'read', 'ok', 'Folder, model and context size')
94
95 // The temp folder lookup runs while the model writes the summary, so it costs no extra wait.
96 const warming = (await read($, tempDir)) ? Promise.resolve() : warmTemp($)
97 await mark($, 'sum', 'run')
98 const t0: number = await $.clock.now()
99 const reply = await $.model.fork({ prompt: RELAY_PROMPT })
100 if (!reply.isAnswered) {
101 await mark($, 'sum', 'fail', reply.reason)
102 return await finish($, false, `Could not summarize: ${reply.reason}`)
103 }
104 const handoff: string = redact(reply.text.trim())
105 await mark($, 'sum', 'ok', `Written by the model in ${secs((await $.clock.now()) - t0)}`)
106
107 await mark($, 'file', 'run')
108 await warming
109 const { file, note } = await saveHandoff($, cwd, handoff)
110 await mark($, 'file', 'ok', `${file}${note}`)
111
112 await mark($, 'launch', 'run')
113 const history: { role: string; text: string }[] = await $.session.messages()
114 const resume = buildResume(handoff, history, cwd, file)
115 // The `folder` parameter makes the app ask to trust the folder, which slows the send.
116 const folder = withFolder ? `folder=${encodeURIComponent(cwd.replace(/\//g, '\\'))}&` : ''
117 const link = `claude://code/new?${folder}q=${encodeURIComponent(resume)}`
118 await mark($, 'send', 'run')
119 // The sender starts first so PowerShell's startup overlaps with the new session loading.
120 const sending = $.process.run(['powershell', '-NoProfile', '-Command', sendScript(`${file.replace(/\.md$/, '')}-send.log`)], {
121 cwd,
122 timeoutMs: 40000,
123 })
124 const opened = await $.process.run(['rundll32.exe', 'url.dll,FileProtocolHandler', link], { cwd, timeoutMs: 15000 })
125 if (opened.exitCode !== 0) {
126 await mark($, 'launch', 'fail', `Windows error ${opened.exitCode}`)
127 return await finish($, false, 'Windows could not open the Claude link.')
128 }
129 await mark($, 'launch', 'ok', 'Handoff and end of the chat in the prompt')
130
131 const sent = (await sending).stdout.includes('sent')
132 await mark($, 'send', sent ? 'ok' : 'fail', sent ? 'Message sent' : 'Claude did not take focus')
133
134 return await finish($, sent, sent ? 'The first message was sent.' : 'Press Enter in the new session.')
135 } catch (error) {
136 return await finish($, false, String(error))
137 } finally {
138 await update($, isBusy, () => false)
139 }
140}
141
142export const register: Register = (on, options) => {
143 const withFolder = options.openInFolder !== false
144
145 registerRelayPanel(on)
146
147 on('session.start', async ($, e, next) => {
148 await trace($, 'session-start')
149 await $.command.register({ name: 'context-relay', description: 'Hand this conversation to a new session' })
150
151 return next(e)
152 })
153
154 on('command.run', { command: 'context-relay' }, async $ => {
155 await trace($, 'command-run')
156 try {
157 return { text: await relay($, withFolder) }
158 } catch (error) {
159 return { text: `Relay failed: ${String(error)}` }
160 }
161 })
162
163 // The button starts the handoff itself, beside whatever else draws above the prompt (the usage bars).
164 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165 if (e.props.hasSurvey || e.surface === 'terminal') return next(e)
166 const others = await next(e)
167 const { Box, Button } = $.ui.resolve(e)
168
169 return (
170 <Box flexDirection="column" gap={1}>
171 <Box paddingX={1}>
172 <Button
173 key="relay"
174 label="⤳ Relay"
175 onPress={async () => {
176 await trace($, 'press')
177 $.ui.toast(await relay($, withFolder))
178 }}
179 />
180 </Box>
181 {others}
182 </Box>
183 )
184 })
185}
186hooks/relay-panel.tsx 48 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { PANE_WIDTH, paneHeight, paneSvg } from './pane-svg'
5
6const PANE = 'context-relay'
7const steps = atom({ plugin: 'context-relay', key: 'steps' } as const, [])
8const runState = atom({ plugin: 'context-relay', key: 'run' } as const, null)
9const isBusy = atom({ plugin: 'context-relay', key: 'isBusy' } as const, false)
10
11const ICON = { wait: '○', run: '…', ok: '✓', fail: '✗' }
12
13/** The side panel a handoff opens: the live progress of its steps, with a Close button once it is over. */
14export function registerRelayPanel(on: Parameters<Register>[0]): void {
15 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
16 const ui = $.ui.resolve(e)
17 const run = await read($, runState)
18 const list = await read($, steps)
19 const busy = await read($, isBusy)
20 const { Box, Button, Text } = ui
21 const Svg = 'Svg' in ui ? ui.Svg : null
22
23 return (
24 <Box flexDirection="column" gap={1}>
25 {Svg && run ? (
26 <Svg source={paneSvg(list, run)} alt="Handoff progress" width={PANE_WIDTH} height={paneHeight(list, run)} isInteractive />
27 ) : (
28 list.map(s => (
29 <Text key={s.id} dimColor={s.status === 'wait'}>
30 {ICON[s.status]} {s.label} {s.detail ?? ''}
31 </Text>
32 ))
33 )}
34 {!busy && (
35 <Button
36 key="close"
37 label="Close"
38 onPress={async () => {
39 await update($, runState, () => null)
40 await $.ui.close({ id: PANE })
41 }}
42 />
43 )}
44 </Box>
45 )
46 })
47}
48hooks/relay-prompt.ts 86 lines1import { redact } from './relay-redact'
2import { isSmallTalk, unlink } from './relay-text'
3
4export const RELAY_FILE = 'relais.md'
5export const RESUME_MARK = 'You are continuing a previous session'
6
7// Encoded characters the prompt may take inside the claude:// deep link.
8const LINK_BUDGET = 7000
9const TAIL_MESSAGES = 2
10const TAIL_SIZES = [700, 300, 0]
11const SUMMARY_FLOOR = 1500
12
13// Shipped comes last on purpose: when the prompt must be cut, the cut falls on the least useful part.
14export const RELAY_PROMPT = `Summarize this conversation so a fresh session can pick it up without losing anything.
15Answer ONLY in English markdown, with exactly these five sections, in this order:
16
17# Handoff
18
19## Goal
20(what this conversation is for, in 1 to 3 sentences)
21
22## Up next
23(bullet list, in order, the next action first)
24
25## Locked in
26(bullet list of decisions made and why, not to be reopened)
27
28## Suggested skills
29(skills the next session should invoke, one per line with a few words on why; write "none" if no skill applies)
30
31## Shipped
32(bullet list of what is finished, with file paths when useful)
33
34Rules:
35- Keep the whole answer under 2500 characters.
36- Do not copy what already lives in a spec, plan, issue, commit or diff: point to it by path or URL instead.
37- Never include secrets or personal data (passwords, tokens, API keys, emails, phone numbers, addresses). Write "[redacted]" if one is needed to make sense.
38- Describe the current state, not the conversation: never write "the user said" or "the assistant did".
39- In Locked in, keep only decisions specific to this task; leave general repo conventions (commit style, push rules) to the project CLAUDE.md.
40- Use plain paths and commit hashes, no markdown links.
41- Be concise and factual. Invent nothing.`
42
43const flat = (t: string) => t.replace(/\s+/g, ' ').trim()
44const head = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)}…` : t)
45const tailOf = (t: string, n: number) => (t.length > n ? `…${t.slice(-n)}` : t)
46const cost = (t: string) => encodeURIComponent(t).length
47
48/**
49 * The prompt that opens the new session: handoff, end of the old chat, then the ask.
50 * Sized by its encoded length, since spaces, newlines and accents all swell in a URL. The end of
51 * each message is kept (that is where the open question is) and the summary is cut from the back.
52 */
53export const buildResume = (
54 handoff: string,
55 history: { role: string; text: string }[],
56 cwd: string,
57 file: string,
58): string => {
59 const messages = history.filter(m => m.text && m.text.trim() !== '' && !isSmallTalk(m.text)).slice(-TAIL_MESSAGES)
60 const compose = (summaryMax: number, tailMax: number): string =>
61 [
62 `${RESUME_MARK}. Previous working folder: ${cwd}. The full handoff is also saved at ${file}.`,
63 head(unlink(redact(handoff)), summaryMax),
64 tailMax > 0 && messages.length > 0
65 ? `End of the previous conversation:\n\n${messages.map(m => `[${m.role}] ${tailOf(flat(redact(m.text)), tailMax)}`).join('\n\n')}`
66 : '',
67 'Summarize Goal and Up next in two lines, then continue from Up next.',
68 ]
69 .filter(Boolean)
70 .join('\n\n')
71
72 // Cut the summary first (down to a floor), then the messages, so the open question is the last thing to go.
73 let text = ''
74 for (const tailMax of TAIL_SIZES) {
75 let summaryMax = handoff.length
76 text = compose(summaryMax, tailMax)
77 while (summaryMax > SUMMARY_FLOOR && cost(text) > LINK_BUDGET) {
78 summaryMax = Math.max(SUMMARY_FLOOR, Math.floor(summaryMax * 0.85))
79 text = compose(summaryMax, tailMax)
80 }
81 if (cost(text) <= LINK_BUDGET) return text
82 }
83
84 return text
85}
86hooks/relay-redact.ts 23 lines1const MASK = '[redacted]'
2
3// Each pattern matches one family of secret or personal data. Order matters: the long blocks go first.
4const PATTERNS: RegExp[] = [
5 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
6 /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@/gi,
7 /\bsk-[A-Za-z0-9_-]{16,}/g,
8 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
9 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
10 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
11 /\bAKIA[0-9A-Z]{16}\b/g,
12 /\bAIza[0-9A-Za-z_-]{30,}/g,
13 /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
14 /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi,
15 // A password or secret is masked from 4 characters; a token or key from 8, never a call like getToken().
16 /\b(?:password|passwd|pwd|secret)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}["']?/gi,
17 /\b(?:token|api[_-]?key|access[_-]?key)\b["']?\s*[:=]\s*["']?[^\s"',;()]{8,}(?!\()["']?/gi,
18 /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g,
19]
20
21/** Replaces secrets and personal data (keys, tokens, passwords, credentials in URLs, e-mails) with a mask. */
22export const redact = (text: string): string => PATTERNS.reduce((out, pattern) => out.replace(pattern, MASK), text)
23hooks/relay-steps.ts 15 lines1import type { RelayStep } from '../types'
2
3export const STEPS: RelayStep[] = [
4 { id: 'read', label: 'Read this session', status: 'wait' },
5 { id: 'sum', label: 'Summarize the conversation', status: 'wait' },
6 { id: 'file', label: 'Save the handoff', status: 'wait' },
7 { id: 'launch', label: 'Open a new session', status: 'wait' },
8 { id: 'send', label: 'Send the first message', status: 'wait' },
9]
10
11export const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s`
12
13/** The project folder's name made safe for a file name (and a PowerShell string). */
14export const safeName = (cwd: string) => (cwd.split(/[\\/]/).filter(Boolean).pop() ?? 'session').replace(/[^\w.-]+/g, '_')
15hooks/send-script.ts 31 lines1import { RESUME_MARK } from './relay-prompt'
2
3const A = 'System.Windows.Automation'
4// Inside a single-quoted PowerShell string, a quote is written twice.
5const quoted = (s: string) => s.replace(/'/g, "''")
6
7/**
8 * PowerShell that focuses Claude, accepts the workspace-trust dialog if shown, finds the
9 * prompt box through UI Automation, waits for the prefilled handoff, then presses Enter.
10 * Each step is logged to `log` so a silent failure can be read afterwards.
11 */
12export const sendScript = (log: string): string =>
13 [
14 `$log = '${quoted(log)}'; function L($m) { Add-Content $log ((Get-Date -Format o) + ' ' + $m) }`,
15 "L 'spawn'; Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes",
16 "L 'start'; $s = New-Object -ComObject WScript.Shell; $done = $false",
17 `$btn = [${A}.ControlType]::Button; $edit = [${A}.ControlType]::Edit`,
18 `$pid2 = [${A}.AutomationElement]::ProcessIdProperty; $typ = [${A}.AutomationElement]::ControlTypeProperty`,
19 "$id = (Get-Process claude | Where-Object { $_.MainWindowTitle -ne '' } | Select-Object -First 1).Id",
20 'foreach ($i in 1..40) {',
21 "[void]$s.AppActivate('Claude')",
22 `$w = [${A}.AutomationElement]::RootElement.FindFirst('Children', (New-Object ${A}.PropertyCondition($pid2, $id)))`,
23 `if ($w) { foreach ($b in $w.FindAll('Descendants', (New-Object ${A}.PropertyCondition($typ, $btn)))) {`,
24 `if ($b.Current.Name -match 'confiance|Trust') { $b.GetCurrentPattern([${A}.InvokePattern]::Pattern).Invoke(); L 'trusted'; Start-Sleep -Milliseconds 800; break } }`,
25 `$e = $w.FindFirst('Descendants', (New-Object ${A}.PropertyCondition($typ, $edit)))`,
26 `$t = ''; if ($e) { foreach ($c in $e.FindAll('Descendants', [${A}.Condition]::TrueCondition)) { $t += $c.Current.Name } }`,
27 `if ($e -and $t -match '${RESUME_MARK}') { $e.SetFocus(); Start-Sleep -Milliseconds 300; $s.SendKeys('{ENTER}'); L 'enter'; $done = $true; break } }`,
28 "L ('try ' + $i); Start-Sleep -Milliseconds 300 }",
29 "L ('end ' + $done); if ($done) { 'sent' }",
30 ].join('; ')
31hooks/pane-svg.ts 96 lines1import type { RelayRun, RelayStep } from '../types'
2
3export const PANE_WIDTH = 520
4const W = PANE_WIDTH
5const PAD = 8
6const ROW = 64
7const PITCH = 74
8const TOP = 118
9const ACCENT = '#6d5efc'
10const GREEN = '#17b26a'
11const RED = '#f04438'
12
13const CSS =
14 '.card{fill:#f5f6f9;stroke:#e2e5ec}.ink{fill:#14141a}.mute{fill:#6b6f7a}.track{fill:#dcdfe6}' +
15 '.ring{fill:none;stroke:#c4c8d2}' +
16 '@media (prefers-color-scheme:dark){.card{fill:#1e1e23;stroke:#2c2c34}.ink{fill:#f3f3f6}' +
17 '.mute{fill:#9a9ca7}.track{fill:#34343d}.ring{stroke:#4b4b56}}' +
18 "text{font-family:system-ui,'Segoe UI',sans-serif}.mono{font-family:ui-monospace,Consolas,monospace}"
19
20const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
21const cut = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
22const sec = (ms: number) => `${(ms / 1000).toFixed(1)}s`
23const mark = (d: string, extra = '') =>
24 `<path d="${d}" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" ${extra}/>`
25
26const icon = (status: RelayStep['status'], cx: number, cy: number): string => {
27 const at = `transform="translate(${cx} ${cy})"`
28 if (status === 'ok') return `<circle cx="${cx}" cy="${cy}" r="14" fill="${GREEN}"/>${mark('M-5 .5l3.5 3.5L6 -4', at)}`
29 if (status === 'fail') return `<circle cx="${cx}" cy="${cy}" r="14" fill="${RED}"/>${mark('M-4.5 -4.5l9 9M4.5 -4.5l-9 9', at)}`
30 if (status === 'wait') return `<circle cx="${cx}" cy="${cy}" r="11" class="ring" stroke-width="2"/>`
31
32 return (
33 `<circle cx="${cx}" cy="${cy}" r="11" class="ring" stroke-width="3"/>` +
34 `<circle cx="${cx}" cy="${cy}" r="11" fill="none" stroke="${ACCENT}" stroke-width="3" stroke-linecap="round" stroke-dasharray="26 44">` +
35 `<animateTransform attributeName="transform" type="rotate" from="0 ${cx} ${cy}" to="360 ${cx} ${cy}" dur="0.9s" repeatCount="indefinite"/></circle>`
36 )
37}
38
39const row = (s: RelayStep, i: number): string => {
40 const y = TOP + i * PITCH
41 const took = s.startedAt !== undefined && s.endedAt !== undefined ? sec(s.endedAt - s.startedAt) : s.status === 'run' ? '…' : ''
42 const edge = s.status === 'run' ? `stroke="${ACCENT}" stroke-width="1.5"` : 'stroke-width="1"'
43
44 return `<g${s.status === 'wait' ? ' opacity=".55"' : ''}>
45<rect class="card" x="${PAD}" y="${y}" width="${W - 2 * PAD}" height="${ROW}" rx="14" ${edge}/>${icon(s.status, PAD + 34, y + ROW / 2)}
46<text class="ink" x="72" y="${y + 27}" font-size="15" font-weight="600">${esc(s.label)}</text>
47<text class="mute" x="72" y="${y + 47}" font-size="12.5">${esc(cut(s.detail ?? '', 64))}</text>
48<text class="mute mono" x="${W - PAD - 18}" y="${y + 27}" font-size="12" text-anchor="end">${took}</text></g>`
49}
50
51const bar = (steps: RelayStep[]): string => {
52 const w = (W - 2 * PAD - 6 * (steps.length - 1)) / steps.length
53
54 return steps
55 .map((s, i) => {
56 const fill = s.status === 'ok' ? GREEN : s.status === 'fail' ? RED : s.status === 'run' ? ACCENT : ''
57 const anim = s.status === 'run' ? '<animate attributeName="opacity" values="1;.35;1" dur="1.2s" repeatCount="indefinite"/>' : ''
58
59 return `<rect class="${fill ? '' : 'track'}" ${fill ? `fill="${fill}"` : ''} x="${PAD + i * (w + 6)}" y="88" width="${w}" height="6" rx="3">${anim}</rect>`
60 })
61 .join('')
62}
63
64const header = (run: RelayRun | null): string => {
65 const done = run?.endedAt !== undefined
66 const tone = !done ? ACCENT : run?.ok ? GREEN : RED
67 const label = !done ? 'Working…' : `${run?.ok ? 'Done' : 'Failed'} · ${sec((run?.endedAt ?? 0) - (run?.startedAt ?? 0))}`
68
69 return `<text class="ink" x="${PAD}" y="30" font-size="22" font-weight="700">Handoff</text>
70<text class="mute" x="${PAD}" y="52" font-size="12.5">${esc(run?.model ?? '…')} · ${run?.percent ?? '–'}% context</text>
71<text class="mute mono" x="${PAD}" y="70" font-size="11.5">${esc(cut(run?.cwd ?? '', 60))}</text>
72<rect x="${W - PAD - 124}" y="12" width="124" height="28" rx="14" fill="${tone}" fill-opacity=".15"/>
73<text x="${W - PAD - 62}" y="31" font-size="12.5" font-weight="600" text-anchor="middle" fill="${tone}">${esc(label)}</text>`
74}
75
76const footer = (run: RelayRun | null, y: number): string => {
77 if (!run?.result) return ''
78 const tone = run.ok ? GREEN : RED
79
80 return `<rect x="${PAD}" y="${y}" width="${W - 2 * PAD}" height="62" rx="14" fill="${tone}" fill-opacity=".12" stroke="${tone}" stroke-opacity=".4"/>
81<text class="ink" x="${PAD + 18}" y="${y + 26}" font-size="14.5" font-weight="600">${run.ok ? 'New session is running' : 'Handoff stopped'}</text>
82<text class="mute" x="${PAD + 18}" y="${y + 46}" font-size="12.5">${esc(cut(run.result, 70))}</text>`
83}
84
85/** Height of the drawing, so the surface can be given its size explicitly. */
86export const paneHeight = (steps: RelayStep[], run: RelayRun | null): number =>
87 TOP + steps.length * PITCH + (run?.result ? 78 : 12)
88
89/** The handoff panel as one vector drawing: header, progress bar, step cards, result. */
90export const paneSvg = (steps: RelayStep[], run: RelayRun | null): string => {
91 const end = TOP + steps.length * PITCH
92 const height = paneHeight(steps, run)
93
94 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" width="${W}" height="${height}"><style>${CSS}</style>${header(run)}${bar(steps)}${steps.map(row).join('')}${footer(run, end + 6)}</svg>`
95}
96hooks/relay-text.ts 11 lines1// Words that only thank or acknowledge: a message made of nothing else says nothing about the work.
2const SMALL_TALK =
3 /\b(?:merci(?: beaucoup)?|thanks?|thank you|good|great|nice|perfect|parfait|super|top|cool|nickel|g[ée]nial|bravo|avec plaisir|de rien|you'?re welcome|np)\b/gi
4
5/** True for a message that is only thanks or acknowledgement ("good merci", "Thanks!"). */
6export const isSmallTalk = (text: string): boolean => text.replace(SMALL_TALK, '').replace(/[\s\p{P}\p{S}]+/gu, '') === ''
7
8/** `[a.html](a.html)` becomes `a.html`; `[docs](https://x)` becomes `docs (https://x)`: the same information in fewer characters. */
9export const unlink = (text: string): string =>
10 text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, target: string) => (label === target ? label : `${label} (${target})`))
11types/index.d.ts 30 lines1export type RelayStep = {
2 id: string
3 label: string
4 status: 'wait' | 'run' | 'ok' | 'fail'
5 detail?: string
6 startedAt?: number
7 endedAt?: number
8}
9
10export type RelayRun = {
11 startedAt: number
12 endedAt?: number
13 model?: string
14 percent?: number
15 cwd?: string
16 ok?: boolean
17 result?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'context-relay': {
23 tempDir: string | null
24 isBusy: boolean
25 steps: RelayStep[]
26 run: RelayRun | null
27 }
28 }
29}
30