SLOPSHOPPER

session-relay

Passes prompts from a planning session to the session that does the work and its answers back, without copy and paste

newpanebandspinnercommandtoast
★ 2v0.1.0MITupdated 2026-10-16MichaelP17/claude-mods/session-relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-relay
│ ┃ Relay ✕ › fix the failing auth test and add an audit log call │ ┃ ⇄ Relay │ ┃ ⏺ Read(src/auth.ts) │ ┃ This folder is not linked to a relay ⎿ Read 6 lines │ ┃ channel. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ /relay lead <channel> in the planning ⏺ Bash(bun test) │ ┃ session, /relay worker [channel] in the ⎿ 3 pass, 1 fail │ ┃ session that does the work, /relay send │ ┃ [text], /relay off, /relay alone shows what ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ is pending. │ ✻ Worked for 42s · done 4:20 PM │ │ › /relay │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Relay
⇄ Relay This folder is not linked to a relay channel. /relay lead <channel> in the planning session, /relay worker [channel] in the session that does the work, /relay send [text], /relay off, /relay alone shows what is pending.
README

session-relay

Passes prompts from a planning session to the session that does the work, and its answers back, without copy and paste.

One session plans and writes the prompts, the lead. Another one carries them out, the worker. Without this mod every prompt and every report travels through the clipboard, and text copied out of the fullscreen terminal loses characters at its line ends: a prompt arrives with "the real foed" where "the real footage" was written. Here both directions are a press, and the text arrives as it was written.

Setup

WhereCommand
The lead's folder/relay lead <channel>
The worker's folder/relay worker [channel]; without a name the folder's name, clued for ~/Projects/Clued

Each folder remembers its role: every later session started there joins the channel by itself, /clear included. /relay off ends that.

What you see

WhenWhereWhat
The lead's answer holds a prompt in a fenced blocklead, band above the promptSend, Send as fresh session, Other block when there are several, View, Dismiss
A task arrivedworker, bandClear & send runs /clear first, Send here goes into the running conversation; the one the lead chose is the primary button. View, Discard
The worker finished a turnlead, toast, notification and bandAttach to next prompt, Forward now, View, Discard
A session is linkedfooter under the promptlead ⇄ clued ⚙ 12m 📨 1, worker ⇄ clued worker 📥

In the footer, offline means no worker session is open, ⚙ 12m that the worker has been busy for 12 minutes, 📨 answers waiting and 📎 answers attached to the next prompt; on the worker, 📥 is a waiting task and ⏸ that another session in the folder is the worker. The pane spells the same out in words.

Attach to next prompt sends the answers along with the next prompt you type in the lead, as context the model reads beside it: you type only your feedback. Forward now sends them at once as your message.

A fenced block counts as a prompt from 300 characters on, when it has no language or is tagged text, markdown or prompt. Blocks tagged bash, json and the like are code to read and never offered.

CommandEffect
/relayA pane with everything pending in full, and the actions; in the desktop app the prompt, the answers and the task are cards that show their Markdown, each under a bar that names it and carries its actions
/relay send [text]Sends the text as typed; without text the last fenced block of the last answer
/relay offLeaves the channel; this folder no longer joins it

Nothing reaches a model without a press

A task waits in the worker until you press Clear & send or Send here there, and an answer waits in the lead until you attach or forward it. The relay travels through files any process of yours can write; the press in the session itself is what makes the text your prompt. The worker then reads the task as your own words.

How it travels

Everything lives under ~/.claude/relay/<channel>/: one JSON file per task in to-worker/, one per answer in to-lead/, and worker.json, which names the worker session, whether it is working, and a heartbeat every 30 seconds. Each linked session looks every two seconds.

So it works between the terminal and the desktop app alike, and nothing is lost while a session is closed: an answer that arrives at night waits in the lead's band the next morning, and a task sent to a worker nobody has opened yet is there when it opens.

Details and limits

  • The worker is the session in the worker folder used last: a prompt there takes the slot over. A second session in the same folder shows another session is the worker and a Take over button.
  • Every finished turn of the worker goes back, not only the ones a task started: an answer to a question typed in the worker belongs to the work as well. The lead keeps the last ten answers, each cut at 100,000 characters.
  • Only the final text of a worker turn travels; text Claude wrote between tool calls stays in the worker.
  • The offer belongs to the answer that just ended: typing a prompt in the lead drops it. /relay send takes the last block later.
  • A new task replaces one the worker has not picked up yet; the toast says so.
  • One worker and any number of leads per channel. A channel name is letters, digits, dots, dashes and underscores.
  • The notification goes through Claude Code's notification channel and is skipped where there is none; the toast always shows.
  • The mobile app draws no band; /relay opens the pane there.
  • With mod-hub loaded, the desktop footer leaves the relay label to its 🧰, whose Relay row shows it and opens the pane.

Configuration

OptionDefaultMeaning
minPromptChars300Shortest fenced block offered as a prompt
notifytrueRaise a notification in the lead when an answer arrives

Change them in /config, or in ~/.claude/settings.json under pluginConfigs["session-relay"].options.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Pending tasks and answers stay in ~/.claude/relay/ until you delete it.

