SLOPSHOPPER

peer

Watch and drive peer rooms from Claude Code, and deliver room messages to main as they come

newpanespinnerrowsguardtoast
★ 4v0.1.50no licenseupdated 2026-10-15r13v/peer/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · peer
│ ┃ peer ✕ › fix the failing auth test and add an audit log call │ ┃ No active peer room in this checkout. Start │ ┃ one with /peer:peer TASK. ● peer: peer watch stopped: no output │ ┃ peer watch stopped: no output; retrying ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · peer
No active peer room in this checkout. Start one with /peer:peer TASK. peer watch stopped: no output; retrying
README

peer for Claude Code

The peer plugin shows a session's peer rooms in Claude Code: in the Code tab of the desktop app, in the terminal and in VS Code. A session's rooms are the ones it started with peer start, leads with peer wait --as main or joined with a peer join command of its own; after a rewind or a resume the plugin finds them again in the conversation; another session's rooms in the same checkout stay out of it, and the peer TUI shows them all. Outside a Git checkout, the plugin shows nothing.

  • /peer:peer TASK starts the work: the plugin's own skill runs peer skills flow, and as main, Claude reads the room through the plugin instead of peer wait. With the plugin, Claude Code does not need the standalone peer skill.
  • Pane. It opens by itself when a room is active and there is room for it, and from the footer button.
  • One row of tabs: Chat with its message count, a tab for each member, ● while its agent runs and ○ once it stopped, and ⏹ end at the right. A member's tab shows its model and state, a kick button and its log: its text, its commands and their results. Kick and end act on a second press: the first turns the button into confirm for three seconds.
  • Chat shows the newest message first. Each message is a card in its author's color, the time first, with Markdown, code blocks and links to the checkout's files, such as main.go:42. Notices from peer take one line, and a long message shows its first lines until you expand it. A timeline shows who wrote when (not in the terminal). The pane shows each new message unless you hold its keyboard.
  • Boards. The pane's boards tab lists the boards of the room it shows, from peer board list: each with its title, a link to open it in the browser (start peer if the link does not open) and a preview. The preview is a schematic of the frames, nodes and edges that the plugin lays out itself, so a board nobody opened in the browser has one too; in the terminal, a list of frames with their node and open-thread counts. See docs/BOARD.md.
  • Footer button. At the right of the prompt footer, 👀, with the number of messages that came since the pane last showed, opens the pane, and closes it when the pane already shows its room; with several rooms, a numbered button for each, in the order of the pane's room list, opens the pane on that room or closes it the same way.
  • Delivery to main. When Claude Code is main of a room, the plugin reads the room for main and puts each batch of messages into the chat as a turn of its own: right away while Claude is idle, and right after the running turn while it works. These rows are hidden in the chat, since the toast, the pane and the footer's count show the messages; in the terminal and VS Code, ctrl+o shows them as the model reads them. Main does not run peer wait; the plugin answers a wait for the room itself, so the room has only one reader as main.
  • Toasts when a member joins, exits or is kicked, and when a room ends.

The plugin's version is the CLI's: each release of peer sets it, so claude plugin update brings the plugin that goes with the new CLI. The plugin runs peer watch, peer memberlog and peer ack from your PATH, so it needs a peer with these commands. After each delivery, peer ack moves main's cursor just past the last message in the chat, so a peer wait run after the plugin is turned off gets the rest. When peer ack fails, for example with an older peer earlier on PATH, the pane shows why in red; the plugin tries that room again and goes on delivering the other rooms.

Install

claude plugin marketplace add r13v/peer
claude plugin install peer@r13v

If you installed the standalone skill for Claude Code before, remove it there and keep it for the other agents:

npx skills remove peer -g -a claude-code

To install peer itself, see the main README.

Develop

claude plugin validate plugin
claude plugin test plugin
claude --plugin-dir plugin

Check against a new Claude Code

