A live pane of the Guildhall during a /quest: who is working, what they are up to in character, and who answers to whom.

A Claude Code mod that draws the Guildhall tavern as a live pane while a /quest runs: Mordain at the top, every adventurer he summons grouped by phase (Build chain, Review fan-out, Closer, Field work, Hirelings), each with an icon, a spinner while at work, the model the spawn resolved to, elapsed time, a deed count, and one in-character line for what they are doing right now ("Bruga forges rate_limit.ts", "Oriana searches for unwatched gates: password"). A short chronicle under the tree records summons and returns.
It is a spectator. It reads agent.spawn, tool.call, turn.complete and command.run, never changes them, and every hook falls through to the engine if its own bookkeeping fails. It does not touch the guildhall plugin, its agents or /quest.
In a Claude Code session:
/plugin install guildhall-tavern --marketplace GrillerGeek/guildhall
Answer y to add the marketplace, then pick a scope. Or from a terminal, in the project where Guildhall is installed:
claude plugin marketplace add https://github.com/GrillerGeek/guildhall.git --scope project
claude plugin install guildhall-tavern@guildhall-local --scope project
It is a function-hook mod, written and tested against Claude Code 2.1.295. For local development, load the folder directly:
claude --plugin-dir <repo>/plugin --plugin-dir <repo>/mods/guildhall-tavern
The pane opens itself when a /quest starts, or when a guild agent is dispatched outside one. /guildhall-tavern opens it any time.
The pane needs a surface that draws mod UI: the Claude Code terminal (a pane you did not ask for waits for 144 columns; asked for, it opens at any width) or a local session in the desktop app. A cloud session viewed from the Claude app attaches no such surface, so there /guildhall-tavern prints the tavern as text instead; run it again for a fresh look.
hooks/roster.ts: one entry per agent in plugin/agents/, voiced from plugin/CHARACTERS.md. A new adventurer needs a row here or it shows as a hireling; tests/test_tavern_roster.py fails CI until it has one, and when a roster name drifts from its character sheet.hooks/register.tsx: the hooks and the pane.types/index.d.ts: the session state the pane draws from.claude plugin validate mods/guildhall-tavern
claude plugin test mods/guildhall-tavernhooks/register.tsx 340 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ChronicleEntry, GuildMember, GuildQuest } from '../types'
5import { KEEPER, PHASES, characterOf, classify, flavor, guildRole, shorten } from './roster'
6import type { Character } from './roster'
7
8const PANE = 'guildhall'
9const TITLE = 'The Tavern'
10const SPINNER = ['◐', '◓', '◑', '◒']
11const CHRONICLE_LENGTH = 8
12const MEMBER_LIMIT = 40
13
14const quest = atom({ plugin: 'guildhall-tavern', key: 'quest' } as const, null as GuildQuest | null)
15const members = atom({ plugin: 'guildhall-tavern', key: 'members' } as const, [] as GuildMember[])
16const chronicle = atom({ plugin: 'guildhall-tavern', key: 'chronicle' } as const, [] as ChronicleEntry[])
17const tick = atom({ plugin: 'guildhall-tavern', key: 'tick' } as const, 0)
18
19// Which surfaces have asked this module to draw the pane since it loaded: diagnostics only.
20const drawnOn = new Set<string>()
21
22async function whereText($: EngineInterface): Promise<string> {
23 const surfaces = await $.session.surfaces()
24 const pane = (await $.ui.panes()).find(p => p.id === PANE)
25 const state = pane ? `placed ${pane.isPlaced}, shown ${pane.isShown}` : 'not open'
26 const drawn = drawnOn.size > 0 ? [...drawnOn].join(', ') : 'none yet'
27 return `Surfaces attached: ${surfaces.join(', ') || 'none'} · pane: ${state} · drawn on: ${drawn}`
28}
29
30const isQuestCommand = (command: string) => command === 'quest' || command === 'guildhall:quest'
31
32const elapsed = (ms: number) => {
33 const s = Math.max(0, Math.floor(ms / 1000))
34 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
35}
36
37const memberCharacter = (m: GuildMember): Character => characterOf(m.role)
38
39async function record($: EngineInterface, icon: string, text: string) {
40 await update($, chronicle, list => [...(list ?? []), { at: Date.now(), icon, text }].slice(-CHRONICLE_LENGTH))
41}
42
43async function beginQuest($: EngineInterface, title: string) {
44 await update($, quest, () => ({
45 title,
46 startedAt: Date.now(),
47 keeperLine: `${KEEPER.name} reads the whole scroll before speaking`,
48 isKeeperBusy: true,
49 }))
50 await update($, members, () => [])
51 await update($, chronicle, () => [])
52 await record($, KEEPER.icon, `${KEEPER.name}: “${KEEPER.catchphrase}”`)
53 // A surface that cannot seat the pane leaves the ledger kept; /guildhall-tavern opens it later.
54 await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
55 await refreshStatus($)
56}
57
58/**
59 * A guild agent dispatched outside /quest still gets a hall to work in. Created only
60 * when none exists, in one write, so agents dispatched together share one quest.
61 */
62async function ensureQuest($: EngineInterface, title: string) {
63 await update($, quest, current =>
64 current ?? {
65 title,
66 startedAt: Date.now(),
67 keeperLine: `${KEEPER.name} sends adventurers out on an errand`,
68 isKeeperBusy: true,
69 },
70 )
71 await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
72}
73
74async function setKeeper($: EngineInterface, line: string, isKeeperBusy = true) {
75 await update($, quest, q => (q ? { ...q, keeperLine: line, isKeeperBusy } : q))
76 await refreshStatus($)
77}
78
79/** The tavern as plain text: the command's answer where no surface draws the pane. */
80function tavernText(q: GuildQuest | null, list: GuildMember[], log: ChronicleEntry[], now: number): string {
81 if (q === null) return `${KEEPER.icon} The tavern is quiet. ${KEEPER.name} waits by the hearth.`
82 const mark = (m: GuildMember) => (m.status === 'working' ? '▶' : m.status === 'done' ? '✓' : '✗')
83 const lines = [
84 `⚔ ${q.title} (${elapsed(now - q.startedAt)} on the road)`,
85 `${q.isKeeperBusy ? '▶' : '·'} ${KEEPER.icon} ${KEEPER.name} ${KEEPER.title}: ${q.keeperLine}`,
86 ]
87 for (const p of PHASES) {
88 const group = list.filter(m => memberCharacter(m).phase === p.phase)
89 if (group.length === 0) continue
90 lines.push(` ${p.label}`)
91 for (const m of group) {
92 const who = memberCharacter(m)
93 lines.push(` ${mark(m)} ${who.icon} ${who.name} ${who.title} (${m.role}, ${elapsed((m.endedAt ?? now) - m.startedAt)}, ${m.deeds} deeds): ${m.line}`)
94 }
95 }
96 if (log.length > 0) lines.push(' Chronicle', ...log.map(entry => ` ${entry.icon} ${entry.text}`))
97 return lines.join('\n')
98}
99
100/** One status-line entry that follows the quest on every surface, pane or no pane. */
101async function refreshStatus($: EngineInterface) {
102 const q = await read($, quest)
103 if (q === null) return $.ui.status(undefined)
104 const list = (await read($, members)) ?? []
105 const working = list.filter(m => m.status === 'working')
106 if (working.length > 0) {
107 return $.ui.status(`⚔ ${working.map(m => `${memberCharacter(m).icon} ${m.line}`).join(' · ')}`)
108 }
109 $.ui.status(`⚔ ${KEEPER.icon} ${q.keeperLine}${list.length > 0 ? ` · ${list.length} summoned, all returned` : ''}`)
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 await $.command.register({
115 name: 'guildhall-tavern',
116 description: 'Open the Guildhall tavern: who is on the quest and what they are doing',
117 })
118 // Spinners and running clocks advance only while someone is at work.
119 $.clock.every(500, () => {
120 void (async () => {
121 const list = (await read($, members)) ?? []
122 const q = await read($, quest)
123 if (list.some(m => m.status === 'working') || q?.isKeeperBusy) {
124 await update($, tick, n => (n ?? 0) + 1)
125 }
126 })()
127 })
128 return next(e)
129 })
130
131 on('command.run', { command: 'guildhall-tavern' }, async $ => {
132 const opened = await $.ui.open({ id: PANE, title: TITLE })
133 // With no surface attached (a cloud session viewed from an app) every pane reads
134 // as placed, yet nothing draws it: only this command's text reaches the person.
135 const surfaces = await $.session.surfaces()
136 if (opened.isPlaced && surfaces.length > 0) {
137 return { text: `The tavern door swings open. (${await whereText($)})` }
138 }
139 const why = opened.isPlaced ? 'no surface that draws panes is attached to this session' : opened.reason
140 const snapshot = tavernText(
141 await read($, quest),
142 (await read($, members)) ?? [],
143 (await read($, chronicle)) ?? [],
144 Date.now(),
145 )
146 return { text: `The pane cannot be drawn here (${why}). Run /guildhall-tavern again for a fresh look.\n\n${snapshot}` }
147 })
148
149 // A /quest begins a fresh ledger. The hook only watches; the command runs as typed.
150 on('command.run', async ($, e, next) => {
151 if (isQuestCommand(e.command)) {
152 await beginQuest($, shorten(e.args ?? '', 80) || 'An unnamed quest')
153 }
154 return next(e)
155 }).catch(($, e, next) => next(e))
156
157 on('agent.spawn', async ($, e, next) => {
158 const role = guildRole(e.subagentType)
159 if (role === undefined && (await read($, quest)) === null) return next(e)
160
161 const spawned = await next(e)
162 if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
163 if (role !== undefined) await ensureQuest($, shorten(e.description ?? '', 80) || 'An impromptu errand')
164
165 const who = role ? characterOf(role) : characterOf(e.subagentType)
166 const member: GuildMember = {
167 id: spawned.agentId,
168 role: role ?? e.subagentType,
169 parentId: e.parentAgentId,
170 status: 'working',
171 task: shorten(e.description ?? '', 80),
172 line: `${who.name} answers the summons`,
173 deeds: 0,
174 model: spawned.model,
175 startedAt: Date.now(),
176 }
177 await update($, members, list => [...(list ?? []), member].slice(-MEMBER_LIMIT))
178 await refreshStatus($)
179 if (e.parentAgentId === undefined) {
180 await setKeeper($, `${KEEPER.name} summons ${who.name}`)
181 }
182 await record($, who.icon, `${who.name} ${who.title} takes up: ${member.task}`)
183 return spawned
184 }).catch(($, e, next) => next(e)) // the pane is a spectator: a bookkeeping failure never blocks a dispatch
185
186 on('tool.call', async ($, e, next) => {
187 const q = await read($, quest)
188 if (q === null) return next(e)
189 const { deed, target } = classify(String(e.tool), e as unknown as Record<string, unknown>)
190
191 if (e.agentId === undefined) {
192 // The main loop is Mordain; his summons are drawn by agent.spawn.
193 if (e.tool !== 'Agent') await setKeeper($, flavor(KEEPER, deed, target))
194 return next(e)
195 }
196
197 const agentId = e.agentId
198 const list = (await read($, members)) ?? []
199 const member = list.find(m => m.id === agentId)
200 if (member === undefined) return next(e)
201 const line = flavor(memberCharacter(member), deed, target)
202 await update($, members, all =>
203 (all ?? []).map(m => (m.id === agentId ? { ...m, line, deeds: m.deeds + 1 } : m)),
204 )
205 await refreshStatus($)
206 return next(e)
207 }).catch(($, e, next) => next(e))
208
209 on('turn.complete', async ($, e, next) => {
210 const done = await next(e)
211 const q = await read($, quest)
212 if (q === null) return done
213
214 if (e.agentId === undefined) {
215 await setKeeper($, flavor(KEEPER, 'idle', ''), false)
216 return done
217 }
218
219 const agentId = e.agentId
220 const list = (await read($, members)) ?? []
221 const member = list.find(m => m.id === agentId)
222 if (member === undefined || member.status !== 'working') return done
223 const who = memberCharacter(member)
224 const fell = e.isAborted || e.reason === 'error' || e.reason === 'refusal'
225 await update($, members, all =>
226 (all ?? []).map(m =>
227 m.id === agentId
228 ? {
229 ...m,
230 status: fell ? 'fallen' : 'done',
231 line: fell ? `${who.name} was called back from the field` : `“${who.catchphrase}”`,
232 endedAt: Date.now(),
233 }
234 : m,
235 ),
236 )
237 await record(
238 $,
239 who.icon,
240 fell ? `${who.name} returns empty-handed` : `${who.name} returns to the hall (${elapsed(Date.now() - member.startedAt)})`,
241 )
242 await refreshStatus($)
243 return done
244 }).catch(($, e, next) => next(e)) // replays the turn's own answer; nothing runs twice
245
246 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247 const { Box, Text } = $.ui.resolve(e)
248 drawnOn.add(e.surface)
249 const q = await read($, quest)
250 const list = (await read($, members)) ?? []
251 const log = (await read($, chronicle)) ?? []
252 const frame = SPINNER[((await read($, tick)) ?? 0) % SPINNER.length]
253 const now = Date.now()
254
255 if (q === null) {
256 return (
257 <Box flexDirection="column">
258 <Text bold>{KEEPER.icon} The tavern is quiet.</Text>
259 <Text dimColor>{KEEPER.name} waits by the hearth. Issue /quest to call the guild.</Text>
260 </Box>
261 )
262 }
263
264 const statusMark = (m: GuildMember) =>
265 m.status === 'working' ? frame : m.status === 'done' ? '✓' : '✗'
266 const statusColor = (m: GuildMember) =>
267 m.status === 'working' ? 'claude' : m.status === 'done' ? 'success' : 'error'
268
269 const rows: RenderChildren[] = []
270 const drawMember = (m: GuildMember, indent: string) => {
271 const who = memberCharacter(m)
272 const clock = elapsed((m.endedAt ?? now) - m.startedAt)
273 rows.push(
274 <Text wrap="truncate-end">
275 {indent}
276 <Text color={statusColor(m)}>{statusMark(m)}</Text> {who.icon} <Text bold>{who.name}</Text>
277 <Text dimColor> {who.title} · {m.role}{m.model ? ` · ${m.model}` : ''} · {clock} · {m.deeds} deeds</Text>
278 </Text>,
279 )
280 rows.push(
281 <Text wrap="truncate-end" dimColor={m.status !== 'working'} italic>
282 {indent} {m.line}
283 </Text>,
284 )
285 for (const child of list.filter(c => c.parentId === m.id)) drawMember(child, indent + ' ')
286 }
287
288 const withDescendants = (m: GuildMember): GuildMember[] => [
289 m,
290 ...list.filter(c => c.parentId === m.id).flatMap(withDescendants),
291 ]
292 const ids = new Set(list.map(m => m.id))
293 const roots = list.filter(m => m.parentId === undefined || !ids.has(m.parentId))
294 const groups = PHASES.map(p => ({
295 ...p,
296 list: roots.filter(m => memberCharacter(m).phase === p.phase),
297 })).filter(g => g.list.length > 0)
298
299 groups.forEach((g, i) => {
300 const last = i === groups.length - 1
301 const working = g.list.flatMap(withDescendants).filter(m => m.status === 'working').length
302 rows.push(
303 <Text>
304 <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
305 <Text bold>{g.label}</Text>
306 <Text dimColor>{working > 0 ? ` ${working} at work` : ' done'}</Text>
307 </Text>,
308 )
309 for (const m of g.list) drawMember(m, last ? ' ' : '│ ')
310 })
311
312 const atWork = list.filter(m => m.status === 'working').length
313 return (
314 <Box flexDirection="column">
315 <Text bold wrap="truncate-end">⚔ {q.title}</Text>
316 <Text dimColor>
317 {elapsed(now - q.startedAt)} on the road · {atWork} at work · {list.length} summoned
318 </Text>
319 <Text> </Text>
320 <Text wrap="truncate-end">
321 <Text color={q.isKeeperBusy ? 'claude' : 'subtle'}>{q.isKeeperBusy ? frame : '·'}</Text> {KEEPER.icon}{' '}
322 <Text bold>{KEEPER.name}</Text>
323 <Text dimColor> {KEEPER.title} · Guildmaster</Text>
324 </Text>
325 <Text wrap="truncate-end" italic dimColor={!q.isKeeperBusy}>
326 {' '}{q.keeperLine}
327 </Text>
328 {rows}
329 {log.length > 0 && <Text> </Text>}
330 {log.length > 0 && <Text bold dimColor>Chronicle</Text>}
331 {log.map(entry => (
332 <Text wrap="truncate-end" dimColor>
333 {entry.icon} {entry.text}
334 </Text>
335 ))}
336 </Box>
337 )
338 })
339}
340hooks/roster.ts 299 lines1// The guild as the pane draws it: one entry per agent in plugin/agents/,
2// voiced from plugin/CHARACTERS.md. `{x}` in a line is the deed's target.
3
4export type Phase = 'build' | 'review' | 'closer' | 'standalone' | 'hireling'
5
6export type Deed = 'read' | 'search' | 'write' | 'run' | 'ask' | 'ledger' | 'idle'
7
8export type Character = {
9 name: string
10 title: string
11 icon: string
12 phase: Phase
13 catchphrase: string
14 lines: Partial<Record<Deed, string>>
15}
16
17export const PHASES: { phase: Phase; label: string }[] = [
18 { phase: 'build', label: 'Build chain' },
19 { phase: 'review', label: 'Review fan-out' },
20 { phase: 'closer', label: 'Closer' },
21 { phase: 'standalone', label: 'Field work' },
22 { phase: 'hireling', label: 'Hirelings' },
23]
24
25export const KEEPER: Character = {
26 name: 'Mordain',
27 title: 'the Keeper',
28 icon: '🧙',
29 phase: 'standalone',
30 catchphrase: 'First, the plan. Then, the adventurers.',
31 lines: {
32 read: 'reads the whole scroll before speaking: {x}',
33 search: 'consults the archives for {x}',
34 write: 'inks the plan: {x}',
35 run: 'sends a runner on an errand: {x}',
36 ask: 'awaits the patron’s word',
37 ledger: 'updates the ledger',
38 idle: 'rests in the high chair by the hearth',
39 },
40}
41
42export const ROSTER: Record<string, Character> = {
43 'prototype-builder': {
44 name: 'Pip', title: 'the Scout', icon: '🎒', phase: 'standalone',
45 catchphrase: 'Here, it runs! Don’t ask what happens on Tuesday.',
46 lines: {
47 read: 'peeks at {x} between bites', search: 'rummages around for {x}',
48 write: 'lashes together {x}', run: 'pokes it to see if it runs: {x}',
49 idle: 'chews thoughtfully',
50 },
51 },
52 'test-author': {
53 name: 'Seraphine', title: 'the Oracle', icon: '🔮', phase: 'build',
54 catchphrase: 'The spec is written. The test is its shadow.',
55 lines: {
56 read: 'reads the spec as scripture: {x}', search: 'seeks the Expectation of {x}',
57 write: 'inscribes a prophecy: {x}', run: 'watches the prophecy fail, as foretold: {x}',
58 idle: 'meditates on the spec, eyes averted from the code',
59 },
60 },
61 'feature-implementer': {
62 name: 'Bruga', title: 'the Smith', icon: '🔨', phase: 'build',
63 catchphrase: 'Show me the blueprint.',
64 lines: {
65 read: 'studies the blueprint: {x}', search: 'sorts the scrap pile for {x}',
66 write: 'forges {x}', run: 'quenches the blade: {x}',
67 idle: 'stokes the forge',
68 },
69 },
70 refactorer: {
71 name: 'Tink', title: 'the Enchanter', icon: '✨', phase: 'build',
72 catchphrase: 'Same stone. Better setting.',
73 lines: {
74 read: 'turns {x} in the light', search: 'hunts loose settings in {x}',
75 write: 'resets the stone: {x}', run: 'checks the shine holds: {x}',
76 idle: 'polishes quietly',
77 },
78 },
79 'architecture-reviewer': {
80 name: 'Aldric', title: 'the Cartographer', icon: '📐', phase: 'build',
81 catchphrase: 'Three paths lie open. Only one leads forward without debt.',
82 lines: {
83 read: 'surveys the terrain of {x}', search: 'traces the roads to {x}',
84 run: 'paces the distance: {x}', idle: 'weighs three paths',
85 },
86 },
87 'debug-investigator': {
88 name: 'Kael', title: 'the Tracker', icon: '🏹', phase: 'standalone',
89 catchphrase: 'I know WHY. What you do next is not my tale to tell.',
90 lines: {
91 read: 'reads the tracks in {x}', search: 'follows the trail of {x}',
92 write: 'marks the trail: {x}', run: 'sets a snare: {x}',
93 idle: 'kneels to study the ground',
94 },
95 },
96 'ui-test-author': {
97 name: 'Vera', title: 'the Playwright', icon: '🎭', phase: 'review',
98 catchphrase: 'The curtain has risen. Let us see if the play matches the script.',
99 lines: {
100 read: 'reads the script: {x}', search: 'scouts the stage for {x}',
101 write: 'writes the scene: {x}', run: 'raises the curtain: {x}',
102 idle: 'rehearses the blocking',
103 },
104 },
105 'security-reviewer': {
106 name: 'Oriana', title: 'the Watcher', icon: '🦉', phase: 'review',
107 catchphrase: 'Trust no path you have not walked.',
108 lines: {
109 read: 'walks the wall past {x}', search: 'searches for unwatched gates: {x}',
110 run: 'tests the lock: {x}', idle: 'keeps the watch',
111 },
112 },
113 'docs-writer': {
114 name: 'Cassian', title: 'the Scribe', icon: '📖', phase: 'review',
115 catchphrase: 'A song is only as true as the singer who remembers it.',
116 lines: {
117 read: 'reads the old songs: {x}', search: 'seeks the verse about {x}',
118 write: 'sets down the verse: {x}', run: 'checks the tune: {x}',
119 idle: 'sharpens the quill',
120 },
121 },
122 'observability-reviewer': {
123 name: 'Vance', title: 'the Chronicler', icon: '📜', phase: 'review',
124 catchphrase: 'What happened, and would we know if it happened again?',
125 lines: {
126 read: 'reads the chronicle of {x}', search: 'searches for unrecorded deeds: {x}',
127 run: 'listens for echoes: {x}', idle: 'asks: would we know?',
128 },
129 },
130 'reliability-reviewer': {
131 name: 'Thalia', title: 'the Stormwarden', icon: '⚡', phase: 'review',
132 catchphrase: 'The wind will come. The wall must hold.',
133 lines: {
134 read: 'tests the walls of {x}', search: 'searches for cracks: {x}',
135 run: 'summons a squall: {x}', idle: 'reads the sky',
136 },
137 },
138 'performance-reviewer': {
139 name: 'Cassia', title: 'the Smith of Cycles', icon: '⏳', phase: 'review',
140 catchphrase: 'Every cycle costs something. Pay it knowingly.',
141 lines: {
142 read: 'counts the cycles in {x}', search: 'hunts hot loops: {x}',
143 run: 'times the forge: {x}', idle: 'weighs the cost',
144 },
145 },
146 'ops-readiness-reviewer': {
147 name: 'Garran', title: 'the Quartermaster', icon: '📦', phase: 'review',
148 catchphrase: 'No army marches without a wagon train.',
149 lines: {
150 read: 'inspects the supply lines in {x}', search: 'inventories {x}',
151 run: 'loads the wagon: {x}', idle: 'drafts the marching orders',
152 },
153 },
154 'migration-safety-reviewer': {
155 name: 'Ysolde', title: 'the Gravedigger', icon: '🪦', phase: 'review',
156 catchphrase: 'Some doors close behind you. Be sure before you walk through.',
157 lines: {
158 read: 'reads the gravestones of {x}', search: 'searches for one-way doors: {x}',
159 run: 'tests the ground: {x}', idle: 'weighs what cannot be undone',
160 },
161 },
162 'accessibility-reviewer': {
163 name: 'Lior', title: 'the Lampbearer', icon: '🏮', phase: 'review',
164 catchphrase: 'A door without a handle is a wall.',
165 lines: {
166 read: 'carries the lamp through {x}', search: 'looks for doors without handles: {x}',
167 run: 'walks the path in the dark: {x}', idle: 'trims the wick',
168 },
169 },
170 'pr-author': {
171 name: 'Rook', title: 'the Herald', icon: '📯', phase: 'closer',
172 catchphrase: 'The deed is done. Now let the tale be told precisely.',
173 lines: {
174 read: 'reviews the deeds in {x}', search: 'gathers witnesses: {x}',
175 write: 'drafts the proclamation: {x}', run: 'consults the ledger: {x}',
176 idle: 'clears the throat',
177 },
178 },
179 'fog-cartographer': {
180 name: 'Wren', title: 'the Wayfinder', icon: '🧭', phase: 'closer',
181 catchphrase: 'I mark what the scouts saw. I do not redraw coastlines I haven’t walked.',
182 lines: {
183 read: 'reads the scouts’ notes: {x}', search: 'seeks the fog line near {x}',
184 write: 'marks the map: {x}', run: 'checks the bearings: {x}',
185 idle: 'watches the fog',
186 },
187 },
188 'plugin-validator': {
189 name: 'Tabs', title: 'the Apprentice', icon: '🔩', phase: 'standalone',
190 catchphrase: 'Small checks, small surprises.',
191 lines: {
192 read: 'checks {x}, line by line', search: 'counts the rivets: {x}',
193 write: 'notes it down: {x}', run: 'runs the checks: {x}',
194 idle: 'ticks the checklist',
195 },
196 },
197 'model-echo': {
198 name: 'Echo', title: 'the Diagnostic', icon: '🔔', phase: 'standalone',
199 catchphrase: 'An echo is a hint, never proof.',
200 lines: { idle: 'rings once and listens' },
201 },
202}
203
204const GENERIC: Record<Deed, string> = {
205 read: 'reads {x}',
206 search: 'searches for {x}',
207 write: 'scribbles notes: {x}',
208 run: 'runs a command: {x}',
209 ask: 'asks the patron a question',
210 ledger: 'updates the ledger',
211 idle: 'is at work',
212}
213
214/** A subagent type the guild does not employ: `Explore`, `general-purpose`. */
215export function hireling(subagentType: string): Character {
216 return {
217 name: shorten(subagentType, 40),
218 title: 'hireling',
219 icon: '🧳',
220 phase: 'hireling',
221 catchphrase: 'Paid in full.',
222 lines: {},
223 }
224}
225
226/** `guildhall:test-author` → `test-author`; undefined for a non-guild agent. */
227export function guildRole(subagentType: string): string | undefined {
228 const role = subagentType.startsWith('guildhall:')
229 ? subagentType.slice('guildhall:'.length)
230 : subagentType
231 return Object.hasOwn(ROSTER, role) ? role : undefined
232}
233
234export function characterOf(role: string): Character {
235 return Object.hasOwn(ROSTER, role) ? (ROSTER[role] as Character) : hireling(role)
236}
237
238/** One line of plain text: control characters (terminal escapes) dropped, cut to `max`. */
239export const shorten = (text: string, max = 48) => {
240 const one = text.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g, ' ').replace(/\s+/g, ' ').trim()
241 return one.length > max ? one.slice(0, max - 1) + '…' : one
242}
243
244// A URL's query and fragment can carry tokens: only its host and path are shown.
245const withoutQuery = (url: string) => {
246 try {
247 const parsed = new URL(url)
248 return parsed.origin + parsed.pathname // drops user:pass@ too
249 } catch {
250 return url.split(/[?#]/)[0]?.replace(/\/\/[^/@]*@/, '//') ?? ''
251 }
252}
253
254const basename = (path: string) => shorten(path.split('/').filter(Boolean).pop() ?? path)
255
256/** Which kind of deed a tool call is, and what it is done to. */
257export function classify(
258 tool: string,
259 input: Record<string, unknown>,
260): { deed: Deed; target: string } {
261 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
262 switch (tool) {
263 case 'Read':
264 case 'NotebookRead':
265 return { deed: 'read', target: basename(str('file_path') || str('notebook_path')) }
266 case 'Write':
267 case 'Edit':
268 case 'MultiEdit':
269 case 'NotebookEdit':
270 return { deed: 'write', target: basename(str('file_path') || str('notebook_path')) }
271 case 'Grep':
272 case 'Glob':
273 return { deed: 'search', target: shorten(str('pattern'), 32) }
274 case 'WebFetch':
275 return { deed: 'search', target: shorten(withoutQuery(str('url')), 40) }
276 case 'WebSearch':
277 return { deed: 'search', target: shorten(str('query'), 40) }
278 case 'Bash':
279 // A bare command can hold a credential; only the call's own description is shown.
280 return { deed: 'run', target: shorten(str('description'), 40) }
281 case 'AskUserQuestion':
282 return { deed: 'ask', target: '' }
283 case 'TodoWrite':
284 case 'TaskCreate':
285 case 'TaskUpdate':
286 return { deed: 'ledger', target: '' }
287 default:
288 return { deed: 'idle', target: shorten(tool, 40) }
289 }
290}
291
292/** The in-character line for a character doing one deed. */
293export function flavor(who: Character, deed: Deed, target: string): string {
294 const template = who.lines[deed] ?? GENERIC[deed]
295 // A deed with no target to show drops its trailing `: {x}`.
296 const line = target ? template.replace('{x}', target) : template.replace(/:\s*\{x\}$/, '').replace('{x}', 'something')
297 return `${who.name} ${line}`
298}
299types/index.d.ts 35 lines1export type MemberStatus = 'working' | 'done' | 'fallen'
2
3export type GuildMember = {
4 id: string
5 role: string
6 parentId?: string
7 status: MemberStatus
8 task: string
9 line: string
10 deeds: number
11 model?: string
12 startedAt: number
13 endedAt?: number
14}
15
16export type GuildQuest = {
17 title: string
18 startedAt: number
19 keeperLine: string
20 isKeeperBusy: boolean
21}
22
23export type ChronicleEntry = { at: number; icon: string; text: string }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'guildhall-tavern': {
28 quest: GuildQuest | null
29 members: GuildMember[]
30 chronicle: ChronicleEntry[]
31 tick: number
32 }
33 }
34}
35