Source 6 files
hooks/register.tsx 1057 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Answer, Link, Presence, Task } from '../types'
5import type { HubCall, HubEntry } from './hub'
6import { bandCard, barCard, buttonWidthOf, headerCard, messageCards } from './look'
7import type { Card } from './look'
8import { ROOMY_WIDTH, cardWidthOf } from './svg'
9import {
10  USAGE,
11  channelOf,
12  folderNameOf,
13  footerOf,
14  formatChars,
15  forwardTextOf,
16  isAnswer,
17  isLink,
18  isOnline,
19  isPresence,
20  isTask,
21  newId,
22  parseCommand,
23  previewOf,
24  promptBlocksOf,
25  statusOf,
26} from './relay'
27
28const PANE = 'relay'
29const LINK_PREFIX = 'link:'
30const POLL_MS = 2_000
31const HEARTBEAT_MS = 30_000
32const MAX_ANSWER_CHARS = 100_000
33const MAX_ANSWERS = 10
34
35const link = atom({ plugin: 'session-relay', key: 'link' } as const, null)
36const tasks = atom({ plugin: 'session-relay', key: 'tasks' } as const, [])
37const answers = atom({ plugin: 'session-relay', key: 'answers' } as const, [])
38const offer = atom({ plugin: 'session-relay', key: 'offer' } as const, null)
39const isAttached = atom({ plugin: 'session-relay', key: 'isAttached' } as const, false)
40const presence = atom({ plugin: 'session-relay', key: 'presence' } as const, null)
41const isActive = atom({ plugin: 'session-relay', key: 'isActive' } as const, false)
42const footer = atom({ plugin: 'session-relay', key: 'footer' } as const, null)
43const isHubbed = atom({ plugin: 'session-relay', key: 'isHubbed' } as const, false)
44
45// Set from the plugin's options when the module registers.
46let settings = { minPromptChars: 300, isNotifying: true }
47let poller: Timer | undefined
48let isPolling = false
49let isFirstPoll = true
50let lastHeartbeatAt = 0
51let published: string | null = null
52
53// Without mod-hub the call throws, and the mod keeps its own footer label.
54async function publish($: EngineInterface, entry: HubEntry): Promise<void> {
55  const text = JSON.stringify(entry)
56  if (text === published) {
57    return
58  }
59  let isReached = true
60  try {
61    await ($ as unknown as HubCall).hub.set({ id: 'session-relay', ...entry })
62  } catch {
63    isReached = false
64  }
65  published = isReached ? text : null
66  if ((await read($, isHubbed)) !== isReached) {
67    await update($, isHubbed, () => isReached)
68  }
69}
70const announced = new Set<string>()
71// Answers can be long and are re-listed every poll; a file's content never
72// changes once written, so each is read once.
73const fileCache = new Map<string, unknown>()
74
75type ChannelPaths = { toWorker: string; toLead: string; presence: string }
76
77async function relayDirectory($: EngineInterface): Promise<string> {
78  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
79  const configDirectory = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
80
81  return `${configDirectory}/relay`
82}
83
84async function pathsOf($: EngineInterface, channel: string): Promise<ChannelPaths> {
85  const base = `${await relayDirectory($)}/${channel}`
86
87  return { toWorker: `${base}/to-worker`, toLead: `${base}/to-lead`, presence: `${base}/worker.json` }
88}
89
90async function readJson($: EngineInterface, path: string): Promise<unknown> {
91  try {
92    if (!(await $.fs.exists(path))) {
93      return undefined
94    }
95
96    return JSON.parse(await $.fs.read(path)) as unknown
97  } catch {
98    return undefined
99  }
100}
101
102async function readInbox<T extends { id: string }>(
103  $: EngineInterface,
104  directory: string,
105  isValid: (value: unknown) => value is T,
106): Promise<T[]> {
107  if (!(await $.fs.exists(directory))) {
108    return []
109  }
110  const entries = await $.fs.list(directory)
111  const names = entries
112    .filter(i => i.kind === 'file' && i.name.endsWith('.json'))
113    .map(i => i.name)
114    .sort()
115  const items: T[] = []
116  for (const name of names) {
117    const path = `${directory}/${name}`
118    const value = fileCache.has(path) ? fileCache.get(path) : await readJson($, path)
119    if (isValid(value) && `${value.id}.json` === name) {
120      fileCache.set(path, value)
121      items.push(value)
122    }
123  }
124
125  return items
126}
127
128async function writeItem($: EngineInterface, directory: string, item: { id: string }): Promise<void> {
129  await $.fs.write(`${directory}/${item.id}.json`, JSON.stringify(item))
130}
131
132async function removeItems($: EngineInterface, directory: string, ids: readonly string[]): Promise<void> {
133  if (ids.length === 0) {
134    return
135  }
136  const paths = ids.map(i => `${directory}/${i}.json`)
137  paths.forEach(i => fileCache.delete(i))
138  await $.process.run(['rm', '-f', ...paths])
139}
140
141async function readPresence($: EngineInterface, channel: string): Promise<Presence | null> {
142  const value = await readJson($, (await pathsOf($, channel)).presence)
143
144  return isPresence(value) ? value : null
145}
146
147async function writePresence($: EngineInterface, channel: string, busySince: number | null): Promise<void> {
148  const now = await $.clock.now()
149  const value: Presence = { sessionId: await $.session.id(), busySince, seenAt: now }
150  await $.fs.write((await pathsOf($, channel)).presence, JSON.stringify(value))
151  lastHeartbeatAt = now
152  await update($, presence, () => value)
153  await update($, isActive, () => true)
154}
155
156// The worker of a channel is the session in its folder that was used last: a
157// prompt there or an explicit /relay worker takes the slot over, a session that
158// merely starts takes it only while no other worker is alive.
159async function claim($: EngineInterface, current: Link, isForced: boolean, busySince?: number | null): Promise<void> {
160  const held = await readPresence($, current.channel)
161  const now = await $.clock.now()
162  const sessionId = await $.session.id()
163  const isMine = held?.sessionId === sessionId
164  if (!isForced && !isMine && isOnline(held, now)) {
165    await update($, presence, () => held)
166    await update($, isActive, () => false)
167    return
168  }
169  await writePresence($, current.channel, busySince !== undefined ? busySince : isMine ? held.busySince : null)
170}
171
172async function refreshFooter($: EngineInterface): Promise<void> {
173  const current = await read($, link)
174  const label =
175    current === null
176      ? null
177      : footerOf({
178          link: current,
179          presence: await read($, presence),
180          answers: await read($, answers),
181          tasks: await read($, tasks),
182          isAttached: await read($, isAttached),
183          isActive: await read($, isActive),
184          now: await $.clock.now(),
185        })
186  if (label !== (await read($, footer))) {
187    await update($, footer, () => label)
188  }
189  const waiting = current === null ? 0 : current.role === 'lead' ? (await read($, answers)).length : (await read($, tasks)).length
190  await publish($, {
191    icon: '⇄',
192    title: 'Relay',
193    status: label === null ? 'Off · /relay lead or /relay worker links a session' : label.replace(/^⇄ /, ''),
194    attention: waiting > 0 ? 'info' : 'none',
195    actions: current === null ? [] : [{ key: 'open', label: 'Open' }],
196  })
197}
198
199async function announceAnswers($: EngineInterface, current: Link, items: readonly Answer[], isQuiet: boolean): Promise<void> {
200  const fresh = items.filter(i => !announced.has(i.id))
201  const latest = fresh.at(-1)
202  if (latest === undefined) {
203    return
204  }
205  fresh.forEach(i => announced.add(i.id))
206  const count = items.length === 1 ? '' : ` (${items.length} waiting)`
207  $.ui.toast(`${current.channel} answered${count}: ${previewOf(latest.text)}`, { timeoutMs: 10_000 })
208  if (!isQuiet && settings.isNotifying) {
209    await $.ui.notify(previewOf(latest.text, 120), { title: `${current.channel} finished` }).catch(() => undefined)
210  }
211}
212
213function announceTasks($: EngineInterface, items: readonly Task[]): void {
214  const fresh = items.filter(i => !announced.has(i.id))
215  const latest = fresh.at(-1)
216  if (latest === undefined) {
217    return
218  }
219  fresh.forEach(i => announced.add(i.id))
220  $.ui.toast(`Task from the lead: ${previewOf(latest.text)}`, { timeoutMs: 10_000 })
221}
222
223async function pollLead($: EngineInterface, current: Link, paths: ChannelPaths): Promise<void> {
224  const items = await readInbox($, paths.toLead, isAnswer)
225  await update($, answers, () => items)
226  if (items.length === 0) {
227    await update($, isAttached, () => false)
228  }
229  await announceAnswers($, current, items, isFirstPoll)
230  const held = await readPresence($, current.channel)
231  await update($, presence, () => held)
232}
233
234async function pollWorker($: EngineInterface, current: Link, paths: ChannelPaths): Promise<void> {
235  const held = await readPresence($, current.channel)
236  const now = await $.clock.now()
237  const isMine = held !== null && held.sessionId === (await $.session.id())
238  if (isMine && now - lastHeartbeatAt >= HEARTBEAT_MS) {
239    await writePresence($, current.channel, held.busySince)
240  } else if (!isMine && !isOnline(held, now)) {
241    await writePresence($, current.channel, null)
242  } else if (!isMine) {
243    await update($, presence, () => held)
244    await update($, isActive, () => false)
245  }
246  const items = await readInbox($, paths.toWorker, isTask)
247  await update($, tasks, () => items)
248  if (await read($, isActive)) {
249    announceTasks($, items)
250  }
251}
252
253async function poll($: EngineInterface): Promise<void> {
254  const current = await read($, link)
255  if (current === null || isPolling) {
256    return
257  }
258  isPolling = true
259  try {
260    const paths = await pathsOf($, current.channel)
261    if (current.role === 'lead') {
262      await pollLead($, current, paths)
263    } else {
264      await pollWorker($, current, paths)
265    }
266    isFirstPoll = false
267    await refreshFooter($)
268  } finally {
269    isPolling = false
270  }
271}
272
273function startPolling($: EngineInterface): void {
274  poller?.cancel()
275  poller = $.clock.every(POLL_MS, () => {
276    void poll($)
277  })
278}
279
280async function resetView($: EngineInterface): Promise<void> {
281  await update($, tasks, () => [])
282  await update($, answers, () => [])
283  await update($, offer, () => null)
284  await update($, isAttached, () => false)
285  await update($, presence, () => null)
286  await update($, isActive, () => false)
287  announced.clear()
288  fileCache.clear()
289}
290
291async function releaseWorkerSlot($: EngineInterface, current: Link | null): Promise<void> {
292  if (current?.role === 'worker' && (await read($, isActive))) {
293    await $.process.run(['rm', '-f', (await pathsOf($, current.channel)).presence])
294  }
295}
296
297async function join($: EngineInterface, next: Link, isExplicit: boolean): Promise<void> {
298  await releaseWorkerSlot($, await read($, link))
299  await $.store.set(`${LINK_PREFIX}${await $.session.root()}`, next)
300  await update($, link, () => next)
301  await resetView($)
302  isFirstPoll = true
303  if (next.role === 'worker') {
304    await claim($, next, isExplicit)
305  }
306  startPolling($)
307  await poll($)
308}
309
310async function leave($: EngineInterface): Promise<void> {
311  const current = await read($, link)
312  poller?.cancel()
313  poller = undefined
314  await $.store.delete(`${LINK_PREFIX}${await $.session.root()}`)
315  await releaseWorkerSlot($, current)
316  await update($, link, () => null)
317  await resetView($)
318  await refreshFooter($)
319  await $.ui.close({ id: PANE })
320}
321
322// A lead named without a channel joins the only channel there is, if one is.
323async function existingChannels($: EngineInterface): Promise<string[]> {
324  const directory = await relayDirectory($)
325  if (!(await $.fs.exists(directory))) {
326    return []
327  }
328  const entries = await $.fs.list(directory)
329
330  return entries.filter(i => i.kind === 'dir' && channelOf(i.name) === i.name).map(i => i.name)
331}
332
333async function sendTask($: EngineInterface, text: string, isFresh: boolean): Promise<void> {
334  const current = await read($, link)
335  if (current?.role !== 'lead') {
336    $.ui.toast('Only a lead sends tasks: /relay lead <channel> first.')
337    return
338  }
339  const paths = await pathsOf($, current.channel)
340  const pending = await readInbox($, paths.toWorker, isTask)
341  await removeItems($, paths.toWorker, pending.map(i => i.id))
342  const now = await $.clock.now()
343  const task: Task = { id: newId(now, Math.random()), text, isFresh, sentAt: now }
344  await writeItem($, paths.toWorker, task)
345  await update($, offer, () => null)
346  const held = await readPresence($, current.channel)
347  const notes = [
348    isFresh ? 'as a fresh session' : null,
349    pending.length > 0 ? 'replaces the task not picked up yet' : null,
350    isOnline(held, now) ? null : `no ${current.channel} session is open, it waits there`,
351  ].filter(i => i !== null)
352  $.ui.toast(`Sent to ${current.channel}${notes.length > 0 ? ` · ${notes.join(' · ')}` : ''}`)
353}
354
355async function sendLatestBlock($: EngineInterface): Promise<void> {
356  const current = await read($, offer)
357  const fromOffer = current?.blocks[current.index]
358  if (fromOffer !== undefined) {
359    await sendTask($, fromOffer, false)
360    return
361  }
362  const messages = await $.session.messages()
363  const lastAnswer = [...messages].reverse().find(i => i.role === 'assistant')
364  const block = promptBlocksOf(lastAnswer?.text ?? '', 1).at(-1)
365  if (block === undefined) {
366    $.ui.toast('The last answer holds no fenced block to send; /relay send <text> sends text as typed.')
367    return
368  }
369  await sendTask($, block, false)
370}
371
372async function takeTask($: EngineInterface, task: Task, isFresh: boolean): Promise<void> {
373  const current = await read($, link)
374  if (current?.role !== 'worker') {
375    return
376  }
377  const paths = await pathsOf($, current.channel)
378  await removeItems($, paths.toWorker, [task.id])
379  await update($, tasks, items => items.filter(i => i.id !== task.id))
380  await $.ui.close({ id: PANE })
381  try {
382    if (isFresh) {
383      await $.command.run({ command: 'clear' })
384    }
385    await claim($, current, true)
386    await $.prompt.submit({ text: task.text, asUser: true })
387  } catch (error) {
388    await writeItem($, paths.toWorker, task)
389    $.ui.toast(`session-relay could not hand the task over; it is kept. ${error instanceof Error ? error.message : String(error)}`)
390  }
391  await refreshFooter($)
392}
393
394async function discardTask($: EngineInterface, task: Task): Promise<void> {
395  const current = await read($, link)
396  if (current === null) {
397    return
398  }
399  await removeItems($, (await pathsOf($, current.channel)).toWorker, [task.id])
400  await update($, tasks, items => items.filter(i => i.id !== task.id))
401  await refreshFooter($)
402}
403
404async function dropAnswers($: EngineInterface, items: readonly Answer[]): Promise<void> {
405  const current = await read($, link)
406  if (current === null) {
407    return
408  }
409  const ids = items.map(i => i.id)
410  await removeItems($, (await pathsOf($, current.channel)).toLead, ids)
411  const remaining = await update($, answers, list => list.filter(i => !ids.includes(i.id)))
412  if (remaining.length === 0) {
413    await update($, isAttached, () => false)
414  }
415  await refreshFooter($)
416}
417
418async function attachAnswers($: EngineInterface, isOn: boolean): Promise<void> {
419  await update($, isAttached, () => isOn)
420  await refreshFooter($)
421  if (isOn) {
422    $.ui.toast('The answer goes with your next prompt.')
423  }
424}
425
426async function forwardAnswers($: EngineInterface): Promise<void> {
427  const current = await read($, link)
428  const items = await read($, answers)
429  if (current === null || items.length === 0) {
430    return
431  }
432  await $.ui.close({ id: PANE })
433  await $.prompt.submit({ text: forwardTextOf(current.channel, items), asUser: true })
434  await dropAnswers($, items)
435}
436
437async function report($: EngineInterface, current: Link, text: string): Promise<void> {
438  const paths = await pathsOf($, current.channel)
439  const now = await $.clock.now()
440  const body = text.length > MAX_ANSWER_CHARS ? `${text.slice(0, MAX_ANSWER_CHARS)}\n\n[cut at ${MAX_ANSWER_CHARS} characters]` : text
441  const answer: Answer = { id: newId(now, Math.random()), text: body, finishedAt: now }
442  await writeItem($, paths.toLead, answer)
443  const items = await readInbox($, paths.toLead, isAnswer)
444  await removeItems($, paths.toLead, items.slice(0, Math.max(0, items.length - MAX_ANSWERS)).map(i => i.id))
445}
446
447async function cycleOffer($: EngineInterface): Promise<void> {
448  await update($, offer, current =>
449    current === null ? null : { ...current, index: (current.index + current.blocks.length - 1) % current.blocks.length },
450  )
451}
452
453async function openPane($: EngineInterface): Promise<void> {
454  await $.ui.open({ id: PANE, title: 'Relay', focus: true, closeOnEscape: true })
455}
456
457async function runCommand($: EngineInterface, args: string): Promise<void> {
458  const command = parseCommand(args)
459  if (command.kind === 'invalid') {
460    $.ui.toast(command.reason)
461    return
462  }
463  if (command.kind === 'open') {
464    await poll($)
465    await openPane($)
466    return
467  }
468  if (command.kind === 'off') {
469    const current = await read($, link)
470    await leave($)
471    $.ui.toast(current === null ? 'This folder was not linked.' : `Left ${current.channel}; this folder no longer joins it.`)
472    return
473  }
474  if (command.kind === 'send') {
475    if (command.text.length > 0) {
476      await sendTask($, command.text, false)
477    } else {
478      await sendLatestBlock($)
479    }
480    return
481  }
482  const root = await $.session.root()
483  let channel = command.channel
484  if (channel === null && command.role === 'worker') {
485    channel = channelOf(folderNameOf(root))
486  }
487  if (channel === null && command.role === 'lead') {
488    const channels = await existingChannels($)
489    channel = channels.length === 1 ? (channels[0] ?? null) : null
490    if (channel === null) {
491      const known = channels.length > 0 ? ` Known: ${channels.join(', ')}.` : ''
492      $.ui.toast(`Name the channel: /relay lead <channel>.${known}`)
493      return
494    }
495  }
496  if (channel === null) {
497    $.ui.toast(`This folder's name is no channel name; /relay worker <channel> names one.`)
498    return
499  }
500  const previous = await read($, link)
501  await join($, { role: command.role, channel }, true)
502  const wasOther =
503    previous !== null && (previous.role !== command.role || previous.channel !== channel)
504      ? ` This folder was the ${previous.role} of ${previous.channel} until now.`
505      : ''
506  $.ui.toast(
507    command.role === 'lead'
508      ? `Lead of ${channel}. A prompt in a fenced block can go to the worker from the band above the prompt.${wasOther}`
509      : `Worker of ${channel}. Tasks from the lead show above the prompt; every answer goes back to it.${wasOther}`,
510  )
511}
512
513export const register: Register = (on, options) => {
514  settings = {
515    minPromptChars: Number(options.minPromptChars ?? 300),
516    isNotifying: options.notify !== false,
517  }
518
519  on('session.start', async ($, e, next) => {
520    await $.command.register({
521      name: 'relay',
522      description: 'Pass prompts and answers between a planning session and the session that does the work',
523      argumentHint: 'lead <channel> | worker [channel] | send [text] | off',
524      immediate: true,
525    })
526    const result = await next(e)
527    const stored = await $.store.get(`${LINK_PREFIX}${await $.session.root()}`)
528    if (isLink(stored)) {
529      await join($, stored, false)
530    } else {
531      await refreshFooter($)
532    }
533
534    return result
535  })
536
537  // A /clear or /resume starts no session.start: the offer and an attached answer
538  // belong to the conversation that was left, and a worker goes on under a new id.
539  on('classic.SessionStart', async ($, e, next) => {
540    const result = await next(e)
541    if (e.source !== 'clear' && e.source !== 'resume') {
542      return result
543    }
544    await update($, offer, () => null)
545    await update($, isAttached, () => false)
546    const current = await read($, link)
547    if (current?.role === 'worker') {
548      await claim($, current, true)
549    }
550    await refreshFooter($)
551
552    return result
553  })
554
555  on('session.end', async ($, e, next) => {
556    const current = await read($, link)
557    if (e.reason !== 'clear' && e.reason !== 'resume' && current?.role === 'worker' && (await read($, isActive))) {
558      await $.process.run(['rm', '-f', (await pathsOf($, current.channel)).presence]).catch(() => undefined)
559    }
560
561    return next(e)
562  })
563
564  on('turn.start', async ($, e, next) => {
565    const result = await next(e)
566    const current = await read($, link)
567    if (current?.role === 'worker') {
568      await claim($, current, true, await $.clock.now())
569      await refreshFooter($)
570    }
571
572    return result
573  })
574
575  on('turn.complete', async ($, e, next) => {
576    const result = await next(e)
577    const current = await read($, link)
578    if (e.agentId !== undefined || current === null) {
579      return result
580    }
581    if (current.role === 'lead') {
582      const blocks = e.reason === 'answer' ? promptBlocksOf(e.answer, settings.minPromptChars) : []
583      await update($, offer, () => (blocks.length === 0 ? null : { blocks, index: blocks.length - 1 }))
584      return result
585    }
586    if (!(await read($, isActive))) {
587      return result
588    }
589    await writePresence($, current.channel, null)
590    if (e.reason === 'answer' && e.answer.trim().length > 0) {
591      await report($, current, e.answer)
592    }
593    await refreshFooter($)
594
595    return result
596  })
597
598  // Typing a prompt in the lead, here or through Remote Control, drops a pending
599  // offer, which belonged to the answer before it, and takes attached answers
600  // along as context.
601  on('prompt.submit', async ($, e, next) => {
602    const current = await read($, link)
603    const isTyped = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
604    if (current?.role !== 'lead' || !isTyped || e.text.trimStart().startsWith('/')) {
605      return next(e)
606    }
607    await update($, offer, () => null)
608    const items = await read($, answers)
609    if (!(await read($, isAttached)) || items.length === 0) {
610      return next(e)
611    }
612    const result = await next({ ...e, context: [...(e.context ?? []), forwardTextOf(current.channel, items)] })
613    if (result.drop === undefined) {
614      await dropAnswers($, items)
615    }
616
617    return result
618  })
619
620  on('command.run', { command: 'relay' }, async ($, e) => {
621    await runCommand($, e.args)
622
623    return {}
624  })
625
626  on('ui.press', { plugin: 'mod-hub', element: 'session-relay:open' }, async ($, e, next) => {
627    await openPane($)
628
629    return next(e)
630  })
631
632  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
633    const current = await read($, link)
634    if (current === null || e.props.hasSurvey || e.props.isWorking) {
635      return next(e)
636    }
637    const currentOffer = await read($, offer)
638    const block = currentOffer?.blocks[currentOffer.index]
639    const items = await read($, answers)
640    const attached = await read($, isAttached)
641    const task = (await read($, tasks)).at(-1)
642    const active = await read($, isActive)
643    const hasLead = current.role === 'lead' && (block !== undefined || items.length > 0)
644    const hasWorker = current.role === 'worker' && task !== undefined
645    if (!hasLead && !hasWorker) {
646      return next(e)
647    }
648    // Another mod's band (cache-watch) may be drawn beneath; it stays under this
649    // one. An `engine` element means none was, and it cannot be nested.
650    const below = await next(e)
651    const beneath = below.type === 'engine' ? null : below
652    const { Box, Button, Text } = $.ui.resolve(e)
653    const latest = items.at(-1)
654    const blockCount = currentOffer?.blocks.length ?? 0
655    if (e.surface !== 'terminal') {
656      const { Svg } = $.ui.resolve(e)
657      const width = Math.max(240, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 8))
658      const labels: Record<string, string[]> = {
659        offer: ['➤ Send', '✦ Fresh session', ...(blockCount > 1 ? ['Other block'] : []), 'View', 'Dismiss'],
660        attached: ['Detach', 'View'],
661        answers: ['📎 Attach', '➤ Forward now', 'View', 'Discard'],
662        inactive: ['Take over'],
663        task: ['✦ Clear & send', '➤ Send here', 'View', 'Discard'],
664      }
665      // The buttons lie over the card, centered on its height: the picture
666      // takes no presses. Too many for the room beside the text, and the band
667      // stays a native card.
668      const drawCard = (key: string, title: string, detail: string, buttons: RenderElement) => {
669        const reserve = buttonWidthOf(labels[key] ?? [])
670        if (reserve > width * 0.6) {
671          return (
672            <Box key={key} flexDirection="row" columnGap={2} alignItems="center" borderStyle="round" borderColor="claude" paddingX={1}>
673              <Box flexDirection="column" flexGrow={1} flexShrink={1}>
674                <Text bold>{title}</Text>
675                <Text dimColor wrap="truncate-end">
676                  {detail}
677                </Text>
678              </Box>
679              {buttons}
680            </Box>
681          )
682        }
683        const card = bandCard(width, title, detail, reserve)
684
685        return (
686          <Box key={key} flexDirection="column">
687            <Svg source={card.source} alt={card.alt} width={width} height={card.height} />
688            <Box position="absolute" top={0} bottom={0} right={2} flexDirection="row" alignItems="center">
689              {buttons}
690            </Box>
691          </Box>
692        )
693      }
694
695      return (
696        <Box flexDirection="column" rowGap={1}>
697          {current.role === 'lead' &&
698            block !== undefined &&
699            drawCard(
700              'offer',
701              `⇄ Prompt for ${current.channel}${blockCount > 1 ? ` · ${(currentOffer?.index ?? 0) + 1} of ${blockCount}` : ''}`,
702              `${formatChars(block.length)} · ${previewOf(block)}`,
703              <Box flexDirection="row" columnGap={1} flexWrap="wrap">
704                <Button key="send" label="➤ Send" hotkey="s" variant="primary" onPress={() => sendTask($, block, false)} />
705                <Button key="send-fresh" label="✦ Fresh session" hotkey="n" onPress={() => sendTask($, block, true)} />
706                {blockCount > 1 && <Button key="other" label="Other block" hotkey="o" onPress={() => cycleOffer($)} />}
707                <Button key="view-offer" label="View" hotkey="v" onPress={() => openPane($)} />
708                <Button key="dismiss-offer" label="Dismiss" hotkey="x" role="dismiss" onPress={() => update($, offer, () => null)} />
709              </Box>,
710            )}
711          {current.role === 'lead' &&
712            latest !== undefined &&
713            attached &&
714            drawCard(
715              'attached',
716              `📎 ${items.length === 1 ? 'Answer' : `${items.length} answers`} from ${current.channel} attached`,
717              'They go with your next prompt',
718              <Box flexDirection="row" columnGap={1}>
719                <Button key="detach" label="Detach" hotkey="u" onPress={() => attachAnswers($, false)} />
720                <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
721              </Box>,
722            )}
723          {current.role === 'lead' &&
724            latest !== undefined &&
725            !attached &&
726            drawCard(
727              'answers',
728              `⇄ ${current.channel} answered${items.length > 1 ? ` ${items.length}×` : ''}`,
729              `${formatChars(latest.text.length)} · ${previewOf(latest.text)}`,
730              <Box flexDirection="row" columnGap={1} flexWrap="wrap">
731                <Button
732                  key="attach"
733                  label="📎 Attach"
734                  hotkey="a"
735                  {...(block === undefined ? { variant: 'primary' as const } : {})}
736                  onPress={() => attachAnswers($, true)}
737                />
738                <Button key="forward" label="➤ Forward now" hotkey="f" onPress={() => forwardAnswers($)} />
739                <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
740                <Button key="discard-answers" label="Discard" hotkey="d" onPress={() => dropAnswers($, items)} />
741              </Box>,
742            )}
743          {current.role === 'worker' &&
744            task !== undefined &&
745            !active &&
746            drawCard(
747              'inactive',
748              `⇄ A task for ${current.channel} waits`,
749              'Another session in this folder is the worker',
750              <Button key="take-over" label="Take over" hotkey="t" variant="primary" onPress={() => claim($, current, true)} />,
751            )}
752          {current.role === 'worker' &&
753            task !== undefined &&
754            active &&
755            drawCard(
756              'task',
757              `⇄ Task from the lead${task.isFresh ? ' · for a fresh session' : ''}`,
758              `${formatChars(task.text.length)} · ${previewOf(task.text)}`,
759              <Box flexDirection="row" columnGap={1} flexWrap="wrap">
760                <Button
761                  key="send-fresh"
762                  label="✦ Clear & send"
763                  hotkey="n"
764                  {...(task.isFresh ? { variant: 'primary' as const } : {})}
765                  onPress={() => takeTask($, task, true)}
766                />
767                <Button
768                  key="send"
769                  label="➤ Send here"
770                  hotkey="s"
771                  {...(task.isFresh ? {} : { variant: 'primary' as const })}
772                  onPress={() => takeTask($, task, false)}
773                />
774                <Button key="view-task" label="View" hotkey="v" onPress={() => openPane($)} />
775                <Button key="discard-task" label="Discard" hotkey="d" onPress={() => discardTask($, task)} />
776              </Box>,
777            )}
778          {beneath}
779        </Box>
780      )
781    }
782
783    return (
784      <Box flexDirection="column">
785        {current.role === 'lead' && block !== undefined && (
786          <Box key="offer" flexDirection="column">
787            <Text wrap="truncate-end">
788              {`Prompt for ${current.channel}${blockCount > 1 ? ` (${(currentOffer?.index ?? 0) + 1}/${blockCount})` : ''} · ${formatChars(block.length)} · ${previewOf(block)}`}
789            </Text>
790            <Box flexDirection="row" columnGap={2} flexWrap="wrap">
791              <Button key="send" label="Send" hotkey="s" variant="primary" onPress={() => sendTask($, block, false)} />
792              <Button key="send-fresh" label="Send as fresh session" hotkey="n" onPress={() => sendTask($, block, true)} />
793              {blockCount > 1 && <Button key="other" label="Other block" hotkey="o" onPress={() => cycleOffer($)} />}
794              <Button key="view-offer" label="View" hotkey="v" onPress={() => openPane($)} />
795              <Button key="dismiss-offer" label="Dismiss" hotkey="x" role="dismiss" onPress={() => update($, offer, () => null)} />
796            </Box>
797          </Box>
798        )}
799        {current.role === 'lead' && latest !== undefined && attached && (
800          <Box key="attached" flexDirection="column">
801            <Text wrap="truncate-end">{`📎 ${items.length === 1 ? 'The answer' : `${items.length} answers`} from ${current.channel} go with your next prompt`}</Text>
802            <Box flexDirection="row" columnGap={2}>
803              <Button key="detach" label="Detach" hotkey="u" onPress={() => attachAnswers($, false)} />
804              <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
805            </Box>
806          </Box>
807        )}
808        {current.role === 'lead' && latest !== undefined && !attached && (
809          <Box key="answers" flexDirection="column">
810            <Text wrap="truncate-end">
811              {`${current.channel} answered${items.length > 1 ? ` ${items.length}×` : ''} · ${formatChars(latest.text.length)} · ${previewOf(latest.text)}`}
812            </Text>
813            <Box flexDirection="row" columnGap={2} flexWrap="wrap">
814              <Button
815                key="attach"
816                label="Attach to next prompt"
817                hotkey="a"
818                {...(block === undefined ? { variant: 'primary' as const } : {})}
819                onPress={() => attachAnswers($, true)}
820              />
821              <Button key="forward" label="Forward now" hotkey="f" onPress={() => forwardAnswers($)} />
822              <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
823              <Button key="discard-answers" label="Discard" hotkey="d" onPress={() => dropAnswers($, items)} />
824            </Box>
825          </Box>
826        )}
827        {current.role === 'worker' && task !== undefined && !active && (
828          <Box key="inactive" flexDirection="column">
829            <Text wrap="truncate-end">{`A task for ${current.channel} waits, but another session in this folder is the worker.`}</Text>
830            <Box flexDirection="row" columnGap={2}>
831              <Button key="take-over" label="Take over" hotkey="t" variant="primary" onPress={() => claim($, current, true)} />
832            </Box>
833          </Box>
834        )}
835        {current.role === 'worker' && task !== undefined && active && (
836          <Box key="task" flexDirection="column">
837            <Text wrap="truncate-end">
838              {`Task from the lead${task.isFresh ? ' · for a fresh session' : ''} · ${formatChars(task.text.length)} · ${previewOf(task.text)}`}
839            </Text>
840            <Box flexDirection="row" columnGap={2} flexWrap="wrap">
841              <Button
842                key="send-fresh"
843                label="Clear & send"
844                hotkey="n"
845                {...(task.isFresh ? { variant: 'primary' as const } : {})}
846                onPress={() => takeTask($, task, true)}
847              />
848              <Button
849                key="send"
850                label="Send here"
851                hotkey="s"
852                {...(task.isFresh ? {} : { variant: 'primary' as const })}
853                onPress={() => takeTask($, task, false)}
854              />
855              <Button key="view-task" label="View" hotkey="v" onPress={() => openPane($)} />
856              <Button key="discard-task" label="Discard" hotkey="d" onPress={() => discardTask($, task)} />
857            </Box>
858          </Box>
859        )}
860        {beneath}
861      </Box>
862    )
863  })
864
865  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
866    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
867    const current = await read($, link)
868    if (current === null) {
869      return (
870        <Box flexDirection="column" rowGap={1}>
871          <Text bold>⇄ Relay</Text>
872          <Text>This folder is not linked to a relay channel.</Text>
873          <Text dimColor>{USAGE}</Text>
874        </Box>
875      )
876    }
877    const status = statusOf({
878      link: current,
879      presence: await read($, presence),
880      answers: await read($, answers),
881      tasks: await read($, tasks),
882      isAttached: await read($, isAttached),
883      isActive: await read($, isActive),
884      now: await $.clock.now(),
885    })
886    const currentOffer = await read($, offer)
887    const block = currentOffer?.blocks[currentOffer.index]
888    const items = await read($, answers)
889    const task = (await read($, tasks)).at(-1)
890    const isEmpty = current.role === 'lead' ? block === undefined && items.length === 0 : task === undefined
891    if (e.surface !== 'terminal') {
892      const { Svg } = $.ui.resolve(e)
893      const width = cardWidthOf(e.props.bodyColumns)
894      const now = await $.clock.now()
895      const held = await read($, presence)
896      const active = await read($, isActive)
897      const state =
898        current.role === 'worker'
899          ? active
900            ? 'this session works'
901            : 'another session works'
902          : !isOnline(held, now)
903            ? 'no worker open'
904            : held.busySince !== null
905              ? 'worker busy'
906              : 'worker ready'
907      const detail = status.split(' · ').slice(1).join(' · ')
908      const header = headerCard({
909        width,
910        eyebrow: `⇄ RELAY · ${current.role === 'lead' ? 'LEAD' : 'WORKER'}`,
911        title: current.channel,
912        counter: detail.length > 0 ? detail : state,
913      })
914      const drawCards = (list: readonly Card[]) => list.map(card => <Svg source={card.source} alt={card.alt} width={width} height={card.height} />)
915      // A bar names each message and carries its actions, laid over it and
916      // centered on its height; a narrow pane keeps them beneath the name.
917      const actionBar = (key: string, label: string, buttonLabels: readonly string[], buttons: RenderElement[]) =>
918        width >= ROOMY_WIDTH ? (
919          <Box key={key} flexDirection="column">
920            {drawCards([barCard(width, label, label, 'card', buttonWidthOf(buttonLabels))])}
921            <Box position="absolute" top={0} bottom={0} right={2} flexDirection="row" alignItems="center" columnGap={1}>
922              {buttons}
923            </Box>
924          </Box>
925        ) : (
926          <Box key={key} flexDirection="column" rowGap={1}>
927            <Text bold dimColor>
928              {label}
929            </Text>
930            <Box flexDirection="row" columnGap={1} flexWrap="wrap">
931              {buttons}
932            </Box>
933          </Box>
934        )
935
936      return (
937        <Box flexDirection="column" rowGap={1}>
938          {drawCards([header])}
939          {current.role === 'lead' && block !== undefined && (
940            <Box key="offer" flexDirection="column" rowGap={1}>
941              {actionBar('offer-bar', `PROMPT FOR ${current.channel.toUpperCase()} · ${formatChars(block.length)}`, ['➤ Send', '✦ Fresh session'], [
942                <Button key="pane-send" label="➤ Send" variant="primary" onPress={() => sendTask($, block, false)} />,
943                <Button key="pane-send-fresh" label="✦ Fresh session" onPress={() => sendTask($, block, true)} />,
944              ])}
945              {drawCards(messageCards(width, null, block, 'card-accent'))}
946            </Box>
947          )}
948          {current.role === 'lead' && items.length > 0 && (
949            <Box key="answers" flexDirection="column" rowGap={1}>
950              {actionBar('answers-bar', `${items.length === 1 ? 'ANSWER' : `${items.length} ANSWERS`} FROM ${current.channel.toUpperCase()}`, ['📎 Attach', '➤ Forward now', '✕ Discard'], [
951                <Button key="pane-attach" label="📎 Attach" variant="primary" onPress={() => attachAnswers($, true)} />,
952                <Button key="pane-forward" label="➤ Forward now" onPress={() => forwardAnswers($)} />,
953                <Button key="pane-discard" dimColor label="✕ Discard" onPress={() => dropAnswers($, items)} />,
954              ])}
955              {items.map((answer, index) => (
956                <Box key={`answer-${answer.id}`} flexDirection="column">
957                  {drawCards(messageCards(width, `ANSWER ${index + 1} OF ${items.length} · ${formatChars(answer.text.length)}`, answer.text, 'card'))}
958                </Box>
959              ))}
960            </Box>
961          )}
962          {current.role === 'worker' && task !== undefined && (
963            <Box key="task" flexDirection="column" rowGap={1}>
964              {actionBar('task-bar', `TASK FROM THE LEAD · ${formatChars(task.text.length)}${task.isFresh ? ' · FRESH SESSION' : ''}`, ['✦ Clear & send', '➤ Send here', '✕ Discard'], [
965                <Button key="pane-clear-send" label="✦ Clear & send" {...(task.isFresh ? { variant: 'primary' as const } : {})} onPress={() => takeTask($, task, true)} />,
966                <Button key="pane-send-here" label="➤ Send here" {...(task.isFresh ? {} : { variant: 'primary' as const })} onPress={() => takeTask($, task, false)} />,
967                <Button key="pane-discard-task" dimColor label="✕ Discard" onPress={() => discardTask($, task)} />,
968              ])}
969              {drawCards(messageCards(width, null, task.text, 'card-accent'))}
970            </Box>
971          )}
972          {isEmpty && (
973            <Box flexDirection="column">
974              <Text dimColor>Nothing pending.</Text>
975              <Text dimColor>{USAGE}</Text>
976            </Box>
977          )}
978        </Box>
979      )
980    }
981
982    return (
983      <Box flexDirection="column">
984        <Text bold>{status}</Text>
985        {current.role === 'lead' && block !== undefined && (
986          <Box key="offer" flexDirection="column" marginTop={1}>
987            <Text bold>{`Prompt for ${current.channel}`}</Text>
988            <Markdown text={block} />
989            <Box flexDirection="row" columnGap={2} flexWrap="wrap">
990              <Button key="pane-send" label="Send" variant="primary" onPress={() => sendTask($, block, false)} />
991              <Button key="pane-send-fresh" label="Send as fresh session" onPress={() => sendTask($, block, true)} />
992            </Box>
993          </Box>
994        )}
995        {current.role === 'lead' &&
996          items.map((answer, index) => (
997            <Box key={`answer-${answer.id}`} flexDirection="column" marginTop={1}>
998              <Text bold>{`Answer ${index + 1} of ${items.length} from ${current.channel} · ${formatChars(answer.text.length)}`}</Text>
999              <Markdown text={answer.text} />
1000            </Box>
1001          ))}
1002        {current.role === 'lead' && items.length > 0 && (
1003          <Box key="answer-actions" flexDirection="row" columnGap={2} flexWrap="wrap" marginTop={1}>
1004            <Button key="pane-attach" label="Attach to next prompt" variant="primary" onPress={() => attachAnswers($, true)} />
1005            <Button key="pane-forward" label="Forward now" onPress={() => forwardAnswers($)} />
1006            <Button key="pane-discard" label="Discard" onPress={() => dropAnswers($, items)} />
1007          </Box>
1008        )}
1009        {current.role === 'worker' && task !== undefined && (
1010          <Box key="task" flexDirection="column" marginTop={1}>
1011            <Text bold>{`Task from the lead${task.isFresh ? ' · for a fresh session' : ''}`}</Text>
1012            <Markdown text={task.text} />
1013            <Box flexDirection="row" columnGap={2} flexWrap="wrap">
1014              <Button key="pane-clear-send" label="Clear & send" {...(task.isFresh ? { variant: 'primary' as const } : {})} onPress={() => takeTask($, task, true)} />
1015              <Button key="pane-send-here" label="Send here" {...(task.isFresh ? {} : { variant: 'primary' as const })} onPress={() => takeTask($, task, false)} />
1016              <Button key="pane-discard-task" label="Discard" onPress={() => discardTask($, task)} />
1017            </Box>
1018          </Box>
1019        )}
1020        {isEmpty && (
1021          <Box flexDirection="column" marginTop={1}>
1022            <Text dimColor>Nothing pending.</Text>
1023            <Text dimColor>{USAGE}</Text>
1024          </Box>
1025        )}
1026      </Box>
1027    )
1028  })
1029
1030  // The terminal draws the mode labels itself, so the label joins them there.
1031  // On the desktop, status-band draws the footer from the labels it was handed,
1032  // whichever order the mods load in; only a drawn tree survives that.
1033  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1034    const label = await read($, footer)
1035    if (label === null) {
1036      return next(e)
1037    }
1038    if (e.surface === 'terminal') {
1039      return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
1040    }
1041    if (await read($, isHubbed)) {
1042      return next(e)
1043    }
1044    const below = await next(e)
1045    const { Box, Text } = $.ui.resolve(e)
1046    const rest = below.type !== 'engine' ? below : e.props.modes.length === 0 ? null : <Text dimColor>{e.props.modes.join(' & ')}</Text>
1047
1048    return (
1049      <Box flexDirection="row">
1050        <Text dimColor>{label}</Text>
1051        {rest !== null && <Text dimColor> · </Text>}
1052        {rest}
1053      </Box>
1054    )
1055  })
1056}
1057
hooks/hub.ts 12 lines
1// mod-hub's contract, restated: listed under dependencies, it would keep this
2// mod from loading where mod-hub is not installed.
3export type HubEntry = {
4  icon: string
5  title: string
6  status: string
7  attention: 'none' | 'info' | 'warning'
8  actions: { key: string; label: string }[]
9}
10
11export type HubCall = { hub: { set: (entry: HubEntry & { id: string }) => Promise<void> } }
12
hooks/look.ts 31 lines
1import { PAD, blockSections, cards, escape, eyebrowSection, fitText, frame, parseBlocks, plainOf, svgDocument } from './svg'
2import type { Card, Variant } from './svg'
3
4export { barCard, buttonWidthOf, headerCard } from './svg'
5export type { Card } from './svg'
6
7// A prompt, an answer or a task as the other session wrote it, its Markdown
8// drawn in the card; without an eyebrow when a bar above already names it.
9export function messageCards(width: number, eyebrow: string | null, text: string, variant: Variant): Card[] {
10  const body = blockSections(parseBlocks(text), PAD, width - 2 * PAD)
11  const sections = eyebrow === null ? body : [eyebrowSection(eyebrow, null, 'muted', width), ...body]
12
13  return cards(width, variant, sections, `${eyebrow === null ? 'Message' : eyebrow}: ${plainOf(text.replace(/\s+/gu, ' '))}`)
14}
15
16const BAND_HEIGHT = 60
17
18// What waits in the band above the prompt; `reserve` keeps the text clear of
19// the native buttons laid over the card's right edge.
20export function bandCard(width: number, title: string, detail: string, reserve: number): Card {
21  const maxWidth = width - 2 * PAD - reserve
22  const middle = BAND_HEIGHT / 2
23  const body = [
24    frame(width, BAND_HEIGHT, 'card-accent'),
25    `<text class="t" x="${PAD}" y="${middle - 3}" font-size="14" font-weight="650">${escape(fitText(title, maxWidth, 14, true))}</text>`,
26    `<text class="muted" x="${PAD}" y="${middle + 15}" font-size="12.5">${escape(fitText(detail, maxWidth, 12.5))}</text>`,
27  ]
28
29  return { height: BAND_HEIGHT, alt: `${title}: ${detail}`, source: svgDocument(width, BAND_HEIGHT, body.join('')) }
30}
31
hooks/svg.ts 603 lines
1// An SVG drawn as an image cannot lay out text: no wrapping, no Markdown, no
2// measuring. This module does that by estimate and stacks the result into
3// cards. Every mod with desktop cards carries the same copy of this file.
4
5export type Run = { text: string; isBold?: boolean; isItalic?: boolean; isCode?: boolean; isLink?: boolean }
6
7export type Block =
8  | { kind: 'paragraph'; runs: Run[] }
9  | { kind: 'heading'; runs: Run[] }
10  | { kind: 'quote'; runs: Run[] }
11  | { kind: 'item'; marker: string; runs: Run[] }
12  | { kind: 'code'; lines: string[] }
13
14export const SANS = '-apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,system-ui,sans-serif'
15export const MONO = 'ui-monospace,SFMono-Regular,Menlo,monospace'
16
17// Classes rather than colors: drawn as an image the SVG follows the light or
18// dark color scheme by itself, which a color handed in from the hook could not.
19export const STYLE = `<style>
20text{font-family:${SANS};white-space:pre}.mono{font-family:${MONO}}
21.card{fill:#f7f6f3;stroke:#e5e3de}.card-accent{fill:#fbf1ec;stroke:#e7b49c}.card-success{fill:#f1f8f3;stroke:#a9d6b8}
22.card-warning{fill:#fcf6e6;stroke:#e6cf8a}.card-error{fill:#fcefee;stroke:#e8aaa6}
23.tile{fill:#ffffff;stroke:#e5e3de}.tile-accent{fill:#ffffff;stroke:#d97757}.tile-success{fill:#ffffff;stroke:#2e9e5b}
24.t{fill:#1f1e1d}.muted{fill:#77756f}.eyebrow{fill:#c9643f}.code{fill:#a2482a}.link{fill:#c9643f}
25.success{fill:#2e9e5b}.warning{fill:#a87a06}.error{fill:#c0392b}.accent{fill:#d97757}.on-accent{fill:#ffffff}
26.badge{fill:#ecebe7}.badge-t{fill:#4a4844}.codebg{fill:#efede8}.rule{fill:#dad7d0}.pill{fill:#f6e1d7}.pill-success{fill:#dcefe2}
27.open{fill:#e4e2dc}.answered{fill:#2e9e5b}.skipped{fill:#d4a017}.current{fill:#d97757}
28.current,.pulse{animation:pulse 1.8s ease-in-out infinite}@keyframes pulse{50%{opacity:.5}}
29.ripple{transform-box:fill-box;transform-origin:center;animation:ripple 2.4s ease-out infinite}@keyframes ripple{0%{transform:scale(1);opacity:.45}100%{transform:scale(2.8);opacity:0}}
30.spin{transform-box:fill-box;transform-origin:center;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
31.dot{animation:dot 1.2s ease-in-out infinite}.dot2{animation-delay:.2s}.dot3{animation-delay:.4s}@keyframes dot{0%,80%,100%{opacity:.25}40%{opacity:1}}
32@media (prefers-color-scheme: dark){
33.card{fill:#262625;stroke:#363632}.card-accent{fill:#2e2420;stroke:#7a4a36}.card-success{fill:#1f2a23;stroke:#2f5a3d}
34.card-warning{fill:#2c2717;stroke:#6a5a24}.card-error{fill:#2e1f1e;stroke:#6e3430}
35.tile{fill:#1e1e1d;stroke:#3a3a36}.tile-accent{fill:#1e1e1d;stroke:#e08a6a}.tile-success{fill:#1e1e1d;stroke:#3fb36b}
36.t{fill:#ecebe8}.muted{fill:#a5a39d}.eyebrow{fill:#e8916f}.code{fill:#f0a283}.link{fill:#e8916f}
37.success{fill:#4cc27a}.warning{fill:#e2b33c}.error{fill:#e8746a}.accent{fill:#e08a6a}.on-accent{fill:#1f1e1d}
38.badge{fill:#34342f}.badge-t{fill:#d6d4cf}.codebg{fill:#1c1c1b}.rule{fill:#45443f}.pill{fill:#4a3328}.pill-success{fill:#24432f}
39.open{fill:#3a3a37}.answered{fill:#3fb36b}.skipped{fill:#d4a017}.current{fill:#e08a6a}}
40@media (prefers-reduced-motion: reduce){.current,.pulse,.dot,.ripple,.spin{animation:none}}
41</style>`
42
43// Widths are estimated from the font size, on the wide side: a line then ends
44// a little early rather than past the card's edge.
45export function textWidth(text: string, size: number, isBold = false, isMono = false): number {
46  if (isMono) {
47    return Array.from(text).length * size * 0.62
48  }
49  let units = 0
50  for (const char of text) {
51    if (/[\s.,:;'|!ijlI]/u.test(char)) {
52      units += 0.3
53    } else if (/[A-Z@%&MWmw]/u.test(char)) {
54      units += 0.74
55    } else if (/\p{Extended_Pictographic}/u.test(char)) {
56      units += 1.15
57    } else {
58      units += 0.53
59    }
60  }
61
62  return units * size * (isBold ? 1.04 : 1)
63}
64
65function runWidth(run: Run, size: number): number {
66  return textWidth(run.text, size, run.isBold === true, run.isCode === true)
67}
68
69export function fitText(text: string, maxWidth: number, size: number, isBold = false): string {
70  if (textWidth(text, size, isBold) <= maxWidth) {
71    return text
72  }
73  const chars = Array.from(text)
74  while (chars.length > 0 && textWidth(`${chars.join('')}…`, size, isBold) > maxWidth) {
75    chars.pop()
76  }
77
78  return `${chars.join('').trimEnd()}…`
79}
80
81export function escape(text: string): string {
82  return text.replace(/&/gu, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;').replace(/"/gu, '&quot;')
83}
84
85const INLINE = /(\*\*[^*]+?\*\*|__[^_]+?__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|(?<![\w*])\*(?!\s)[^*]+?(?<!\s)\*(?!\w))/gu
86
87export function parseInline(text: string): Run[] {
88  const runs: Run[] = []
89  let last = 0
90  for (const match of text.matchAll(INLINE)) {
91    const [token] = match
92    if (match.index > last) {
93      runs.push({ text: text.slice(last, match.index) })
94    }
95    if (token.startsWith('**') || token.startsWith('__')) {
96      runs.push(...parseInline(token.slice(2, -2)).map(i => ({ ...i, isBold: true })))
97    } else if (token.startsWith('`')) {
98      runs.push({ text: token.slice(1, -1), isCode: true })
99    } else if (token.startsWith('[')) {
100      runs.push({ text: token.slice(1, token.indexOf('](')), isLink: true })
101    } else {
102      runs.push(...parseInline(token.slice(1, -1)).map(i => ({ ...i, isItalic: true })))
103    }
104    last = match.index + token.length
105  }
106  if (last < text.length) {
107    runs.push({ text: text.slice(last) })
108  }
109
110  return runs.filter(i => i.text.length > 0)
111}
112
113export function plainOf(text: string): string {
114  return parseInline(text)
115    .map(i => i.text)
116    .join('')
117}
118
119const ITEM = /^\s*([-*•+]|\d{1,3}[.)])\s+(.*)$/u
120const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/u
121const QUOTE = /^\s*>\s?(.*)$/u
122const FENCE = /^\s*(```|~~~)/u
123
124// Single line breaks join a paragraph, as Markdown reads them; a list item,
125// heading, quote or code fence starts a block of its own.
126export function parseBlocks(markdown: string): Block[] {
127  const blocks: Block[] = []
128  let paragraph: string[] = []
129  let item: { marker: string; text: string[] } | null = null
130  let quote: string[] = []
131  const flush = () => {
132    if (paragraph.length > 0) {
133      blocks.push({ kind: 'paragraph', runs: parseInline(paragraph.join(' ')) })
134    }
135    if (item !== null) {
136      blocks.push({ kind: 'item', marker: item.marker, runs: parseInline(item.text.join(' ')) })
137    }
138    if (quote.length > 0) {
139      blocks.push({ kind: 'quote', runs: parseInline(quote.join(' ')) })
140    }
141    paragraph = []
142    item = null
143    quote = []
144  }
145  const lines = markdown.replace(/\r\n?/gu, '\n').split('\n')
146  for (let index = 0; index < lines.length; index++) {
147    const line = lines[index] ?? ''
148    if (FENCE.test(line)) {
149      flush()
150      const code: string[] = []
151      index += 1
152      while (index < lines.length && !FENCE.test(lines[index] ?? '')) {
153        code.push(lines[index] ?? '')
154        index += 1
155      }
156      blocks.push({ kind: 'code', lines: code })
157      continue
158    }
159    if (line.trim().length === 0) {
160      flush()
161      continue
162    }
163    const heading = HEADING.exec(line)
164    if (heading !== null) {
165      flush()
166      blocks.push({ kind: 'heading', runs: parseInline(heading[1] ?? '') })
167      continue
168    }
169    const listed = ITEM.exec(line)
170    if (listed !== null) {
171      flush()
172      const marker = listed[1] ?? '-'
173      item = { marker: /\d/u.test(marker) ? marker : '•', text: [listed[2] ?? ''] }
174      continue
175    }
176    const quoted = QUOTE.exec(line)
177    if (quoted !== null) {
178      if (quote.length === 0) {
179        flush()
180      }
181      quote.push(quoted[1] ?? '')
182      continue
183    }
184    if (item !== null && /^\s+\S/u.test(line)) {
185      item.text.push(line.trim())
186      continue
187    }
188    if (item !== null || quote.length > 0) {
189      flush()
190    }
191    paragraph.push(line.trim())
192  }
193  flush()
194
195  return blocks
196}
197
198type Word = Run & { hasSpace: boolean }
199
200function wordsOf(runs: readonly Run[]): Word[] {
201  const words: Word[] = []
202  for (const run of runs) {
203    for (const piece of run.text.split(/(\s+)/u)) {
204      if (piece.length === 0) {
205        continue
206      }
207      if (/^\s+$/u.test(piece)) {
208        const previous = words.at(-1)
209        if (previous !== undefined) {
210          previous.hasSpace = true
211        }
212        continue
213      }
214      words.push({ ...run, text: piece, hasSpace: false })
215    }
216  }
217
218  return words
219}
220
221function sameStyle(first: Run, second: Run): boolean {
222  return first.isBold === second.isBold && first.isItalic === second.isItalic && first.isCode === second.isCode && first.isLink === second.isLink
223}
224
225function breakWord(word: Word, maxWidth: number, size: number): Word[] {
226  const pieces: Word[] = []
227  let current = ''
228  for (const char of word.text) {
229    if (current.length > 0 && runWidth({ ...word, text: current + char }, size) > maxWidth) {
230      pieces.push({ ...word, text: current, hasSpace: false })
231      current = ''
232    }
233    current += char
234  }
235  pieces.push({ ...word, text: current })
236
237  return pieces
238}
239
240// Greedy, word by word; a word wider than the line is broken by characters.
241// `firstWidth` keeps the first line clear of a badge drawn at its end.
242export function wrapRuns(runs: readonly Run[], maxWidth: number, size: number, firstWidth = maxWidth): Run[][] {
243  const lines: Word[][] = []
244  let line: Word[] = []
245  let width = 0
246  const space = textWidth(' ', size)
247  const words = wordsOf(runs).flatMap(i => (runWidth(i, size) > maxWidth ? breakWord(i, maxWidth, size) : [i]))
248  for (const word of words) {
249    const previous = line.at(-1)
250    const gap = previous?.hasSpace === true ? space : 0
251    const wordWidth = runWidth(word, size)
252    if (line.length > 0 && width + gap + wordWidth > (lines.length === 0 ? firstWidth : maxWidth)) {
253      lines.push(line)
254      line = []
255      width = 0
256    }
257    width += (line.length > 0 ? gap : 0) + wordWidth
258    line.push(word)
259  }
260  if (line.length > 0) {
261    lines.push(line)
262  }
263
264  // The space after a code span is drawn in the text's font, not the code's
265  // wider one.
266  return lines.map(words => {
267    const merged: Run[] = []
268    const append = (run: Run) => {
269      const previous = merged.at(-1)
270      if (previous !== undefined && sameStyle(previous, run)) {
271        previous.text += run.text
272      } else {
273        merged.push({ ...run })
274      }
275    }
276    words.forEach((word, index) => {
277      const { hasSpace, ...run } = word
278      const hasGap = hasSpace && index < words.length - 1
279      if (run.isCode === true) {
280        append(run)
281        if (hasGap) {
282          append({ text: ' ' })
283        }
284      } else {
285        append({ ...run, text: run.text + (hasGap ? ' ' : '') })
286      }
287    })
288
289    return merged
290  })
291}
292
293// Cuts wrapped lines to `maxLines`, the last one ending in an ellipsis.
294export function clampLines(lines: Run[][], maxLines: number, maxWidth: number, size: number): Run[][] {
295  if (lines.length <= maxLines) {
296    return lines
297  }
298  const kept = lines.slice(0, maxLines)
299  const last = kept[maxLines - 1] ?? []
300  const text = last.map(i => i.text).join('')
301  kept[maxLines - 1] = [{ ...(last[0] ?? { text: '' }), text: fitText(`${text}…`, maxWidth, size, last[0]?.isBold === true) }]
302
303  return kept
304}
305
306export function lineSvg(runs: readonly Run[], x: number, y: number, size: number, tone = 't', weight?: number): string {
307  const spans = runs.map(run => {
308    const classes = run.isCode ? 'code' : run.isLink ? 'link' : tone
309    const attributes = [
310      `class="${classes}"`,
311      run.isBold === true || weight !== undefined ? `font-weight="${run.isBold === true ? 650 : weight}"` : '',
312      run.isItalic === true ? 'font-style="italic"' : '',
313      run.isCode === true ? `font-family="${MONO}" font-size="${(size * 0.92).toFixed(1)}"` : '',
314      run.isLink === true ? 'text-decoration="underline"' : '',
315    ].filter(i => i.length > 0)
316
317    return `<tspan ${attributes.join(' ')}>${escape(run.text)}</tspan>`
318  })
319
320  return `<text x="${x}" y="${y.toFixed(1)}" font-size="${size}">${spans.join('')}</text>`
321}
322
323// A pane reports its width in columns only; about 8 CSS pixels each, as
324// savvy-progress measures it, minus the pane's own inset.
325export function cardWidthOf(columns: number): number {
326  return Math.max(240, Math.min(900, (columns || 40) * 8 - 8))
327}
328
329// The columns a native control laid over a card is pushed in by to clear
330// `pixels` of the card, at the same 8 pixels per column.
331export function columnsOf(pixels: number): number {
332  return Math.ceil(pixels / 8)
333}
334
335// About how wide the desktop draws a row of native buttons with these labels,
336// for text that has to keep clear of buttons laid over a card.
337export function buttonWidthOf(labels: readonly string[]): number {
338  return labels.reduce((sum, label) => sum + textWidth(label, 13) + 30, 0) + Math.max(0, labels.length - 1) * 8
339}
340
341export function svgDocument(width: number, height: number, body: string): string {
342  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
343}
344
345export type Segment = 'answered' | 'skipped' | 'current' | 'open'
346
347export type Card = { source: string; height: number; alt: string }
348
349export type Variant = 'card' | 'card-accent' | 'card-success' | 'card-warning' | 'card-error'
350
351export type Section = { height: number; draw: (y: number) => string }
352
353export const PAD = 18
354export const SIZE = 14
355export const LINE = 21
356export const EYEBROW = 11
357export const RADIUS = 14
358
359// The engine refuses an Svg taller than 4096 pixels; a longer card is cut
360// between sections into several cards of the same look.
361export const MAX_HEIGHT = 4000
362
363export function baseline(y: number, line: number, lineHeight = LINE): number {
364  return y + line * lineHeight + lineHeight * 0.72
365}
366
367export function frame(width: number, height: number, variant: Variant): string {
368  return `<rect class="${variant}" x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}"/>`
369}
370
371export function eyebrowSvg(text: string, x: number, y: number, tone = 'eyebrow', anchor = 'start'): string {
372  return `<text class="${tone}" x="${x}" y="${y}" font-size="${EYEBROW}" font-weight="700" letter-spacing="1.1" text-anchor="${anchor}">${escape(text)}</text>`
373}
374
375export function eyebrowWidth(text: string): number {
376  return textWidth(text, EYEBROW, true) + text.length * 1.1
377}
378
379export function textSection(
380  runs: readonly Run[],
381  x: number,
382  maxWidth: number,
383  options: { tone?: string; size?: number; maxLines?: number; weight?: number; firstWidth?: number } = {},
384): Section {
385  const size = options.size ?? SIZE
386  const lineHeight = Math.round(size * 1.5)
387  const wrapped = wrapRuns(runs, maxWidth, size, options.firstWidth)
388  const lines = options.maxLines === undefined ? wrapped : clampLines(wrapped, options.maxLines, maxWidth, size)
389
390  return {
391    height: lines.length * lineHeight,
392    draw: y => lines.map((line, index) => lineSvg(line, x, baseline(y, index, lineHeight), size, options.tone, options.weight)).join(''),
393  }
394}
395
396export function codeSection(lines: readonly string[], x: number, maxWidth: number): Section {
397  const size = 12.5
398  const lineHeight = 18
399  const perLine = Math.max(8, Math.floor((maxWidth - 24) / (size * 0.62)))
400  const rows = lines.flatMap(line => {
401    const chars = Array.from(line)
402    const pieces: string[] = []
403    for (let index = 0; index < Math.max(1, chars.length); index += perLine) {
404      pieces.push(chars.slice(index, index + perLine).join(''))
405    }
406
407    return pieces
408  })
409
410  return {
411    height: rows.length * lineHeight + 20,
412    draw: y =>
413      `<rect class="codebg" x="${x}" y="${y}" width="${maxWidth}" height="${rows.length * lineHeight + 20}" rx="8"/>` +
414      rows.map((row, index) => lineSvg([{ text: row, isCode: true }], x + 12, baseline(y + 10, index, lineHeight), size / 0.92)).join(''),
415  }
416}
417
418export function blockSection(block: Block, x: number, maxWidth: number): Section {
419  switch (block.kind) {
420    case 'code':
421      return codeSection(block.lines, x, maxWidth)
422    case 'heading':
423      return textSection(block.runs, x, maxWidth, { size: SIZE + 1.5, weight: 650 })
424    case 'quote': {
425      const text = textSection(block.runs, x + 14, maxWidth - 14, { tone: 'muted' })
426
427      return { height: text.height, draw: y => `<rect class="rule" x="${x}" y="${y + 2}" width="3" height="${text.height - 4}" rx="1.5"/>${text.draw(y)}` }
428    }
429    case 'item': {
430      const indent = block.marker === '•' ? 16 : textWidth(block.marker, SIZE) + 8
431      const text = textSection(block.runs, x + indent, maxWidth - indent)
432
433      return {
434        height: text.height,
435        draw: y => `<text class="muted" x="${x + (block.marker === '•' ? 3 : 0)}" y="${baseline(y, 0).toFixed(1)}" font-size="${SIZE}">${escape(block.marker)}</text>${text.draw(y)}`,
436      }
437    }
438    default:
439      return textSection(block.runs, x, maxWidth)
440  }
441}
442
443// Paragraphs sit further apart than the items of one list.
444export function blockSections(blocks: readonly Block[], x: number, maxWidth: number): Section[] {
445  return blocks.map((block, index) => {
446    const section = blockSection(block, x, maxWidth)
447    const previous = blocks[index - 1]
448    const spacing = index === 0 ? 0 : previous?.kind === 'item' && block.kind === 'item' ? 4 : 10
449
450    return { height: section.height + spacing, draw: y => section.draw(y + spacing) }
451  })
452}
453
454// Stacks sections inside one frame; past MAX_HEIGHT the rest go on in a next one.
455// `minHeight` keeps a short card tall enough for native controls laid over it.
456export function cards(width: number, variant: Variant, sections: readonly Section[], alt: string, decoration = '', minHeight = 0): Card[] {
457  const result: Card[] = []
458  let pending: Section[] = []
459  let used = 2 * PAD
460  const close = () => {
461    if (pending.length === 0) {
462      return
463    }
464    let y = PAD
465    const body = pending.map(section => {
466      const drawn = section.draw(y)
467      y += section.height
468
469      return drawn
470    })
471    const natural = Math.ceil(y + PAD)
472    const height = result.length === 0 ? Math.max(natural, minHeight) : natural
473    result.push({ height, alt, source: svgDocument(width, height, frame(width, height, variant) + (result.length === 0 ? decoration : '') + body.join('')) })
474    pending = []
475    used = 2 * PAD
476  }
477  for (const section of sections) {
478    if (pending.length > 0 && used + section.height > MAX_HEIGHT) {
479      close()
480    }
481    pending.push(section)
482    used += section.height
483  }
484  close()
485
486  return result
487}
488
489export const GLOW =
490  '<defs><linearGradient id="glow" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d97757" stop-opacity="0.14"/><stop offset="0.55" stop-color="#d97757" stop-opacity="0"/></linearGradient></defs>'
491
492// The card heading a pane: an eyebrow, a count at the right, a title of up
493// to two lines and, given segments, a bar with one segment per step.
494// `reserve` keeps the title clear of native buttons laid over the right side;
495// a card without a bar is too low for them beside the count at its top right,
496// so the count then goes beneath the title.
497export function headerCard(card: { width: number; eyebrow: string; title: string; counter: string; segments?: readonly Segment[]; reserve?: number }): Card {
498  const { width } = card
499  const segments = card.segments ?? []
500  const inner = width - 2 * PAD
501  const reserve = card.reserve ?? 0
502  const isCountBelow = reserve > 0 && segments.length === 0
503  const counterWidth = isCountBelow ? 0 : textWidth(card.counter, 12)
504  const eyebrow = fitText(card.eyebrow, inner - counterWidth - 16 - (isCountBelow ? reserve : 0), EYEBROW, true)
505  const title = textSection(parseInline(card.title).map(i => ({ ...i, isBold: true })), PAD, inner - reserve, { size: 17, maxLines: 2 })
506  const titleTop = PAD + EYEBROW + 8
507  const countY = titleTop + title.height + 14
508  const barY = titleTop + title.height + 12
509  const height = isCountBelow ? countY + PAD - 2 : segments.length === 0 ? titleTop + title.height + PAD - 4 : barY + 6 + PAD
510  const count = Math.max(1, segments.length)
511  const segmentWidth = (inner - 4 * (count - 1)) / count
512  const bars = segments.map(
513    (segment, index) => `<rect class="${segment}" x="${(PAD + index * (segmentWidth + 4)).toFixed(1)}" y="${barY}" width="${segmentWidth.toFixed(1)}" height="6" rx="3"/>`,
514  )
515  const body = [
516    frame(width, height, 'card'),
517    GLOW,
518    `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}" fill="url(#glow)"/>`,
519    eyebrowSvg(eyebrow, PAD, PAD + EYEBROW),
520    isCountBelow
521      ? `<text class="muted" x="${PAD}" y="${countY}" font-size="12.5">${escape(fitText(card.counter, inner - reserve, 12.5))}</text>`
522      : `<text class="muted" x="${width - PAD}" y="${PAD + EYEBROW}" font-size="12" text-anchor="end">${escape(card.counter)}</text>`,
523    title.draw(titleTop),
524    ...bars,
525  ]
526
527  return { height, alt: `${card.eyebrow}: ${plainOf(card.title)}, ${card.counter}`, source: svgDocument(width, height, body.join('')) }
528}
529
530export function noticeCard(width: number, variant: 'card-warning' | 'card-error', title: string, text: string, reserve = 0): Card[] {
531  const inner = width - 2 * PAD - reserve
532  const body = textSection([{ text }], PAD, inner, { tone: 'muted' })
533  const sections = [textSection([{ text: title, isBold: true }], PAD, inner, { size: 15 }), { height: body.height + 6, draw: (y: number) => body.draw(y + 6) }]
534
535  return cards(width, variant, sections, `${title} ${text}`, '', reserve > 0 ? 72 : 0)
536}
537
538const FIELD_HEIGHT = 72
539
540// A card holding a native text field: its label and a hint in a column at the
541// left, the rest left free for the field laid over it. `labelWidth` is that
542// column's width; the field starts past it.
543export function fieldCard(width: number, label: string, hint: string, labelWidth: number): Card {
544  const maxWidth = labelWidth - PAD - 12
545  const hints = wrapRuns([{ text: hint }], maxWidth, 12).slice(0, 2)
546  const top = FIELD_HEIGHT / 2 - (hints.length * 16 + EYEBROW + 6) / 2 + EYEBROW
547  const body = [
548    frame(width, FIELD_HEIGHT, 'card'),
549    eyebrowSvg(fitText(label, maxWidth, EYEBROW, true), PAD, top),
550    ...hints.map((line, index) => lineSvg(line, PAD, top + 18 + index * 16, 12, 'muted')),
551    `<rect class="rule" x="${labelWidth - 6}" y="16" width="1" height="${FIELD_HEIGHT - 32}"/>`,
552  ]
553
554  return { height: FIELD_HEIGHT, alt: `${label}: ${hint}`, source: svgDocument(width, FIELD_HEIGHT, body.join('')) }
555}
556
557// Below this width a card leaves too little room beside native controls, so
558// they go beneath it instead of over it.
559export const ROOMY_WIDTH = 420
560
561// A slim card for a row of native buttons laid over it; given a label, that
562// heads the bar at its left, as a section title with its actions beside it.
563export function barCard(width: number, alt: string, label = '', variant: Variant = 'card', reserve = 0): Card {
564  const height = 56
565  const text = label.length === 0 ? '' : eyebrowSvg(fitText(label, width - 2 * PAD - reserve - 12, EYEBROW, true), PAD, height / 2 + EYEBROW / 2 - 1)
566
567  return { height, alt, source: svgDocument(width, height, frame(width, height, variant) + text) }
568}
569
570// A diff or a list of changes in the code font: added lines green, removed
571// ones red, hunk heads in the accent color. Long lines are cut, not wrapped,
572// so the columns stay aligned.
573export function diffCards(width: number, text: string, alt: string): Card[] {
574  const size = 12
575  const lineHeight = 17
576  const maxWidth = width - 2 * PAD
577  const rows = text.replace(/\s+$/u, '').split('\n')
578  const toneOf = (row: string) =>
579    /^\+(?!\+\+)/u.test(row) ? 'success' : /^-(?!--)/u.test(row) ? 'error' : /^@@/u.test(row) ? 'accent' : /^(diff |index |\+\+\+|---)/u.test(row) ? 'muted' : 't'
580  const sections: Section[] = rows.map(row => ({
581    height: lineHeight,
582    draw: y =>
583      `<text class="${toneOf(row)} mono" x="${PAD}" y="${(y + lineHeight * 0.75).toFixed(1)}" font-size="${size}">${escape(fitMono(row, maxWidth, size))}</text>`,
584  }))
585
586  return cards(width, 'card', sections, alt)
587}
588
589function fitMono(text: string, maxWidth: number, size: number): string {
590  const chars = Array.from(text.replace(/\t/gu, '  '))
591  const fits = Math.floor(maxWidth / (size * 0.62))
592
593  return chars.length <= fits ? chars.join('') : `${chars.slice(0, Math.max(1, fits - 1)).join('')}…`
594}
595
596// An eyebrow line heading a card, with an optional second one at its right.
597export function eyebrowSection(left: string, right: string | null, rightTone: string, width: number): Section {
598  return {
599    height: EYEBROW + 10,
600    draw: y => eyebrowSvg(left, PAD, y + EYEBROW) + (right === null ? '' : eyebrowSvg(right, width - PAD, y + EYEBROW, rightTone, 'end')),
601  }
602}
603
hooks/relay.ts 240 lines
1import type { Answer, Link, Presence, Role, Task } from '../types'
2
3export const STALE_MS = 90_000
4
5export const USAGE =
6  '/relay lead <channel> in the planning session, /relay worker [channel] in the session that does the work, /relay send [text], /relay off, /relay alone shows what is pending.'
7
8// Prompts for the other session come as fenced blocks without a language or
9// tagged as text; a block tagged bash, json or ts is code to read, not to relay.
10const PROMPT_LANGUAGES = new Set(['', 'text', 'txt', 'plaintext', 'markdown', 'md', 'prompt'])
11
12const OPENING_FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/
13const CLOSING_FENCE = /^ {0,3}(`{3,}|~{3,})\s*$/
14
15export type RelayCommand =
16  | { kind: 'open' }
17  | { kind: 'join'; role: Role; channel: string | null }
18  | { kind: 'send'; text: string }
19  | { kind: 'off' }
20  | { kind: 'invalid'; reason: string }
21
22export function promptBlocksOf(answer: string, minChars: number): string[] {
23  const blocks: string[] = []
24  let open: { indent: number; marker: string; language: string; lines: string[] } | null = null
25  for (const line of answer.replace(/\r\n?/g, '\n').split('\n')) {
26    if (open === null) {
27      const match = OPENING_FENCE.exec(line)
28      const [, indent = '', marker = '', info = ''] = match ?? []
29      // A backtick fence whose info string holds a backtick is inline code.
30      if (match === null || (marker.startsWith('`') && info.includes('`'))) {
31        continue
32      }
33      open = { indent: indent.length, marker, language: (info.trim().split(/\s+/)[0] ?? '').toLowerCase(), lines: [] }
34      continue
35    }
36    const closing = CLOSING_FENCE.exec(line)?.[1]
37    if (closing !== undefined && closing[0] === open.marker[0] && closing.length >= open.marker.length) {
38      const body = open.lines.join('\n').trim()
39      if (PROMPT_LANGUAGES.has(open.language) && body.length >= minChars) {
40        blocks.push(body)
41      }
42      open = null
43      continue
44    }
45    const strip = Math.min(open.indent, line.length - line.trimStart().length)
46    open.lines.push(line.slice(strip))
47  }
48
49  return blocks
50}
51
52export function channelOf(text: string): string | null {
53  const channel = text.trim().toLowerCase().replace(/\s+/g, '-')
54
55  return /^[a-z0-9][a-z0-9._-]{0,39}$/.test(channel) ? channel : null
56}
57
58export function folderNameOf(path: string): string {
59  const parts = path.replace(/\\/g, '/').split('/').filter(i => i.length > 0)
60
61  return parts.at(-1) ?? ''
62}
63
64export function parseCommand(args: string): RelayCommand {
65  const trimmed = args.trim()
66  if (trimmed.length === 0) {
67    return { kind: 'open' }
68  }
69  const word = trimmed.split(/\s+/)[0] ?? ''
70  const remainder = trimmed.slice(word.length).trim()
71  const action = word.toLowerCase()
72  if (action === 'lead' || action === 'worker') {
73    if (remainder.length === 0) {
74      return { kind: 'join', role: action, channel: null }
75    }
76    const channel = channelOf(remainder)
77    if (channel === null) {
78      return {
79        kind: 'invalid',
80        reason: `"${remainder}" is not a channel name: letters, digits, dots, dashes and underscores, up to 40.`,
81      }
82    }
83
84    return { kind: 'join', role: action, channel }
85  }
86  if (action === 'send') {
87    return { kind: 'send', text: remainder }
88  }
89  if (action === 'off') {
90    return { kind: 'off' }
91  }
92
93  return { kind: 'invalid', reason: USAGE }
94}
95
96export function previewOf(text: string, maxChars = 70): string {
97  const line =
98    text
99      .split('\n')
100      .map(i => i.replace(/^\s*(#+|>|[-*+]|\d+\.)\s+/, '').replace(/[*_`]/g, '').trim())
101      .find(i => i.length > 0) ?? ''
102
103  return line.length > maxChars ? `${line.slice(0, maxChars - 1).trimEnd()}…` : line
104}
105
106export function formatChars(count: number): string {
107  return count < 1000 ? `${count} chars` : `${(count / 1000).toFixed(1)}k chars`
108}
109
110export function formatDuration(ms: number): string {
111  const seconds = Math.max(0, Math.floor(ms / 1000))
112  if (seconds < 60) {
113    return `${seconds}s`
114  }
115  const minutes = Math.floor(seconds / 60)
116  if (minutes < 60) {
117    return `${minutes}m`
118  }
119
120  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
121}
122
123export function forwardTextOf(channel: string, answers: readonly Answer[]): string {
124  if (answers.length === 1) {
125    return `The latest answer from the Claude session working on "${channel}", relayed unchanged:\n\n${answers[0]?.text ?? ''}`
126  }
127  const parts = answers.map((answer, index) => `--- Answer ${index + 1} of ${answers.length} ---\n\n${answer.text}`)
128
129  return `The latest ${answers.length} answers from the Claude session working on "${channel}", oldest first, relayed unchanged:\n\n${parts.join('\n\n')}`
130}
131
132export function isOnline(presence: Presence | null, now: number): presence is Presence {
133  return presence !== null && now - presence.seenAt <= STALE_MS
134}
135
136export type StatusInput = {
137  link: Link
138  presence: Presence | null
139  answers: readonly Answer[]
140  tasks: readonly Task[]
141  isAttached: boolean
142  isActive: boolean
143  now: number
144}
145
146export function statusOf(input: StatusInput): string {
147  const { link, presence, answers, tasks, isAttached, isActive, now } = input
148  if (link.role === 'worker') {
149    const parts = [`⇄ ${link.channel} · worker`]
150    if (!isActive) {
151      parts.push('another session is the worker')
152    } else if (tasks.length > 0) {
153      parts.push('task waiting')
154    }
155
156    return parts.join(' · ')
157  }
158  const parts = [`⇄ ${link.channel}`]
159  if (!isOnline(presence, now)) {
160    parts.push('no worker open')
161  } else if (presence.busySince !== null) {
162    parts.push(`working ${formatDuration(now - presence.busySince)}`)
163  }
164  if (answers.length > 0) {
165    const count = answers.length === 1 ? '1 answer' : `${answers.length} answers`
166    parts.push(isAttached ? `${count} attached` : `${count} waiting`)
167  }
168
169  return parts.join(' · ')
170}
171
172export function footerOf(input: StatusInput): string {
173  const { link, presence, answers, tasks, isAttached, isActive, now } = input
174  const parts = [`⇄ ${link.channel}`]
175  if (link.role === 'worker') {
176    parts.push('worker')
177    if (!isActive) {
178      parts.push('⏸')
179    } else if (tasks.length > 0) {
180      parts.push('📥')
181    }
182
183    return parts.join(' ')
184  }
185  if (!isOnline(presence, now)) {
186    parts.push('offline')
187  } else if (presence.busySince !== null) {
188    parts.push(`⚙ ${formatDuration(now - presence.busySince)}`)
189  }
190  if (answers.length > 0) {
191    parts.push(`${isAttached ? '📎' : '📨'} ${answers.length}`)
192  }
193
194  return parts.join(' ')
195}
196
197export function newId(now: number, random: number): string {
198  const suffix = Math.floor(random * 36 ** 6)
199    .toString(36)
200    .padStart(6, '0')
201
202  return `${String(now).padStart(14, '0')}-${suffix}`
203}
204
205function isRecord(value: unknown): value is Record<string, unknown> {
206  return typeof value === 'object' && value !== null && !Array.isArray(value)
207}
208
209export function isLink(value: unknown): value is Link {
210  return (
211    isRecord(value) &&
212    (value.role === 'lead' || value.role === 'worker') &&
213    typeof value.channel === 'string' &&
214    channelOf(value.channel) === value.channel
215  )
216}
217
218export function isTask(value: unknown): value is Task {
219  return (
220    isRecord(value) &&
221    typeof value.id === 'string' &&
222    typeof value.text === 'string' &&
223    typeof value.isFresh === 'boolean' &&
224    typeof value.sentAt === 'number'
225  )
226}
227
228export function isAnswer(value: unknown): value is Answer {
229  return isRecord(value) && typeof value.id === 'string' && typeof value.text === 'string' && typeof value.finishedAt === 'number'
230}
231
232export function isPresence(value: unknown): value is Presence {
233  return (
234    isRecord(value) &&
235    typeof value.sessionId === 'string' &&
236    (value.busySince === null || typeof value.busySince === 'number') &&
237    typeof value.seenAt === 'number'
238  )
239}
240
types/index.d.ts 47 lines
1export type Role = 'lead' | 'worker'
2
3export type Link = {
4  role: Role
5  channel: string
6}
7
8export type Task = {
9  id: string
10  text: string
11  isFresh: boolean
12  sentAt: number
13}
14
15export type Answer = {
16  id: string
17  text: string
18  finishedAt: number
19}
20
21export type Presence = {
22  sessionId: string
23  busySince: number | null
24  seenAt: number
25}
26
27export type Offer = {
28  blocks: string[]
29  index: number
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'session-relay': {
35      link: Link | null
36      tasks: Task[]
37      answers: Answer[]
38      offer: Offer | null
39      isAttached: boolean
40      presence: Presence | null
41      isActive: boolean
42      footer: string | null
43      isHubbed: boolean
44    }
45  }
46}
47