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

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.⏹ 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.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.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.peer wait; the plugin answers a wait for the room itself, so the room has only one reader as main.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.
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.
claude plugin validate plugin
claude plugin test plugin
claude --plugin-dir plugin
Claude Code updates can break the plugin. Two checks catch it:
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.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 }] }] }hooks/register.tsx 1391 lines1import 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 lines1import 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}
673types/index.d.ts 103 lines1export 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