Claude Code updates can break the plugin. Two checks catch it:

  • Typecheck (npm run typecheck:plugin) checks the hooks module and the tests against the types that Claude Code writes into plugin/.claude-plugin/types (claude-code and claude-code-tools, not claude-code-mcp, which depends on the machine's MCP servers). Only an interactive load of the plugin writes them: claude --plugin-dir plugin in a terminal, or the desktop app with the plugin. claude -p does not. The first line of claude-code/index.d.ts names the Claude Code that wrote them, which can differ from claude --version when the desktop app and the CLI differ.
  • Tests (npm run test:plugin) run plugin/tests/*.test.ts in the kit inside the claude binary, without a login. They catch changes of the hook API and of the plugin's logic. They do not catch engine semantics: everything below the plugin is a stub of the test. engine.test.ts pins the hooks that keep one reader as main: the plugin answers main's peer wait and denies a line that mixes it with other commands.

npm run engine-check runs both once per pair of versions: the Claude Code that wrote the types and claude --version. It keeps the last pair that passed in ~/.cache/peer/engine-checked, so the stamp belongs to the machine, not to the checkout; --force checks again. On a failure it prints the step and the last 30 lines of its output and exits 1. When the two versions differ, it says that the types of the CLI were not checked. PEER_ENGINE_CHECK set in the environment skips the check, so a Claude Code session that a step starts does not run it again.

The Claude Code workflow runs claude plugin validate and the tests against the latest Claude Code every day and on demand, so a release that breaks the plugin fails there before you update; GitHub mails the failure. It cannot run the typecheck, since CI has no interactive load to write the types.

To run engine-check when a session starts, add a hook to your own .claude/settings.local.json. Claude Code puts the hook's output into the session only on exit 0, hence || true:

"hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && node scripts/engine-check.ts || true", "timeout": 180 }] }] }
Source 3 files
hooks/register.tsx 1391 lines
1import type { EngineInterface as Engine, Register } from "claude-code"
2
3import type {
4	PeerBoard,
5	PeerBoardRef,
6	PeerLogEntry,
7	PeerMark,
8	PeerMember,
9	PeerMessage,
10	PeerRoom,
11	PeerState,
12} from "../types"
13import { boardStats, boardSvg, frameLines } from "./board-preview.ts"
14
15const PANE = "peer"
16const EVERYONE = "*"
17// BOARDS is the tab value of the room's boards; ":" keeps it apart from roles.
18const BOARDS = ":boards"
19// ICON stands for peer in the prompt footer.
20const ICON = "👀"
21
22// All of the plugin's session state is one value: the engine lists the
23// values a module reads and writes, so each reference is a literal.
24const STATE = { plugin: "peer", key: "state" } as const
25
26const INITIAL: PeerState = {
27	rooms: {},
28	messages: {},
29	selected: null,
30	tab: "chat",
31	confirm: null,
32	led: [],
33	own: [],
34	watchError: null,
35	ackErrors: {},
36	log: [],
37	// delivered marks, per led room, the last message for main that is in
38	// the chat, so a reload goes on from it.
39	delivered: {},
40	// notified lists the led rooms whose end main has been told about.
41	notified: [],
42	expanded: [],
43	seen: {},
44}
45
46const rooms = "rooms"
47const messages = "messages"
48const selected = "selected"
49const tab = "tab"
50const confirm = "confirm"
51const led = "led"
52const own = "own"
53const watchError = "watchError"
54const ackErrors = "ackErrors"
55const log = "log"
56const delivered = "delivered"
57const notified = "notified"
58const expanded = "expanded"
59const seen = "seen"
60
61const HANDOFF =
62	"The peer plugin reads this room for main and puts its messages into this chat as a turn of their own, once this session is free. Do not run peer wait for this room; go on with the task."
63
64// Module variables start over on a reload; everything drawn is in $.state.
65let isReady = false
66// watch's last failure, so the transcript tells of each kind once, and its
67// pause before the next try.
68let lastWatchError = ""
69let watchRetry = 2500
70let isStarted = false
71let isPumping = false
72let isPumpWanted = false
73// The rooms restore found this session started, led once watch is ready.
74let toLead: string[] = []
75// Whether the person holds the pane's keyboard: then a new message does not
76// scroll it, so what they read stays put.
77let isPaneFocused = false
78// The host's offset from UTC in minutes: the module's environment does not
79// know the host's time zone, so times are shifted by hand.
80let utcOffset = 0
81// The checkout's files, so a message's file:line becomes a link.
82let files = { repo: "", paths: new Set<string>() }
83// The boards of each room, with their board.json once read, while the
84// boards tab shows; error says why the list could not be read.
85type BoardView = { ref: PeerBoardRef; board?: PeerBoard; error?: string }
86let boardViews: Record<string, { boards: BoardView[]; error?: string }> = {}
87
88// normalize fills in the fields a state lacks; a state without own takes
89// the rooms it led as its own.
90function normalize(value: Partial<PeerState> | undefined): PeerState {
91	return { ...INITIAL, ...value, own: value?.own ?? value?.led ?? [] }
92}
93
94// get reads a field of the plugin's state; while a drawing reads it, a
95// later put draws that drawing again.
96async function get<K extends keyof PeerState>(
97	$: Engine,
98	field: K,
99): Promise<PeerState[K]> {
100	const { value } = await $.state.get(STATE)
101	return normalize(value)[field]
102}
103
104// snapshot reads the whole state at once: a drawing reads it once, not
105// once per field.
106async function snapshot($: Engine): Promise<PeerState> {
107	const { value } = await $.state.get(STATE)
108	return normalize(value)
109}
110
111// put applies fn to a field and writes the state, again when another write
112// came first. A write that changes nothing is skipped, since each write
113// draws every drawing that reads the state again.
114async function put<K extends keyof PeerState>(
115	$: Engine,
116	field: K,
117	fn: (old: PeerState[K]) => PeerState[K],
118) {
119	for (;;) {
120		const { value, version } = await $.state.get(STATE)
121		const old = normalize(value)
122		const next = fn(old[field])
123		if (next === old[field]) return
124		const written = await $.state.set(
125			STATE,
126			{ ...old, [field]: next },
127			{ ifVersion: version },
128		)
129		if (written.isSet) return
130	}
131}
132
133// begin starts the session's background work once; session.start calls it,
134// and so do the first hooks of a mod loaded into a running session.
135async function begin($: Engine) {
136	if (isStarted) return
137	isStarted = true
138	// A pane left open by the module before a reload is drawn by this one.
139	$.ui.invalidate("ui.render")
140	await restore($)
141	watch($)
142	$.clock.every(3000, () => void refresh($))
143	void loadOffset($)
144	$.clock.every(60_000, () => void loadFiles($))
145	void loadFiles($)
146}
147
148// restore finds this session's rooms in its conversation when its state
149// starts empty while the chat goes on, as after a rewind or a resume: a
150// room joined there is its own again, and one started there is led again
151// once watch is ready.
152async function restore($: Engine) {
153	if ((await $.state.get(STATE)).value !== undefined) return
154	const found = await $.session.messages().catch(() => [])
155	if ("deny" in found) return
156	const joined: string[] = []
157	for (const use of found.flatMap((m) => m.toolUses)) {
158		if (
159			use.tool !== "Bash" ||
160			use.isError ||
161			typeof use.input.command !== "string"
162		)
163			continue
164		const all = shellCommands(use.input.command)
165		const commands = all
166			.map(peerArgs)
167			.filter((args): args is string[] => args !== null)
168		const stdout = (use.result as { stdout?: string } | undefined)?.stdout ?? ""
169		const [cmd, id, role] = commands[0] ?? []
170		if (
171			all.length === 1 &&
172			cmd === "join" &&
173			id &&
174			role &&
175			joinedRoom(stdout, id, role)
176		)
177			joined.push(id)
178		const room = commands.some((args) => args[0] === "start")
179			? await startedRoom(stdout)
180			: null
181		if (room) toLead.push(room.id)
182	}
183	const mine = [...new Set([...joined, ...toLead])]
184	if (mine.length > 0) await put($, own, () => mine)
185}
186
187// loadOffset reads the host's UTC offset, as +0300, and draws the times again.
188async function loadOffset($: Engine) {
189	try {
190		const { stdout } = await $.process.run(["date", "+%z"], { timeoutMs: 5000 })
191		const match = /^([+-])(\d\d)(\d\d)$/.exec(stdout.trim())
192		if (!match) return
193		utcOffset =
194			(match[1] === "-" ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3]))
195		$.ui.invalidate("ui.render")
196	} catch {
197		// times stay in UTC
198	}
199}
200
201async function loadFiles($: Engine) {
202	try {
203		const repo = current(await snapshot($))?.repo ?? (await $.session.cwd())
204		const { exitCode, stdout } = await $.process.run(
205			["git", "-C", repo, "ls-files"],
206			{ timeoutMs: 10_000 },
207		)
208		if (exitCode === 0)
209			files = { repo, paths: new Set(stdout.split("\n").filter(Boolean)) }
210	} catch {
211		// no links until the next try
212	}
213}
214
215// toNewest scrolls the pane to its top, where the newest message is.
216function toNewest($: Engine) {
217	void $.ui.scroll({ in: PANE, to: "start" }).catch(() => ({}))
218}
219
220function peer($: Engine, argv: string[], timeoutMs = 30_000) {
221	return $.process.run(["peer", ...argv], { timeoutMs })
222}
223
224// activeRooms lists this session's active rooms, the ones it leads first,
225// then the newest first. Other sessions' rooms of the checkout are theirs:
226// the plugin does not show them.
227function activeRooms(st: PeerState) {
228	const isLed = (room: PeerRoom) => (st.led.includes(room.id) ? 0 : 1)
229	return Object.values(st.rooms)
230		.filter((room) => !room.ended_at && st.own.includes(room.id))
231		.sort(
232			(a, b) => isLed(a) - isLed(b) || b.started_at.localeCompare(a.started_at),
233		)
234}
235
236// current is the room the pane shows: the one picked, else the first active.
237function current(st: PeerState) {
238	const picked =
239		st.selected && st.own.includes(st.selected)
240			? st.rooms[st.selected]
241			: undefined
242	return picked ?? activeRooms(st)[0] ?? null
243}
244
245async function openPane($: Engine) {
246	const opened = await $.ui
247		.open({ id: PANE, title: "peer" })
248		.catch(() => ({ isPlaced: false }))
249	if (opened.isPlaced) toNewest($)
250	return opened
251}
252
253// watch keeps rooms and messages current from peer watch, which reads
254// the store without moving anyone's cursor.
255function watch($: Engine) {
256	void (async () => {
257		let buffer = ""
258		let stderr = ""
259		try {
260			for await (const { stream, text } of $.process.spawn({
261				argv: ["peer", "watch"],
262			})) {
263				if (stream === "stderr") {
264					stderr += text
265					continue
266				}
267				buffer += text
268				const lines = buffer.split("\n")
269				buffer = lines.pop() ?? ""
270				await apply($, lines)
271			}
272		} catch (err) {
273			stderr += String(err)
274		}
275		const reason = stderr.trim().slice(0, 300) || "no output"
276		// A session outside a Git checkout has no rooms: that is not an error,
277		// and a cd into a checkout is picked up by the next try.
278		const isOutside = /inside the shared Git checkout/.test(reason)
279		if (!isOutside && reason !== lastWatchError)
280			$.ui.log(`peer watch stopped: ${reason}`, { to: "transcript" })
281		lastWatchError = reason
282		const isOldCli = /usage|unknown|not found|no such file/i.test(stderr)
283		const failure = isOldCli
284			? "peer watch failed: update the peer CLI (peer update or brew upgrade --cask peer)"
285			: `peer watch stopped: ${reason.slice(0, 200)}; retrying`
286		await put($, watchError, () => (isOutside ? null : failure))
287		watchRetry = Math.min(watchRetry * 2, 60_000)
288		$.clock.after(watchRetry, () => watch($))
289	})()
290}
291
292async function apply($: Engine, lines: string[]) {
293	const roomEvents: PeerRoom[] = []
294	const newMessages: Record<string, PeerMessage[]> = {}
295	let ready = false
296	for (const line of lines) {
297		if (!line.trim()) continue
298		let event: { event?: string; room?: unknown; message?: PeerMessage }
299		try {
300			event = JSON.parse(line)
301		} catch {
302			continue
303		}
304		if (
305			event.event === "room" &&
306			event.room &&
307			typeof event.room === "object"
308		) {
309			roomEvents.push(event.room as PeerRoom)
310		} else if (
311			event.event === "message" &&
312			typeof event.room === "string" &&
313			event.message
314		) {
315			const list = newMessages[event.room] ?? []
316			list.push(event.message)
317			newMessages[event.room] = list
318		} else if (event.event === "ready") {
319			ready = true
320		}
321	}
322	const before = await get($, rooms)
323	const known = await get($, messages)
324	// A restarted watch replays the transcript: keep each message once.
325	const fresh: Record<string, PeerMessage[]> = {}
326	for (const [id, list] of Object.entries(newMessages)) {
327		const ids = new Set((known[id] ?? []).map((m) => m.id))
328		const added = list.filter((m) => !ids.has(m.id))
329		if (added.length > 0) fresh[id] = added
330	}
331	if (roomEvents.length > 0) {
332		await put($, rooms, (all) => {
333			const next = { ...all }
334			for (const room of roomEvents) next[room.id] = room
335			return next
336		})
337	}
338	if (Object.keys(fresh).length > 0) {
339		await put($, messages, (all) => {
340			const next = { ...all }
341			for (const [id, list] of Object.entries(fresh))
342				next[id] = [...(next[id] ?? []), ...list]
343			return next
344		})
345	}
346	await put($, watchError, (old) => (old === null ? old : null))
347	if (ready) {
348		watchRetry = 2500
349		lastWatchError = ""
350	}
351	if (isReady) {
352		const mine = new Set(await get($, own))
353		for (const room of roomEvents) {
354			const was = before[room.id]
355			if (!mine.has(room.id)) continue
356			if (room.ended_at && was && !was.ended_at)
357				$.ui.toast(`peer: room ${room.id} ended`)
358		}
359		for (const [id, list] of Object.entries(fresh)) {
360			if (!mine.has(id)) continue
361			for (const m of list) {
362				if (m.from === "peer") $.ui.toast(`${id}: ${m.text}`)
363			}
364		}
365		const st = await snapshot($)
366		if (!isPaneFocused && st.tab === "chat" && fresh[current(st)?.id ?? ""])
367			toNewest($)
368	}
369	if (ready) {
370		// A watch started after a room ended does not list it: read the rest
371		// of a led room's transcript, then its state, from log and status.
372		const listed = new Set(roomEvents.map((room) => room.id))
373		const isLed = new Set(await get($, led))
374		const mine = new Set(await get($, own))
375		for (const room of Object.values(await get($, rooms))) {
376			if (room.ended_at || listed.has(room.id) || !mine.has(room.id)) continue
377			if (isLed.has(room.id) && !(await readLog($, room.id))) continue
378			const { exitCode, stdout } = await peer($, ["status", room.id])
379			if (exitCode === 0 && stdout.trim()) {
380				const status = JSON.parse(stdout) as PeerRoom
381				await put($, rooms, (all) => ({ ...all, [status.id]: status }))
382			}
383		}
384		if (!isReady) {
385			// Lead the restored rooms now that their messages are in: lead
386			// marks what main read against them.
387			const all = await get($, rooms)
388			for (const id of toLead)
389				if (all[id] && !all[id].ended_at) await lead($, id)
390			toLead = []
391			isReady = true
392			if (activeRooms(await snapshot($)).length > 0) void openPane($)
393		}
394	}
395	void pump($)
396}
397
398// readLog adds a room's whole transcript from peer log to the messages,
399// for what a watch may not have printed yet; it says whether it could.
400async function readLog($: Engine, id: string) {
401	const { exitCode, stdout, isStdoutTruncated } = await peer($, [
402		"log",
403		id,
404		"--json",
405	])
406	if (exitCode !== 0) return false
407	if (isStdoutTruncated) {
408		await put(
409			$,
410			watchError,
411			() =>
412				`the transcript of ${id} is over 4 MiB; read its end with peer log ${id}`,
413		)
414		return false
415	}
416	const list = stdout
417		.split("\n")
418		.filter((line) => line.trim())
419		.map((line) => JSON.parse(line) as PeerMessage)
420	await put($, messages, (all) => {
421		const ids = new Set((all[id] ?? []).map((m) => m.id))
422		return {
423			...all,
424			[id]: [...(all[id] ?? []), ...list.filter((m) => !ids.has(m.id))],
425		}
426	})
427	return true
428}
429
430// lead makes this session main's reader for room id. Messages come from
431// peer watch's transcript, not from wait, so a reload or a failed delivery
432// loses none; main's cursor moves only after a delivery. A reload between
433// a queued turn and its mark brings that batch again: at least once, not
434// exactly once.
435async function lead($: Engine, id: string, isNew = false) {
436	await put($, own, (list) => (list.includes(id) ? list : [...list, id]))
437	if ((await get($, led)).includes(id)) return
438	if (!(id in (await get($, delivered)))) {
439		// From a new room, deliver everything; otherwise start after what main
440		// already read with wait, which status counts as not unread.
441		let mark: PeerMark | null = null
442		if (!isNew) {
443			const { stdout } = await peer($, ["status", id])
444			const unread =
445				(JSON.parse(stdout || "{}") as PeerRoom).members?.find(
446					(m) => m.role === "main",
447				)?.unread ?? 0
448			const list = forMain((await get($, messages))[id] ?? [])
449			const last = list[list.length - unread - 1]
450			mark = last ? { id: last.id, at: last.at } : null
451		}
452		await put($, delivered, (all) => ({ ...all, [id]: mark }))
453	}
454	await put($, led, (list) => [...list, id])
455	void pump($)
456}
457
458function forMain(list: PeerMessage[]) {
459	return list.filter(
460		(m) => m.from !== "main" && (m.to === "main" || m.to === EVERYONE),
461	)
462}
463
464// after returns the messages of list that come after mark.
465function after(list: PeerMessage[], mark: PeerMark | null) {
466	if (!mark) return list
467	const i = list.findIndex((m) => m.id === mark.id)
468	if (i >= 0) return list.slice(i + 1)
469	const at = Date.parse(mark.at)
470	return list.filter((m) => Date.parse(m.at) > at)
471}
472
473// pump puts what is new for main in each led room into the chat, in order;
474// a mark moves only once its batch is there, and a failure is tried again.
475async function pump($: Engine) {
476	if (!isReady) return
477	// A call made while a pump runs is not dropped: that pump runs once more.
478	if (isPumping) {
479		isPumpWanted = true
480		return
481	}
482	isPumping = true
483	isPumpWanted = false
484	let hasFailed = false
485	try {
486		for (const id of await get($, led)) {
487			// A room that fails is tried again later; the rooms after it go on.
488			try {
489				await pumpRoom($, id)
490			} catch {
491				hasFailed = true
492			}
493		}
494	} catch {
495		hasFailed = true
496	} finally {
497		isPumping = false
498	}
499	if (hasFailed) $.clock.after(3000, () => void pump($))
500	if (isPumpWanted) void pump($)
501}
502
503// pumpRoom puts what is new for main in led room id into the chat, then
504// tells main that the room ended once it has.
505async function pumpRoom($: Engine, id: string) {
506	// watch can print a room's end before its last messages: read the
507	// whole transcript once the room has ended, before the end note.
508	const hasEnded = Boolean((await get($, rooms))[id]?.ended_at)
509	if (hasEnded && !(await readLog($, id))) throw new Error("no log")
510	for (;;) {
511		const pending = after(
512			forMain((await get($, messages))[id] ?? []),
513			(await get($, delivered))[id] ?? null,
514		)
515		const batch = pending.slice(0, 16)
516		const last = batch[batch.length - 1]
517		if (!last) break
518		if (!(await deliver($, id, batch))) throw new Error("not delivered")
519		await put($, delivered, (all) => ({
520			...all,
521			[id]: { id: last.id, at: last.at },
522		}))
523		await ack($, id, last)
524	}
525	// Ack the saved mark too: an ack that failed before a reload is
526	// tried again here.
527	await ack($, id, (await get($, delivered))[id] ?? null)
528	const room = (await get($, rooms))[id]
529	if (hasEnded && room) {
530		if (!(await get($, notified)).includes(id)) {
531			const reason = room.ended_reason ? ` (${room.ended_reason})` : ""
532			const note = `peer room ${id} has ended${reason}. Its members no longer read or answer messages.`
533			if (!(await deliver($, id, [], note))) throw new Error("not delivered")
534			await put($, notified, (list) => [...list, id])
535		}
536		await put($, led, (list) => list.filter((one) => one !== id))
537	}
538}
539
540// ack moves main's cursor just past mark, the last message in the chat, so
541// unread counts stay right and a wait run after the plugin is turned off
542// misses nothing. A failed ack throws, and the pump tries again later.
543async function ack($: Engine, id: string, mark: PeerMark | null) {
544	if (!mark) return
545	const { exitCode, stderr } = await peer($, [
546		"ack",
547		id,
548		"--as",
549		"main",
550		"--upto",
551		mark.id,
552	])
553	if (exitCode !== 0) {
554		// An old peer CLI has no ack and prints its usage.
555		const reason = /usage|unknown/i.test(stderr)
556			? "update the peer CLI (peer update or brew upgrade --cask peer)"
557			: stderr.trim().slice(0, 200) || `exit code ${exitCode}`
558		const text = `peer ack failed for ${id}: ${reason}`
559		await put($, ackErrors, (all) =>
560			all[id] === text ? all : { ...all, [id]: text },
561		)
562		throw new Error("not acked")
563	}
564	await put($, ackErrors, (all) => {
565		if (!(id in all)) return all
566		const { [id]: _, ...rest } = all
567		return rest
568	})
569}
570
571// deliver queues one batch, or a note, as a turn of its own: the session
572// runs it once it is idle, so no message lands in a turn that is ending.
573// It says whether the turn was queued.
574async function deliver(
575	$: Engine,
576	id: string,
577	list: PeerMessage[],
578	note?: string,
579) {
580	const body = list
581		.map(
582			(m) =>
583				`[${m.from} → ${m.to === EVERYONE ? "all" : m.to} · ${clock(m.at, true)}]\n${m.text}`,
584		)
585		.join("\n\n")
586	const text =
587		note ??
588		`peer room ${id}: ${list.length} new message${list.length === 1 ? "" : "s"} for main, delivered by the peer plugin; you do not run peer wait for this room.\n\n${body}`
589	const submitted = await $.prompt.submit({ text })
590	return !("drop" in submitted && submitted.drop)
591}
592
593// refresh keeps the footer's count and the shown log or boards tab current
594// while the pane is placed.
595async function refresh($: Engine) {
596	const pane = (await $.ui.panes()).find(
597		(one) => one.id === PANE && one.isPlaced,
598	)
599	if (!pane) return
600	const st = await snapshot($)
601	const room = current(st)
602	if (!room) return
603	// What the pane shows counts as read for the footer's count.
604	const count = (st.messages[room.id] ?? []).length
605	if (pane.isShown && st.seen[room.id] !== count)
606		await put($, seen, (all) => ({ ...all, [room.id]: count }))
607	const shown = await get($, tab)
608	if (shown === BOARDS) await loadBoards($, room)
609	else if (shown !== "chat") await loadLog($, room)
610}
611
612// loadBoards lists the boards that room's commands used, from peer board
613// list in its checkout, and reads each board.json whose mtime or revision
614// changed since the last read.
615async function loadBoards($: Engine, room: PeerRoom) {
616	const { exitCode, stdout, stderr } = await $.process.run(
617		["peer", "board", "list"],
618		{ cwd: room.repo, timeoutMs: 30_000 },
619	)
620	if (exitCode !== 0) {
621		const error = /usage|unknown/i.test(stderr)
622			? "peer board failed: update the peer CLI (peer update or brew upgrade --cask peer)"
623			: `peer board list failed: ${stderr.trim().slice(0, 200)}`
624		boardViews = { ...boardViews, [room.id]: { boards: [], error } }
625		$.ui.invalidate("ui.render")
626		return
627	}
628	let refs: PeerBoardRef[] = []
629	try {
630		refs = (JSON.parse(stdout) as { boards?: PeerBoardRef[] }).boards ?? []
631	} catch {
632		// an empty list
633	}
634	const before = boardViews[room.id]?.boards ?? []
635	const boards: BoardView[] = []
636	for (const ref of refs.filter((one) => one.rooms?.includes(room.id))) {
637		const old = before.find((view) => view.ref.path === ref.path)
638		// mtime has whole seconds: revision tells two writes in one second apart.
639		const isSame =
640			old?.board &&
641			old.ref.mtime === ref.mtime &&
642			old.ref.revision === ref.revision
643		if (ref.error)
644			boards.push({ ref, error: ref.error.message ?? ref.error.error })
645		else if (isSame) boards.push({ ...old, ref })
646		else boards.push(await readBoard($, ref))
647	}
648	boardViews = { ...boardViews, [room.id]: { boards } }
649	$.ui.invalidate("ui.render")
650}
651
652async function readBoard($: Engine, ref: PeerBoardRef): Promise<BoardView> {
653	try {
654		const text = await $.fs.read(`${ref.path}/board.json`)
655		return { ref, board: JSON.parse(text as string) as PeerBoard }
656	} catch (err) {
657		return {
658			ref,
659			error: `cannot read board.json: ${String(err).slice(0, 200)}`,
660		}
661	}
662}
663
664// loadLog reads the log of the member whose tab is shown.
665async function loadLog($: Engine, room: PeerRoom) {
666	const role = await get($, tab)
667	if (role === "chat") return
668	const { exitCode, stdout } = await peer($, ["memberlog", room.id, role])
669	if (exitCode !== 0) return
670	const entries: PeerLogEntry[] = []
671	for (const line of stdout.split("\n")) {
672		try {
673			if (line.trim()) entries.push(JSON.parse(line))
674		} catch {
675			// skip a line that is not JSON
676		}
677	}
678	await put($, log, () => entries.slice(-200))
679}
680
681function state(m: PeerMember) {
682	if (m.kicked) return "kicked"
683	if (m.exited) return "exited"
684	if (m.pid) return "running"
685
686	return m.role === "main" ? "leads" : "joined"
687}
688
689export const register: Register = (on) => {
690	on("session.start", async ($, e, next) => {
691		const started = await next(e)
692		await begin($)
693		return started
694	})
695
696	on("turn.start", async ($, e, next) => {
697		await begin($)
698		return next(e)
699	})
700
701	on("tool.call", { tool: "Bash" }, async ($, e, next) => {
702		await begin($)
703		const all = shellCommands(e.command)
704		const commands = all
705			.map(peerArgs)
706			.filter((args): args is string[] => args !== null)
707		const wait = commands.find((args) => args[0] === "wait" && asMain(args))
708		if (wait?.[1] && all.length > 1 && (await get($, led)).includes(wait[1])) {
709			// Answering the whole line would skip its other commands.
710			return { deny: `Run the other commands without peer wait. ${HANDOFF}` }
711		}
712		if (wait?.[1] && all.length === 1) {
713			const id = wait[1]
714			const room = (await get($, rooms))[id]
715			// led first: rooms comes from watch and can lag behind a start.
716			if ((await get($, led)).includes(id) || (room && !room.ended_at)) {
717				await lead($, id)
718				return {
719					result: {
720						stdout: JSON.stringify({ status: "timeout", note: HANDOFF }),
721						stderr: "",
722						interrupted: false,
723					},
724				}
725			}
726			return next(e)
727		}
728		const startedAt = new Date(Date.now() - 1000).toISOString()
729		const ran = await next(e)
730		if (ran.deny !== undefined || ran.isError) return ran
731		// Main ended the room itself, so it needs no note that it ended.
732		for (const args of commands) {
733			if (args[0] === "end" && args[1] && asMain(args))
734				await put($, notified, (list) => [...list, args[1] ?? ""])
735		}
736		// A room this session joins as a member is its own too: a peer join on
737		// a line of its own that printed the room with that role. On a longer
738		// line, another command can print the room after a failed join.
739		const stdout = (ran.result as { stdout?: string } | undefined)?.stdout ?? ""
740		const [cmd, id, role] = commands[0] ?? []
741		if (
742			all.length === 1 &&
743			cmd === "join" &&
744			id &&
745			role &&
746			joinedRoom(stdout, id, role)
747		) {
748			await put($, own, (list) => (list.includes(id) ? list : [...list, id]))
749		}
750		const start = commands.find((args) => args[0] === "start" && args[1])
751		if (!start) return ran
752		const room =
753			(await startedRoom(stdout)) ??
754			(await newRoom($, start[1] ?? "", startedAt))
755		if (!room) return ran
756		await put($, selected, () => room.id)
757		await lead($, room.id, true)
758		void openPane($)
759		return { ...ran, context: [HANDOFF] }
760	})
761
762	on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
763		isPaneFocused = e.props.isFocused
764		const st = await snapshot($)
765		const els = $.ui.resolve(e)
766		const { Box, Text, Button, Markdown, Code } = els
767		const room = current(st)
768		if (!room) {
769			return (
770				<Box flexDirection="column">
771					<Text dimColor>
772						No active peer room in this checkout. Start one with /peer:peer
773						TASK.
774					</Text>
775					{st.watchError && <Text color="red">{st.watchError}</Text>}
776					{Object.entries(st.ackErrors).map(([id, text]) => (
777						<Text key={`ack-${id}`} color="red">
778							{text}
779						</Text>
780					))}
781				</Box>
782			)
783		}
784		const active = activeRooms(st)
785		const isOpen = !room.ended_at
786		const all = st.messages[room.id] ?? []
787		// A kicked member keeps its tab, ○, for its log.
788		const others = room.members.filter((m) => m.role !== "main")
789		// A member's tab from another room shows this room's chat.
790		const isTab = st.tab === BOARDS || others.some((m) => m.role === st.tab)
791		const shown = isTab ? st.tab : "chat"
792		const pending = st.confirm
793
794		// armed draws a button that acts on its second press: the first turns
795		// it into confirm, and three seconds without the second turn it back.
796		const armed = (what: string, key: string, label: string, argv: string[]) =>
797			pending === what ? (
798				<Button
799					key={key}
800					label="confirm"
801					variant="primary"
802					onPress={async () => {
803						await put($, confirm, () => null)
804						const { exitCode, stderr } = await peer($, argv)
805						if (exitCode !== 0) $.ui.toast(`peer: ${stderr.trim()}`)
806					}}
807				/>
808			) : (
809				<Button
810					key={key}
811					label={label}
812					variant="secondary"
813					onPress={async () => {
814						await put($, confirm, () => what)
815						$.clock.after(
816							3000,
817							() => void put($, confirm, (now) => (now === what ? null : now)),
818						)
819					}}
820				/>
821			)
822
823		// One row: the chat, the boards, a tab per member and, at the right, the
824		// end button. A member's tab shows its log.
825		const pick = (one: string) => async () => {
826			await put($, tab, () => one)
827			if (one === "chat") toNewest($)
828			else if (one === BOARDS) await loadBoards($, room)
829			else await loadLog($, room)
830		}
831		// Every tab is a button of one size, so a switch moves nothing; the
832		// dot is part of the label, so it is pressed with the rest: ● runs, ○
833		// has stopped, as every member of an ended room has.
834		const dot = (isOn: boolean) => (isOn ? "●" : "○")
835		const boardList = boardViews[room.id]
836		const boardCount = boardList ? ` ${boardList.boards.length}` : ""
837		const header = (
838			<Box flexDirection="column" gap={1}>
839				{active.length > 1 && "Select" in els && (
840					<els.Select
841						key="room"
842						label="room"
843						value={room.id}
844						options={active.map((one) => ({ value: one.id }))}
845						onSelect={(value) => put($, selected, () => value)}
846					/>
847				)}
848				<Box gap={1} flexWrap="wrap">
849					<Button
850						key="tab-chat"
851						label={`${dot(isOpen)} chat ${all.length}`}
852						variant={shown === "chat" ? "primary" : "secondary"}
853						onPress={pick("chat")}
854					/>
855					<Button
856						key="tab-boards"
857						label={`boards${boardCount}`}
858						variant={shown === BOARDS ? "primary" : "secondary"}
859						onPress={pick(BOARDS)}
860					/>
861					{others.map((m) => (
862						<Button
863							key={`tab-${m.role}`}
864							label={`${dot(isOpen && !m.exited && !m.kicked)} ${m.role}`}
865							variant={shown === m.role ? "primary" : "secondary"}
866							onPress={pick(m.role)}
867						/>
868					))}
869					<Box flexGrow={1} />
870					{isOpen &&
871						armed("end", "end", "⏹ end", ["end", room.id, "--as", "user"])}
872				</Box>
873				{st.watchError && <Text color="red">{st.watchError}</Text>}
874				{Object.entries(st.ackErrors).map(([id, text]) => (
875					<Text key={`ack-${id}`} color="red">
876						{text}
877					</Text>
878				))}
879			</Box>
880		)
881
882		let body: JSX.Element
883		if (shown === "chat") {
884			// Newest first: the chat grows down from the tabs.
885			const list = all.slice(-60).reverse()
886			body = (
887				<Box flexDirection="column" gap={1}>
888					{e.surface !== "terminal" && "Svg" in els && list.length > 0 && (
889						<els.Svg
890							source={timeline(room, list)}
891							alt={`Message timeline of ${room.id}`}
892						/>
893					)}
894					{list.length === 0 && <Text dimColor>No messages yet.</Text>}
895					{groups(list).map((group) =>
896						group.notices ? (
897							// One line reads in time order, though the lines run newest first.
898							<Box key={`notice-${group.notices[0]?.id}`} gap={1}>
899								<Text dimColor>
900									{clock(group.notices[group.notices.length - 1]?.at ?? "")}
901								</Text>
902								<Text dimColor>
903									{clip(
904										[...group.notices]
905											.reverse()
906											.map((m) => m.text)
907											.join(" · "),
908										2000,
909									)}
910								</Text>
911							</Box>
912						) : (
913							message(group.message)
914						),
915					)}
916				</Box>
917			)
918		} else if (shown === BOARDS) {
919			body = (
920				<Box flexDirection="column" gap={1}>
921					{boardList?.error && <Text color="red">{boardList.error}</Text>}
922					{boardList?.boards.length === 0 && (
923						<Text dimColor>
924							No boards in this room yet. Agents make one with peer board
925							create.
926						</Text>
927					)}
928					{boardList?.boards.map((view) => boardCard(view))}
929					{(boardList?.boards.length ?? 0) > 0 && (
930						<Text dimColor>If a link does not open, start `peer`.</Text>
931					)}
932				</Box>
933			)
934		} else {
935			const member = room.members.find((m) => m.role === shown)
936			body = (
937				<Box flexDirection="column" gap={1}>
938					{member && (
939						<Box gap={1}>
940							<Text dimColor>
941								{[member.agent, member.model, member.effort]
942									.filter(Boolean)
943									.join(" / ") || "joined by hand"}{" "}
944								· {state(member)}
945								{member.worktree ? ` · worktree ${member.worktree}` : ""}
946								{member.branch ? ` · ${member.branch}` : ""}
947							</Text>
948							<Box flexGrow={1} />
949							{isOpen &&
950								!member.kicked &&
951								armed(`kick:${member.role}`, "kick", "kick", [
952									"kick",
953									room.id,
954									member.role,
955									"--as",
956									"user",
957								])}
958						</Box>
959					)}
960					{st.log.length === 0 && <Text dimColor>No log yet.</Text>}
961					{st.log
962						.slice(-80)
963						.reverse()
964						.map((entry, i) => (
965							// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no id; the list is redrawn whole.
966							<Box key={`log-${i}`} gap={1}>
967								<Text dimColor>
968									{entry.at && !entry.at.startsWith("0001")
969										? clock(entry.at, true)
970										: "        "}
971								</Text>
972								<Box flexGrow={1} flexShrink={1}>
973									{entry.kind === "tool" ? (
974										<Code source={clip(entry.text, 500)} language="bash" />
975									) : (
976										<Text
977											dimColor={entry.kind !== "text"}
978											wrap={entry.kind === "text" ? "wrap" : "truncate-end"}
979										>
980											{clip(
981												entry.kind === "text"
982													? entry.text
983													: entry.text.split("\n").slice(0, 3).join(" ⏎ "),
984												500,
985											)}
986										</Text>
987									)}
988								</Box>
989							</Box>
990						))}
991				</Box>
992			)
993		}
994
995		// boardCard shows a board's title and link, then a schematic where the
996		// surface draws Svg, else its frames as text.
997		function boardCard({ ref, board, error }: BoardView) {
998			const isDrawn = e.surface !== "terminal" && "Svg" in els
999			const svg = board && isDrawn ? boardSvg(board) : null
1000			const lines = board && !isDrawn ? frameLines(board) : []
1001			return (
1002				<Box key={`board-${ref.key}`} flexDirection="column">
1003					<Text bold>
1004						<els.Link
1005							href={ref.url}
1006							label={`${board?.title ?? ref.title ?? ref.path} ↗`}
1007						/>
1008					</Text>
1009					{board && <Text dimColor>{boardStats(board)}</Text>}
1010					{error && <Text color="red">{error}</Text>}
1011					{svg && "Svg" in els && (
1012						<els.Svg
1013							source={svg}
1014							alt={`Schematic of board ${ref.title ?? ""}`}
1015						/>
1016					)}
1017					{lines.map((line) => (
1018						<Text key={`${ref.key}-${line}`}>{line}</Text>
1019					))}
1020				</Box>
1021			)
1022		}
1023
1024		function message(m: PeerMessage) {
1025			const color = authorColor(room!, m.from)
1026			const lines = m.text.split("\n")
1027			const isLong = lines.length > 14 && !st.expanded.includes(m.id)
1028			const text = isLong ? excerpt(lines, 12) : m.text
1029			return (
1030				<Box
1031					key={`msg-${m.id}`}
1032					flexDirection="column"
1033					borderStyle="single"
1034					borderColor={borderOf(room!, m.from)}
1035					paddingX={1}
1036				>
1037					<Box gap={1}>
1038						<Text dimColor>{clock(m.at)}</Text>
1039						<Text bold color={color}>
1040							{m.from}
1041						</Text>
1042						<Text dimColor>→</Text>
1043						<Text
1044							color={m.to === EVERYONE ? undefined : authorColor(room!, m.to)}
1045						>
1046							{m.to === EVERYONE ? "all" : m.to}
1047						</Text>
1048					</Box>
1049					<Markdown key={`md-${m.id}`} text={clip(linkify(text), 8000)} />
1050					{lines.length > 14 && (
1051						<Button
1052							key={`more-${m.id}`}
1053							label={isLong ? `show all ${lines.length} lines` : "show less"}
1054							plain
1055							onPress={() =>
1056								put($, expanded, (list) =>
1057									isLong ? [...list, m.id] : list.filter((one) => one !== m.id),
1058								)
1059							}
1060						/>
1061					)}
1062				</Box>
1063			)
1064		}
1065
1066		return (
1067			<Box flexDirection="column" gap={1}>
1068				{header}
1069				{body}
1070			</Box>
1071		)
1072	})
1073
1074	// The prompt footer gets the peer icon, with the count of messages that
1075	// came since the pane last showed: one room opens the pane on it; with
1076	// several, a numbered button per room, in the pane's order. A press on the
1077	// room the shown pane holds closes it.
1078	on("ui.render", { component: "SessionMode" }, async ($, e, next) => {
1079		const st = await snapshot($)
1080		const active = activeRooms(st)
1081		if (active.length === 0) return next(e)
1082		const { Box, Text, Button } = $.ui.resolve(e)
1083		const show = (id: string) => async () => {
1084			const isShown = (await $.ui.panes()).some(
1085				(pane) => pane.id === PANE && pane.isShown,
1086			)
1087			if (isShown && current(await snapshot($))?.id === id)
1088				return $.ui.close({ id: PANE })
1089			await put($, selected, () => id)
1090			await openPane($)
1091		}
1092		const fresh = (room: PeerRoom) => {
1093			const n = (st.messages[room.id] ?? []).length - (st.seen[room.id] ?? 0)
1094			return n > 0 ? ` ${n}` : ""
1095		}
1096		return (
1097			<Box gap={1}>
1098				{e.props.modes.length > 0 && (
1099					<Text dimColor>{e.props.modes.join(" & ")}</Text>
1100				)}
1101				{active.length === 1 && active[0] && (
1102					<Button
1103						key="peer-open"
1104						label={`${ICON}${fresh(active[0])}`}
1105						plain
1106						onPress={show(active[0].id)}
1107					/>
1108				)}
1109				{active.length > 1 && <Text>{ICON}</Text>}
1110				{active.length > 1 &&
1111					active.map((room, i) => (
1112						<Button
1113							// biome-ignore lint/suspicious/noArrayIndexKey: the button is the numbered slot, not the room.
1114							key={`peer-open-${i + 1}`}
1115							label={`${i + 1}${fresh(room)}`}
1116							onPress={show(room.id)}
1117						/>
1118					))}
1119			</Box>
1120		)
1121	})
1122
1123	// The rows this plugin puts into the chat, a delivery or an end
1124	// note, are hidden there: the toast, the pane and the footer's count show
1125	// them. ctrl+o shows each as the model reads it. A surface without ctrl+o,
1126	// such as the desktop, marks every row expanded, so it always hides them.
1127	on("ui.render", { component: "UserMessage" }, ($, e, next) => {
1128		const hasCtrlO = e.surface === "terminal" || e.surface === "vscode"
1129		if ((hasCtrlO && e.props.isExpanded) || !isPeerRow(e.props.text))
1130			return next(e)
1131		const { Box } = $.ui.resolve(e)
1132		return <Box />
1133	})
1134}
1135
1136// startedRoom reads the room peer start printed, when the output reached
1137// the tool whole.
1138async function startedRoom(stdout: string) {
1139	try {
1140		const room = JSON.parse(stdout.trim().split("\n")[0] ?? "") as PeerRoom
1141		return room.id && room.members?.[0]?.role === "main" ? room : null
1142	} catch {
1143		return null
1144	}
1145}
1146
1147// joinedRoom reports whether stdout holds room id as peer join prints it,
1148// with role among its members.
1149function joinedRoom(stdout: string, id: string, role: string) {
1150	return stdout.split("\n").some((line) => {
1151		try {
1152			const room = JSON.parse(line) as PeerRoom
1153			return room.id === id && room.members?.some((m) => m.role === role)
1154		} catch {
1155			return false
1156		}
1157	})
1158}
1159
1160// newRoom finds the room a peer start NAME made when its output was cut or
1161// piped away: NAME, or NAME-2 and so on, started since the command ran.
1162async function newRoom($: Engine, name: string, since: string) {
1163	const { exitCode, stdout } = await peer($, ["status"])
1164	if (exitCode !== 0) return null
1165	const rooms: PeerRoom[] = []
1166	for (const line of stdout.split("\n")) {
1167		try {
1168			if (line.trim()) rooms.push(JSON.parse(line))
1169		} catch {
1170			// skip a line that is not JSON
1171		}
1172	}
1173	const named = new RegExp(`^${name.replace(/[^a-z0-9-]/g, "")}(-\\d+)?$`)
1174	return (
1175		rooms
1176			.filter((room) => named.test(room.id) && room.started_at >= since)
1177			.sort((a, b) => b.started_at.localeCompare(a.started_at))[0] ?? null
1178	)
1179}
1180
1181// shellCommands splits a shell command line into its commands, each a list
1182// of words with quotes removed and leading VAR=value words dropped; text
1183// inside quotes, as in echo "peer wait", is not a command. It reads simple
1184// command lines, not every shell form.
1185function shellCommands(line: string): string[][] {
1186	const commands: string[][] = []
1187	let words: string[] = []
1188	let word = ""
1189	let hasWord = false
1190	let quote = ""
1191	const endWord = () => {
1192		if (hasWord) words.push(word)
1193		word = ""
1194		hasWord = false
1195	}
1196	const endCommand = () => {
1197		endWord()
1198		const at = words.findIndex((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
1199		if (at >= 0) commands.push(words.slice(at))
1200		words = []
hooks/board-preview.ts 673 lines
1import type { PeerBoard, PeerBoardNode } from "../types"
2
3// SVG_LIMIT is the most characters the Svg element takes.
4const SVG_LIMIT = 131_072
5// The schematic is drawn 1:1 up to WIDTH px, then scaled down to it.
6const WIDTH = 720
7const PAD = 12
8const GAP = 16
9// LABEL_GAP parts two layers that a labeled edge joins, so the label sits
10// between them and not on a box.
11const LABEL_GAP = 30
12const FONT = 11
13const LINE = 14
14const HEADER = 22
15const CARD_W = 160
16const CARD_PAD = 8
17const MAX_LINES = 4
18// An average sans-serif glyph is about 0.6 em wide.
19const GLYPH = FONT * 0.6
20const CARD_FILL: Record<string, string> = {
21	gray: "#8883",
22	yellow: "#e5c07b66",
23	green: "#98c37966",
24	blue: "#4c8dff55",
25	purple: "#b48cff55",
26	red: "#e0606066",
27}
28
29type Box = { x: number; y: number; w: number; h: number }
30
31// label is a node's first line: a card's text, an image's caption or a
32// frame's title.
33function label(node: PeerBoardNode) {
34	const text = node.text ?? node.caption ?? node.title ?? ""
35	const first = text.split("\n")[0]?.trim() || node.type
36
37	return fit(first, 40)
38}
39
40// fit cuts text to n glyphs, ending a cut line with "…". Glyphs are code
41// points, so a cut never splits a surrogate pair.
42function fit(text: string, n: number) {
43	const glyphs = Array.from(text)
44
45	return glyphs.length > n
46		? `${glyphs.slice(0, Math.max(0, n - 1)).join("")}…`
47		: text
48}
49
50// wrap breaks a card's text into lines of at most n glyphs, by words, and
51// keeps MAX_LINES of them; a cut ends with "…".
52function wrap(text: string, n: number) {
53	const lines: string[][] = []
54	for (const paragraph of text.split("\n")) {
55		let line: string[] = []
56		for (const word of paragraph.split(/\s+/).filter(Boolean)) {
57			let rest = Array.from(word)
58			while (rest.length > 0) {
59				const room = line.length > 0 ? n - line.length - 1 : n
60				if (rest.length <= room) {
61					line = line.length > 0 ? [...line, " ", ...rest] : rest
62					rest = []
63				} else if (line.length > 0) {
64					lines.push(line)
65					line = []
66				} else {
67					lines.push(rest.slice(0, n))
68					rest = rest.slice(n)
69				}
70			}
71		}
72		lines.push(line)
73	}
74	while (lines.length > 1 && lines.at(-1)?.length === 0) lines.pop()
75	const kept = lines.slice(0, MAX_LINES)
76	if (lines.length > MAX_LINES) {
77		kept[MAX_LINES - 1] = [...(kept[MAX_LINES - 1] ?? []).slice(0, n - 1), "…"]
78	}
79
80	return kept.map((line) => line.join(""))
81}
82
83// ranks gives each item its layer: the longest path to it along the
84// links. A link that closes a cycle is skipped, so a cycle still ends.
85function ranks(ids: string[], links: [string, string][]) {
86	const out = new Map(ids.map((id) => [id, [] as string[]]))
87	for (const [from, to] of links) {
88		if (from !== to) out.get(from)?.push(to)
89	}
90	const state = new Map<string, "open" | "done">()
91	const kept = new Map(ids.map((id) => [id, [] as string[]]))
92	const order: string[] = []
93	const visit = (id: string) => {
94		state.set(id, "open")
95		for (const to of out.get(id) ?? []) {
96			if (state.get(to) === "open") continue
97			kept.get(id)?.push(to)
98			if (!state.has(to)) visit(to)
99		}
100		state.set(id, "done")
101		order.push(id)
102	}
103	for (const id of ids) if (!state.has(id)) visit(id)
104
105	const rank = new Map(ids.map((id) => [id, 0]))
106	for (const id of order.reverse()) {
107		for (const to of kept.get(id) ?? []) {
108			rank.set(to, Math.max(rank.get(to) ?? 0, (rank.get(id) ?? 0) + 1))
109		}
110	}
111
112	return rank
113}
114
115// schematic lays the board out without its stored geometry, so a board
116// nobody opened in the browser still has a picture: each frame holds its
117// children in layers along the edges between them, a layer wraps when it
118// is wider than the drawing, and the top frames go by order.
119function schematic(board: PeerBoard) {
120	const byId = new Map(board.nodes.map((node) => [node.id, node]))
121	const parentOf = (node: PeerBoardNode) =>
122		node.parentId && byId.get(node.parentId)?.type === "frame"
123			? node.parentId
124			: ""
125	const kids = new Map<string, PeerBoardNode[]>()
126	for (const node of board.nodes) {
127		const list = kids.get(parentOf(node)) ?? []
128		list.push(node)
129		kids.set(parentOf(node), list)
130	}
131	// lift gives the ancestor of id that is a direct child of container.
132	const lift = (id: string, container: string) => {
133		const seen = new Set<string>()
134		for (let node = byId.get(id); node && !seen.has(node.id); ) {
135			seen.add(node.id)
136			const parent = parentOf(node)
137			if (parent === container) return node.id
138			if (parent === "") return null
139			node = byId.get(parent)
140		}
141		return null
142	}
143	const open = openThreads(board)
144	const lines = new Map<string, string[]>()
145	const size = new Map<string, { w: number; h: number }>()
146	const rel = new Map<string, { x: number; y: number }>()
147	const arrange = (container: string, depth: number) => {
148		const own = (kids.get(container) ?? []).filter((node) => !size.has(node.id))
149		const items = [
150			...own
151				.filter((node) => node.type === "frame")
152				.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
153			...own.filter((node) => node.type !== "frame"),
154		]
155		for (const node of items) {
156			if (node.type === "frame") {
157				size.set(node.id, { w: 0, h: 0 })
158				const inner = arrange(node.id, depth + 1)
159				// A labeled loop on a card in the frame gets room on the right.
160				const loops = board.edges
161					.filter(
162						(edge) =>
163							edge.label &&
164							edge.from === edge.to &&
165							byId.get(edge.from)?.type !== "frame" &&
166							parentOf(byId.get(edge.from) as PeerBoardNode) === node.id,
167					)
168					.map(
169						(edge) =>
170							14 + Math.min(40, Array.from(edge.label ?? "").length) * 6,
171					)
172				size.set(node.id, {
173					w: Math.max(inner.w + 2 * PAD + Math.max(0, ...loops), CARD_W),
174					h: HEADER + inner.h + PAD,
175				})
176			} else {
177				const text =
178					node.type === "image" ? node.caption || "image" : (node.text ?? "")
179				const wrapped = wrap(text, Math.floor((CARD_W - 2 * CARD_PAD) / GLYPH))
180				lines.set(node.id, wrapped)
181				// A card with open threads keeps a row under its text for the badge.
182				const badge = open.has(node.id) ? LINE : 0
183				size.set(node.id, {
184					w: CARD_W,
185					h: wrapped.length * LINE + 2 * CARD_PAD + badge,
186				})
187			}
188		}
189		const ids = items.map((node) => node.id)
190		const links: [string, string][] = []
191		const labeled: [string, string][] = []
192		for (const edge of board.edges) {
193			const from = lift(edge.from, container)
194			const to = lift(edge.to, container)
195			if (from && to) links.push([from, to])
196			if (from && to && edge.label) labeled.push([from, to])
197		}
198		const rank = ranks(ids, links)
199		const roomy = new Set(
200			labeled
201				.filter(([a, b]) => rank.get(a) !== rank.get(b))
202				.map(([a, b]) => Math.min(rank.get(a) ?? 0, rank.get(b) ?? 0)),
203		)
204		const layers: string[][] = []
205		for (const id of ids) {
206			const r = rank.get(id) ?? 0
207			layers[r] = [...(layers[r] ?? []), id]
208		}
209		const row = Math.max(CARD_W, WIDTH - 2 * PAD * (depth + 1))
210		// A labeled edge between a frame and its own content gets a row under
211		// the title bar for its label.
212		const extra =
213			container &&
214			board.edges.some(
215				(edge) =>
216					edge.label &&
217					((edge.from === container && lift(edge.to, container)) ||
218						(edge.to === container && lift(edge.from, container))),
219			)
220				? 14
221				: 0
222		const left = container ? PAD : 0
223		const top = container ? HEADER + extra : 0
224		let y = 0
225		let w = 0
226		for (const [r, layer] of layers.entries()) {
227			if (!layer) continue
228			let x = 0
229			let tall = 0
230			for (const id of layer) {
231				const s = size.get(id) ?? { w: 0, h: 0 }
232				if (x > 0 && x + s.w > row) {
233					y += tall + GAP
234					x = 0
235					tall = 0
236				}
237				rel.set(id, { x: left + x, y: top + y })
238				x += s.w + GAP
239				tall = Math.max(tall, s.h)
240				w = Math.max(w, x - GAP)
241			}
242			y += tall + (roomy.has(r) ? LABEL_GAP : GAP)
243		}
244		y -= roomy.has(layers.length - 1) ? LABEL_GAP : GAP
245
246		return { w, h: Math.max(0, y) + extra }
247	}
248	const whole = arrange("", 0)
249	const boxes = new Map<string, Box>()
250	const place = (node: PeerBoardNode): Box | null => {
251		const known = boxes.get(node.id)
252		if (known) return known
253		const at = rel.get(node.id)
254		const s = size.get(node.id)
255		if (!at || !s) return null
256		const parent = parentOf(node)
257		const origin = parent ? place(byId.get(parent) as PeerBoardNode) : null
258		const box = {
259			x: (origin?.x ?? 0) + at.x,
260			y: (origin?.y ?? 0) + at.y,
261			w: s.w,
262			h: s.h,
263		}
264		boxes.set(node.id, box)
265
266		return box
267	}
268	for (const node of board.nodes) place(node)
269
270	return { boxes, lines, whole, open }
271}
272
273type Point = { x: number; y: number }
274
275// openThreads counts the open threads on each frame, node and edge by its
276// id; a thread counts on its own target only.
277function openThreads(board: PeerBoard) {
278	const open = new Map<string, number>()
279	for (const thread of board.threads) {
280		const id = thread.target.id
281		if (!thread.resolved && id) open.set(id, (open.get(id) ?? 0) + 1)
282	}
283
284	return open
285}
286
287const center = (b: Box): Point => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 })
288
289// reach is the step along d from at to the side of [lo, hi] it heads to.
290function reach(d: number, lo: number, hi: number, at: number) {
291	if (d > 0) return (hi - at) / d
292	if (d < 0) return (lo - at) / d
293
294	return Number.POSITIVE_INFINITY
295}
296
297// exit is where the line from o, a point in box b, toward p leaves b.
298function exit(b: Box, o: Point, p: Point) {
299	const dx = p.x - o.x
300	const dy = p.y - o.y
301	const t = Math.min(
302		reach(dx, b.x, b.x + b.w, o.x),
303		reach(dy, b.y, b.y + b.h, o.y),
304		1,
305	)
306
307	return { x: o.x + dx * t, y: o.y + dy * t }
308}
309
310// crosses tells whether the segment from p to q passes through the inside
311// of box b (Liang–Barsky clipping on b shrunk by one unit).
312function crosses(p: Point, q: Point, b: Box) {
313	let lo = 0
314	let hi = 1
315	const dx = q.x - p.x
316	const dy = q.y - p.y
317	const sides: [number, number][] = [
318		[-dx, p.x - (b.x + 1)],
319		[dx, b.x + b.w - 1 - p.x],
320		[-dy, p.y - (b.y + 1)],
321		[dy, b.y + b.h - 1 - p.y],
322	]
323	for (const [n, d] of sides) {
324		if (n === 0) {
325			if (d < 0) return false
326		} else if (n < 0) {
327			lo = Math.max(lo, d / n)
328		} else {
329			hi = Math.min(hi, d / n)
330		}
331	}
332
333	return lo < hi
334}
335
336const round = (n: number) => Math.round(n * 10) / 10
337
338// overlaps tells whether boxes a and b share some area.
339const overlaps = (a: Box, b: Box) =>
340	a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
341
342// inside tells whether box a lies within box b.
343const inside = (a: Box, b: Box) =>
344	a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h
345
346// boardSvg draws the board's schematic: frames as titled boxes, cards and
347// images with their wrapped text clipped to the box, and edges as arrows
348// between the boxes' borders. Edges between one pair of boxes are drawn
349// side by side; an edge that would cross another card goes around it on
350// the right; a loop is an arc on its box. Each box and edge has a <title>
351// with its full text for a reader of the markup, and an open thread a
352// "💬 N" badge. The stored
353// positions are not used. A drawing over the Svg limit keeps the frames
354// only; null for an empty board or when even the frames do not fit.
355export function boardSvg(board: PeerBoard): string | null {
356	if (board.nodes.length === 0) return null
357
358	const { boxes, lines, whole, open } = schematic(board)
359	const badge = (id: string) => (open.has(id) ? `💬 ${open.get(id)}` : "")
360	const frame = (node: PeerBoardNode, i: number) => {
361		const b = boxes.get(node.id) as Box
362		const mark = badge(node.id)
363		// The badge has a fixed slot at the right of the title bar; the title
364		// is clipped before it.
365		const slot = b.x + b.w - PAD
366		const room = mark ? slot - 40 - (b.x + PAD) : b.w - 2 * PAD
367		const title = fit(label(node), Math.floor(room / GLYPH))
368		const count = mark
369			? `<text x="${slot}" y="${b.y + 15}" text-anchor="end" font-size="10" fill="#888">${mark}</text>`
370			: ""
371
372		// A title of wide glyphs is clipped to the frame like a card's text.
373		return `<g><title>${esc(node.title ?? "")}</title><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="6" fill="#8881" stroke="#888"/><clipPath id="f${i}"><rect x="${b.x}" y="${b.y}" width="${b.x + PAD + room - b.x}" height="${HEADER}"/></clipPath><text x="${b.x + PAD}" y="${b.y + 15}" clip-path="url(#f${i})" font-weight="600" fill="#888">${esc(title)}</text>${count}</g>`
374	}
375	const card = (node: PeerBoardNode, i: number) => {
376		const b = boxes.get(node.id) as Box
377		const fill = CARD_FILL[node.color ?? "gray"] ?? "#8883"
378		const dash = node.type === "image" ? ' stroke-dasharray="4 3"' : ""
379		const text = (lines.get(node.id) ?? [])
380			.map(
381				(line, n) =>
382					`<tspan x="${b.x + CARD_PAD}" y="${b.y + CARD_PAD + 11 + n * LINE}">${esc(line)}</tspan>`,
383			)
384			.join("")
385		const full = node.type === "image" ? node.caption : node.text
386		const mark = badge(node.id)
387		const count = mark
388			? `<text x="${b.x + b.w - CARD_PAD}" y="${b.y + b.h - CARD_PAD}" text-anchor="end" font-size="10" fill="#888">${mark}</text>`
389			: ""
390
391		return `<g><title>${esc(full || node.type)}</title><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="6" fill="${fill}" stroke="#888"${dash}/><clipPath id="c${i}"><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}"/></clipPath><text clip-path="url(#c${i})" fill="#888">${text}</text>${count}</g>`
392	}
393	const shown = board.nodes.filter((node) => boxes.has(node.id))
394	const cardBoxes = shown
395		.filter((node) => node.type !== "frame")
396		.map((node) => [node.id, boxes.get(node.id) as Box] as const)
397	const frameBoxes = shown
398		.filter((node) => node.type === "frame")
399		.map((node) => boxes.get(node.id) as Box)
400	// Edges of one unordered pair are spread across the line between the
401	// pair's centers, measured in the pair's own direction, so an edge and
402	// its reverse do not draw on one line.
403	const pairs = new Map<string, string[]>()
404	for (const edge of board.edges) {
405		const key = [edge.from, edge.to].sort().join(" ")
406		pairs.set(key, [...(pairs.get(key) ?? []), edge.id])
407	}
408	let right = whole.w
409	const tag = (x: number, y: number, text: string, anchor: string, n = 24) =>
410		`<text x="${round(x)}" y="${round(y)}" text-anchor="${anchor}" font-size="10" fill="#888">${esc(fit(text, n))}</text>`
411	type Spot = { x: number; y: number; anchor: "start" | "end" | "middle" }
412	// put draws a label at the first of the spots where it touches no card,
413	// no frame's title bar and no frame's border, cut to fit inside the
414	// frame around the spot; a label with no free spot, or room for less
415	// than six glyphs, is left out, and its edge's <title> keeps it. A
416	// label with a home box stays inside it.
417	const put = (spots: Spot[], text: string, home?: Box) => {
418		for (const spot of spots) {
419			const around = frameBoxes
420				.filter((f) => inside({ ...spot, w: 0, h: 0 }, f))
421				.sort((f, g) => f.w * f.h - g.w * g.h)[0]
422			const lo = around ? around.x + 4 : Number.NEGATIVE_INFINITY
423			const hi = around ? around.x + around.w - 4 : Number.POSITIVE_INFINITY
424			const room = {
425				start: hi - spot.x,
426				end: spot.x - lo,
427				middle: 2 * Math.min(hi - spot.x, spot.x - lo),
428			}[spot.anchor]
429			const cut = fit(text, Math.min(40, Math.floor(room / 6)))
430			const glyphs = Array.from(cut).length
431			if (glyphs < Math.min(6, Array.from(text).length)) continue
432			const w = glyphs * 6
433			const shift = { start: 0, end: w, middle: w / 2 }[spot.anchor]
434			const r = { x: spot.x - shift, y: spot.y - 9, w, h: 11 }
435			const free =
436				(!home || inside(r, home)) &&
437				cardBoxes.every(([, box]) => !overlaps(r, box)) &&
438				frameBoxes.every((f) =>
439					inside(r, f)
440						? !overlaps(r, { x: f.x, y: f.y, w: f.w, h: HEADER })
441						: !overlaps(r, f),
442				)
443			if (free) {
444				right = Math.max(right, r.x + w)
445				return tag(spot.x, spot.y, cut, spot.anchor, 40)
446			}
447		}
448
449		return ""
450	}
451	const byId = new Map(board.edges.map((edge) => [edge.id, edge]))
452	// heading is an arrow for the way an edge runs on the drawing.
453	const heading = (from: Box, to: Box) => {
454		if (inside(to, from)) return "↓"
455		if (inside(from, to)) return "↑"
456		const dx = center(to).x - center(from).x
457		const dy = center(to).y - center(from).y
458		if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "→" : "←"
459
460		return dy > 0 ? "↓" : "↑"
461	}
462	// groupLabel is one label for all the edges of a pair, each with the
463	// arrow of its way when there are several, so no two labels overlap.
464	const groupLabel = (group: string[]) => {
465		const labeled = group
466			.map((id) => byId.get(id))
467			.filter((edge) => edge?.label)
468		if (labeled.length === 0) return ""
469		if (group.length === 1) return labeled[0]?.label ?? ""
470
471		return labeled
472			.map(
473				(edge) =>
474					`${heading(boxes.get(edge?.from ?? "") as Box, boxes.get(edge?.to ?? "") as Box)} ${edge?.label}`,
475			)
476			.join(" · ")
477	}
478	const arrow = (points: Point[]) =>
479		points.length === 2
480			? `<line x1="${round(points[0]?.x ?? 0)}" y1="${round(points[0]?.y ?? 0)}" x2="${round(points[1]?.x ?? 0)}" y2="${round(points[1]?.y ?? 0)}" stroke="#888" marker-end="url(#arrow)"/>`
481			: `<polyline points="${points.map((p) => `${round(p.x)},${round(p.y)}`).join(" ")}" fill="none" stroke="#888" marker-end="url(#arrow)"/>`
482	const edges = board.edges.map((edge) => {
483		const a = boxes.get(edge.from)
484		const b = boxes.get(edge.to)
485		if (!a || !b) return ""
486		const title = edge.label ? `<title>${esc(edge.label)}</title>` : ""
487		if (edge.from === edge.to) {
488			// A loop is a small arc low on the box's right side, inside the gap
489			// to the next box or the frame's border; a detour enters higher.
490			const x = a.x + a.w
491			const y = a.y + a.h - 12
492			const loop = `<path d="M${x},${y - 7} C${x + 13},${y - 9} ${x + 13},${y + 9} ${x},${y + 7}" fill="none" stroke="#888" marker-end="url(#arrow)"/>`
493			const home = frameBoxes
494				.filter((f) => f !== a && inside(a, f))
495				.sort((f, g) => f.w * f.h - g.w * g.h)[0]
496			const text = edge.label
497				? put([{ x: x + 14, y: y + 3, anchor: "start" }], edge.label, home)
498				: ""
499
500			return `<g>${title}${loop}${text}</g>`
501		}
502		const [first, second] = [edge.from, edge.to].sort()
503		const group = pairs.get([first, second].join(" ")) ?? []
504		const index = group.indexOf(edge.id)
505		// Edges of a pair are spread 6 units apart, inside the smaller box,
506		// so many edges of one pair still start and end on their boxes.
507		const limit = Math.max(0, Math.min(a.w, a.h, b.w, b.h) / 2 - 4)
508		const spread = (k: number) =>
509			Math.max(-limit, Math.min(limit, (k - (group.length - 1) / 2) * 6))
510		const shift = spread(index)
511		const half = spread(group.length - 1)
512		// Only the pair's first edge draws the pair's label.
513		const label = index === 0 ? groupLabel(group) : ""
514		// A frame and a node inside it: the arrow runs between the frame's
515		// title bar and the node's top.
516		if (inside(b, a) || inside(a, b)) {
517			const inner = inside(b, a) ? b : a
518			const outer = inner === b ? a : b
519			const x = inner.x + inner.w / 2 + shift
520			const bar = { x, y: outer.y + HEADER - 2 }
521			const top = { x, y: inner.y }
522			const points = inner === b ? [bar, top] : [top, bar]
523			const y = (bar.y + top.y) / 2 + 3
524			const middle = inner.x + inner.w / 2
525			const text = label
526				? put(
527						[
528							{ x: middle + half + 4, y, anchor: "start" },
529							{ x: middle - half - 4, y, anchor: "end" },
530						],
531						label,
532					)
533				: ""
534
535			return `<g>${title}${arrow(points)}${text}</g>`
536		}
537		const p = center(boxes.get(first ?? "") as Box)
538		const q = center(boxes.get(second ?? "") as Box)
539		const length = Math.hypot(q.x - p.x, q.y - p.y) || 1
540		const normal = { x: -(q.y - p.y) / length, y: (q.x - p.x) / length }
541		const ca = {
542			x: center(a).x + normal.x * shift,
543			y: center(a).y + normal.y * shift,
544		}
545		const cb = {
546			x: center(b).x + normal.x * shift,
547			y: center(b).y + normal.y * shift,
548		}
549		const from = exit(a, ca, cb)
550		const to = exit(b, cb, ca)
551		const others = cardBoxes.filter(
552			([id]) => id !== edge.from && id !== edge.to,
553		)
554		const blocked = others.filter(([, box]) => crosses(from, to, box))
555		if (blocked.length > 0) {
556			// Around on the right: out of a's right side, down a lane right of
557			// everything in the way and of the frames around it, into b's
558			// right side. Each edge of the pair has its own lane and rows.
559			const near = [a, b, ...blocked.map(([, box]) => box)]
560			const lane =
561				Math.max(
562					...near.map((box) => box.x + box.w),
563					...frameBoxes
564						.filter((f) => near.some((box) => inside(box, f)))
565						.map((f) => f.x + f.w),
566				) +
567				10 +
568				index * 6
569			// It leaves and enters near the boxes' tops, clear of a loop low
570			// on their right side.
571			const ya = a.y + 5 + Math.min(index * 6, a.h / 2 - 8)
572			const yb = b.y + 5 + Math.min(index * 6, b.h / 2 - 8)
573			const points = [
574				{ x: a.x + a.w, y: ya },
575				{ x: lane, y: ya },
576				{ x: lane, y: yb },
577				{ x: b.x + b.w, y: yb },
578			]
579			const clear = points
580				.slice(1)
581				.every((pt, n) =>
582					others.every(([, box]) => !crosses(points[n] as Point, pt, box)),
583				)
584			if (clear) {
585				right = Math.max(right, lane + 4)
586				const at = lane + (group.length - 1 - index) * 6 + 4
587				const y = (center(a).y + center(b).y) / 2
588				const text = label ? put([{ x: at, y, anchor: "start" }], label) : ""
589
590				return `<g>${title}${arrow(points)}${text}</g>`
591			}
592		}
593		// The pair's label sits beside the bundle of its lines: right of a
594		// steep one, over a flat one.
595		const base = {
596			x:
597				(exit(a, center(a), center(b)).x + exit(b, center(b), center(a)).x) / 2,
598			y:
599				(exit(a, center(a), center(b)).y + exit(b, center(b), center(a)).y) / 2,
600		}
601		const steep = Math.abs(q.y - p.y) >= Math.abs(q.x - p.x)
602		const spots: Spot[] = steep
603			? [
604					{ x: base.x + half + 4, y: base.y + 3, anchor: "start" },
605					{ x: base.x - half - 4, y: base.y + 3, anchor: "end" },
606				]
607			: [
608					{ x: base.x, y: base.y - half - 4, anchor: "middle" },
609					{ x: base.x, y: base.y + half + 12, anchor: "middle" },
610				]
611		const text = label ? put(spots, label) : ""
612
613		return `<g>${title}${arrow([from, to])}${text}</g>`
614	})
615	const width = right + 2 * PAD
616	const height = whole.h + 2 * PAD
617	const shownWidth = Math.min(WIDTH, width)
618	const shownHeight = Math.max(40, Math.round((shownWidth * height) / width))
619	const svg = (body: string) =>
620		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-PAD} ${-PAD} ${width} ${height}" width="${shownWidth}" height="${shownHeight}" font-family="sans-serif" font-size="${FONT}"><defs><marker id="arrow" viewBox="0 0 8 8" refX="8" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0L8,4L0,8z" fill="#888"/></marker></defs>${body}</svg>`
621	const frames = shown.filter((node) => node.type === "frame").map(frame)
622	const cards = shown.filter((node) => node.type !== "frame").map(card)
623	const full = svg([...frames, ...edges, ...cards].join(""))
624	if (full.length <= SVG_LIMIT) return full
625
626	const framesOnly = svg(frames.join(""))
627
628	return framesOnly.length <= SVG_LIMIT ? framesOnly : null
629}
630
631// boardStats counts a board's frames, its cards and images, and its open
632// threads, for the line under the board's title.
633export function boardStats(board: PeerBoard) {
634	const frames = board.nodes.filter((node) => node.type === "frame").length
635	const nodes = board.nodes.length - frames
636	const threads = board.threads.filter((thread) => !thread.resolved).length
637
638	return `${plural(frames, "frame")} · ${plural(nodes, "node")} · ${plural(threads, "open thread")}`
639}
640
641// frameLines lists the frames by order, each with its count of direct
642// child nodes and of open threads on the frame and those nodes, then the
643// nodes outside any frame.
644export function frameLines(board: PeerBoard) {
645	const open = board.threads.filter((thread) => !thread.resolved)
646	const frames = board.nodes
647		.filter((node) => node.type === "frame")
648		.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
649	const lines = frames.map((frame) => {
650		const children = board.nodes.filter((node) => node.parentId === frame.id)
651		const ids = new Set([frame.id, ...children.map((node) => node.id)])
652		const threads = open.filter(
653			(thread) => thread.target.id && ids.has(thread.target.id),
654		).length
655
656		return `${label(frame)} · ${plural(children.length, "node")} · ${plural(threads, "open thread")}`
657	})
658	const loose = board.nodes.filter(
659		(node) => node.type !== "frame" && !node.parentId,
660	).length
661	if (loose > 0) lines.push(`${plural(loose, "node")} outside frames`)
662
663	return lines
664}
665
666function plural(n: number, noun: string) {
667	return `${n} ${noun}${n === 1 ? "" : "s"}`
668}
669
670function esc(text: string) {
671	return text.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`)
672}
673
types/index.d.ts 103 lines
1export type PeerMember = {
2	role: string
3	agent?: string
4	exited?: boolean
5	worker?: boolean
6	model?: string
7	effort?: string
8	worktree?: string
9	branch?: string
10	base?: string
11	kicked?: boolean
12	pid?: number
13	unread?: number
14}
15
16export type PeerRoom = {
17	id: string
18	repo: string
19	members: PeerMember[]
20	started_at: string
21	ended_at?: string
22	ended_reason?: string
23}
24
25export type PeerMessage = {
26	id: string
27	at: string
28	from: string
29	to: string
30	text: string
31}
32
33export type PeerLogEntry = { at?: string; kind: string; text: string }
34
35export type PeerMark = { id: string; at: string }
36
37// PeerBoardRef is one entry of `peer board list`; see docs/BOARD_API.md.
38export type PeerBoardRef = {
39	key: string
40	path: string
41	url: string
42	title?: string
43	revision?: number
44	rooms?: string[]
45	mtime?: string
46	error?: { error: string; message?: string }
47}
48
49// PeerBoard is the part of board.json the preview draws.
50export type PeerBoard = {
51	title: string
52	nodes: PeerBoardNode[]
53	edges: { id: string; from: string; to: string; label?: string }[]
54	threads: {
55		target: { kind: string; id?: string }
56		resolved: boolean
57	}[]
58}
59
60export type PeerBoardNode = {
61	id: string
62	type: "card" | "image" | "frame"
63	parentId?: string
64	text?: string
65	color?: string
66	caption?: string
67	title?: string
68	order?: number
69	x?: number
70	y?: number
71	w?: number
72	h?: number
73}
74
75export type PeerState = {
76	rooms: Record<string, PeerRoom>
77	messages: Record<string, PeerMessage[]>
78	selected: string | null
79	// 'chat', ':boards', or the role whose log the pane shows.
80	tab: string
81	confirm: string | null
82	led: string[]
83	// own lists the rooms of this session, the only ones the plugin shows:
84	// started or led here, or joined from here; a room stays once it ends.
85	own: string[]
86	watchError: string | null
87	// ackErrors holds, per led room, why main's cursor could not move.
88	ackErrors: Record<string, string>
89	log: PeerLogEntry[]
90	delivered: Record<string, PeerMark | null>
91	notified: string[]
92	// Pane: messages shown whole, and messages per room when the pane was
93	// last on screen.
94	expanded: string[]
95	seen: Record<string, number>
96}
97
98declare module "claude-code" {
99	interface PluginState {
100		peer: { state: PeerState }
101	}
102}
103