SLOPSHOPPER

context-relay

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

newpanebandcommandtoastmodel
★ 1v0.1.0MITupdated 2026-10-13Nelahia/claude-kit/mods/context-relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-relay
│ ┃ Context relay ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ Read this session Folder, model and │ ┃ context size ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ✓ Summarize the conversation Written by the ⏺ Update(src/auth.ts) │ ┃ model in 0.0s ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ✓ Save the handoff /work/app/relais.md (temp ⎿ 3 pass, 1 fail │ ┃ folder unavailable, saved in the project) │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Open a new session Handoff and end of the │ ┃ chat in the prompt ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ✗ Send the first message Claude did not take › /context-relay │ ┃ focus ⎿ context-relay: Press Enter in the new session. │ ┃ │ ┃ [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context relay
✓ Read this session Folder, model and context size ✓ Summarize the conversation Written by the model in 0.0s ✓ Save the handoff /work/app/relais.md (temp folder unavailable, saved in the project) ✓ Open a new session Handoff and end of the chat in the prompt ✗ Send the first message Claude did not take focus [ Close ]
README

context-relay

Hands a long conversation over to a fresh Claude Desktop session, in one click, when you choose.

Install

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

Why

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.

Your context hits 80%, and you are mid-feature

You are halfway through a refactor. Instead of compacting and hoping, click Relay. A few seconds later a clean session opens that already knows:

  • Goal: "split register.tsx into hooks and services".
  • Up next: "extract saveHandoff, then run the tests".
  • Locked in: "no try/catch per handler, one global error handler" (so it does not reopen the debate).
  • Suggested skills: the ones worth invoking next.

You keep working at 5% context instead of 80%, and the 5h and 7d bars thank you.

You stop on Friday, you resume on Monday

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

You want a fresh start without re-explaining

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.

Nothing sensitive travels

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.

How it works

  1. Click Relay (or run /context-relay).
  2. The model writes a summary under 2500 characters, in this order: Goal, Up next, Locked in, Suggested skills, Shipped.
  3. It is saved in the OS temp folder.
  4. A new session opens with the summary and the end of the last 2 messages already typed.
  5. The first message is sent for you.

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

Command

  • /context-relay: run the handoff from the prompt.

Option

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

Limits

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.

Source 9 files
hooks/register.tsx 186 lines
1import { 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}
186
hooks/relay-panel.tsx 48 lines
1import { 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}
48
hooks/relay-prompt.ts 86 lines
1import { 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}
86
hooks/relay-redact.ts 23 lines
1const 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)
23
hooks/relay-steps.ts 15 lines
1import 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, '_')
15
hooks/send-script.ts 31 lines
1import { 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('; ')
31
hooks/pane-svg.ts 96 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
96
hooks/relay-text.ts 11 lines
1// 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})`))
11
types/index.d.ts 30 lines
1export 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