Run Claude Code like a D&D table: a DM screen of pixel heroes for every helper Claude sends out, a strip with your character, a rolling d20, a Realm Map to…

Run Claude Code like a D&D table. Claude is your character at the head of the party; every helper it sends off on a task is a pixel hero who walks, asks, wins and falls in a side panel: your DM screen. Everything runs inside the mod: it adds nothing to Claude's prompt, registers no tool for Claude, and costs no tokens.


| Your Party | The Realm Map |
|---|---|
![]() | ![]() |
| Hall of Heroes | Choose your character |
![]() | ![]() |
| Ten themes, light and dark | Steps told as deeds |
![]() | ![]() |
<sub>Pictures taken in showcase mode (/dm showcase): every hero, session and quest in them is made up.</sub>
| Page | What you get |
|---|---|
| Your Party | A hero for every helper Claude sends out (Fighter, Wizard, Rogue and nine more), walking while they work, with what they are doing, how much context they have left, Stop, and what it is asking when it needs your permission (you answer in the app's own prompt). Below: the camp (commands and monitors left running in the background, with Stop), the quest log (a receipt for each of your last turns) and the graveyard (finished heroes, their last words and XP). |
| The Realm Map | Every Claude session open in the desktop app, sorted into Pinned, Needs you, Working, Idle, Asleep and Archived. Go jumps to one, Archive (or Archive all) clears the ones you are done with, Restore brings one back, and Name gives a session your own quest name. |
| Hall of Heroes | Each hero's XP and level across sessions, their record, and ten achievements to earn. |
| The DM's Tome | Settings: choose your character (the Overlord or any hero, with its tale and ability scores, and your own name), the strip, sounds and volume, the rolling d20, ten D&D themes in light and dark (following the app), the heading lettering, which sections show, and the credits. |
Above the message box sits the strip: your character, how much context is left, your plan's 5-hour and weekly limits, and one button to open or close the DM screen. While Claude works, a d20 tumbles beside the working line; win a long quest and it lands on a natural 20. Tool steps read as deeds: Studied, Forged, Cast, Scouted.
At the prompt of a Claude Code session in a terminal:
/plugin install dungeon-master --marketplace NicBritz/claude-code-mods
Answer y to add the marketplace, then pick the user scope. It opens in your next session (switch that off in the Tome).
Mods are an early-access part of Claude Code, so a future update may change them. The DM screen is built for the Claude desktop app; in a terminal you get the same pages in text.
Updates come out as new versions in this repository. To get the newest one:
claude plugin update dungeon-master@nicbritz-mods
then start a new session. To have it happen by itself, open /plugin, go to Marketplaces, pick nicbritz-mods and switch on auto-update. The version you are on is at the bottom of the Tome (⚙), and /dm status starts with it.
In auto mode the app only approves actions you asked Claude for, and a click on the map is not one, so it refuses until you allow the two session tools. Add this to ~/.claude/settings.json (a permissions section next to env), then start a new session:
"permissions": {
"allow": [
"mcp__ccd_session_mgmt__archive_session",
"mcp__ccd_session_mgmt__unarchive_session",
"mcp__ccd_session_mgmt__list_sessions",
"TaskStop"
]
}
Archiving is undoable (the sidebar's Archived list, or Restore), but the rule also lets Claude archive sessions without asking. The map's ⓘ Archive not working? button shows the same.
The DM screen only ever uses tools these rules allow, so it never costs a token: in auto mode any other tool would go to the mode's checker, which is a model call. Without a rule its button says which line to add and does nothing, and the map asks for the session list only once when you open it. TaskStop is what the Stop buttons use.
/dm opens the DM screen; /dm settings opens the Tome; /dm demo plays a 40-second show of heroes, context bars and compaction/dm showcase swaps in made-up heroes, sessions and quests for 5 minutes, so you can take screenshots without showing anything of your own/dm status says what is on and how the map is doing (for bug reports)/dm name <name> names your character; /dm theme <name>; /dm font fantasy|app; /dm volume 0-100/dm <hud|rows|dice|sound|panel> on|offMods run with your own permissions on your machine, unsandboxed, so here is everything this one touches. It sends nothing over the network, calls no model, and never makes a permission decision.
Files it writes, all in its own folder ~/.claude/dungeon-master/ (or $CLAUDE_CONFIG_DIR/dungeon-master/). None of them is a file Claude Code or any other tool reads as settings or instructions.
settings.json: the mod's own settings (theme, sounds, your character), shared by your open sessions.names.json: names you give sessions on the map.sessions/<id>.json: each open session checks in every few seconds with its folder name, what it is doing and its context left, so the map can show it. Never your prompts.What it reads
HOME and CLAUDE_CONFIG_DIR (to find its folder) and CLAUDE_CODE_HOST_SESSION_ID (this session's id in the app, for the map's Go link). No passwords or tokens.userThemeMode value in the Claude app's config.json, every 30 seconds, to follow light or dark.Programs it runs, each a fixed macOS command:
defaults read -g AppleInterfaceStyle: the Mac's light or dark mode, when the app's setting is not there.open <link>: only the app's own claude:// session links, when you press Go on the map.Tools it calls by itself, only when you press a button or open the map, and only when your own permission rules already allow that tool (otherwise it says which rule to add and does nothing):
list_sessions (the app's session list) while the Realm Map is on screen.archive_session / unarchive_session when you press Archive or Restore.TaskStop when you press Stop on a hero or a camp card.Events it watches, all read-only. Each passes the event on unchanged:
Also
/dm, its one slash command.sounds/.hooks/sprites.ts, hooks/inked.ts and hooks/fantasy-font.ts are drawing data (SVG paths), generated by the scripts in tools/ from the credited art. They are not compiled code.MIT, © 2026 Nico Britz (see LICENSE). The bundled art, lettering and sounds keep their own licences (below). An unofficial fan work: Dungeons & Dragons is a trademark of Wizards of the Coast.
Heroes, the Overlord and items: 0x72's DungeonTileset II (CC0). D&D icons: Lorc, Delapouite and Skoll on game-icons.net (CC BY 3.0). Heading lettering: converted from Cinzel Decorative by Natanael Gama (SIL Open Font Licence). Sounds: Kenney's RPG Audio and Impact Sounds (CC0). Full details in CREDITS.md, the licence texts in licences/, and the Tome's Credits and licences section.
hooks/register.tsx: every hook and every drawing (the engine follows $ only within this file)hooks/*.ts: the pure parts (heroes, sprites, art, characters, achievements, map rules, themes, words)tools/: the generators for sprites, icons and lettering; sounds/make-sounds.py for the soundstests/: claude plugin test mods/dungeon-masterhooks/register.tsx 2728 lines1// The Dungeon Master mod: Claude Code as a D&D table. Three parts, each of
2// which can be switched off in the DM's Tome (settings):
3// - the DM screen (a side panel): a pixel hero per helper Claude sends out,
4// the camp of background work, the quest log, the graveyard, the Hall of
5// Heroes with achievements, the Realm Map of sessions, and the Tome;
6// - the strip above the message box: the person's character (the main
7// thread), its context bar, the plan's limits and critical rolls;
8// - the rows around the chat: tool steps told as deeds, the working line
9// with a rolling d20, and the hints.
10// It adds nothing to Claude's prompt and costs no tokens.
11// Everything that talks to the engine lives in this one file (the engine
12// follows $ only within it); the pure rules, art and words are beside it.
13//
14// Text keeps the theme's own colours (no coloured words, so contrast holds in
15// light and dark); colour lives in borders and in the pixel art.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, ModelUsage, Register, RenderElement, RenderInput, SessionCompactResult } from 'claude-code'
19
20import type { AppSession, CampTask, Context, Fame, Kingdom, OpenedRows, Overlord, Pal, PalStatus, Quest, Section, Settings, Stamina, View } from '../types'
21import { describeCall, elapsed, firstLine, softBreak } from './activity'
22import { BAR_SIZE, MINI_BAR_SIZE, contextBarSvg, contextBarText, contextTokens, contextWords, leftOf, windowFor } from './context'
23import { added, dollars, priced, spend, tokens, tokensOf } from './cost'
24import { EMPTY_FAME, levelOf, readFame, withQuest, withSpend, xpBarSvg, xpFor, xpForLevel } from './fame'
25import { GRAVE_SIZE, graveSvg } from './graves'
26import { MAX_PEEK_BYTES, applyChange, campFromCall, campIcon, campState, cwdSlug, isRunningWork, lastLine, parseNotification, pruneCamp, reconcile } from './camp'
27import { ICON_SIZE, iconSvg } from './icons'
28import { KEPT_QUESTS, SHOWN_QUESTS, listed, newQuest, questGain, questParts, questTally, withCall } from './quests'
29import { asContext, crossing, shownStamina, staminaLabel, staminaWords } from './stamina'
30import { HEARTBEAT_MS, agoWords, dataDirFrom, freshFiles, appSessionsFrom, folderName, hostIdOf, mapGroups, mapSummary, readNames, cleanSessionName, uniqueKingdoms, isBackgroundFolder, isSessionLink, kingdomTitle, sessionLink, kingdomFile, kingdomOf, kingdomWords, kingdomsFrom, sameKingdom } from './realm'
31import { DEFAULT_MAIN_NAME, DEFAULT_SETTINGS, cleanName, HEADING_FONTS, SECTIONS, SWITCH_GROUPS, type Switch, readRealmAsk, readSettings, sameSettings, settingsWords, switchLabel } from './settings'
32import { THEME_BLURBS, THEME_NAMES, type ThemeName, isThemeName, paletteOf } from './themes'
33import { type GlyphName, LOOP_FRAMES, animatedSvg, diePicture, headingPicture, iconPicture } from './art'
34import { CREDITS, MOD, OWN_WORK } from './credits'
35import {
36 ACHIEVEMENTS,
37 ARCHIVES_FOR_SWEEP,
38 type AchievementId,
39 CRITICAL_STEPS,
40 CRITICAL_TURN_MS,
41 FULL_PARTY,
42 MARATHON_MS,
43 isNightOwlHour,
44 readEarned,
45} from './achievements'
46import { SHOWCASE_MS, showcaseGraves, showcaseKingdoms, showcaseLord, showcaseQuests, showcaseSessions } from './showcase'
47import { LOOKS, SCORE_NAMES, croppedFigure, figureTop, lookName, modifier, portraitSvg, scoreBarSvg, sheetOf, stepLook } from './characters'
48import { detailsMarkdown, hintWords, spinnerWords, toolWords } from './words'
49import {
50 FRAME_MS,
51 HERO_SIZE,
52 HEROES,
53 MINI_SIZE,
54 OVERLORD_VOICE,
55 assignHero,
56 epitaph,
57 heroOf,
58 heroSvg,
59 overlordSvg,
60} from './heroes'
61import {
62 BORDERS,
63 ENDING,
64 OUTCOME,
65 RECENT_MS,
66 borderOf,
67 isActive,
68 isCurrent,
69 leaderOf,
70 plural,
71 summary,
72 trimmed,
73 withColours,
74} from './rules'
75
76// The values the panel draws from, held by the host for the session (a
77// reload keeps them). The settings and the hall of fame are mirrored to
78// $.store (and the settings to ~/.claude/realm/settings.json) so they
79// outlive the session.
80const pals = atom({ plugin: 'dungeon-master', key: 'pals' } as const, [] as Pal[])
81const tick = atom({ plugin: 'dungeon-master', key: 'tick' } as const, 0)
82// The animation frame, one step per FRAME_MS while anyone is at work: only the
83// drawings that move read it (the strip, the party page, the rolling die), so
84// a redraw for the walk never reaches the settings, fame or map pages and
85// swallows a click there.
86const frameTick = atom({ plugin: 'dungeon-master', key: 'frameTick' } as const, 0)
87// Whether the panel is open, for the strip's Open / Close toggle.
88const isPaneOpen = atom({ plugin: 'dungeon-master', key: 'isPaneOpen' } as const, false)
89// The look the character select screen shows while the person browses with
90// the arrows; null shows the character they have chosen.
91const browseLook = atom({ plugin: 'dungeon-master', key: 'browseLook' } as const, null as number | null)
92// Achievements earned (id to when), kept in the store across sessions.
93const earned = atom({ plugin: 'dungeon-master', key: 'earned' } as const, {} as Record<string, number>)
94const STORE_EARNED = 'achievements'
95const STORE_TALLY = 'tally'
96// A critical roll on show on the strip: the face, who rolled it, until when.
97const critical = atom({ plugin: 'dungeon-master', key: 'critical' } as const, null as { face: number; who: string; until: number } | null)
98const CRITICAL_SHOW_MS = 6000
99// Heroes who just reached a new level (slot to until when), to glow a while.
100const levelUps = atom({ plugin: 'dungeon-master', key: 'levelUps' } as const, {} as Record<string, number>)
101const LEVEL_GLOW_MS = 10 * 60_000
102// The person's own names for sessions (app id to name), shared by every
103// session through ~/.claude/realm/names.json, and the one being renamed.
104const sessionNames = atom({ plugin: 'dungeon-master', key: 'sessionNames' } as const, {} as Record<string, string>)
105const renaming = atom({ plugin: 'dungeon-master', key: 'renaming' } as const, null as string | null)
106// A sweeping action waiting for its second press (Archive all, Reset): which.
107const confirming = atom({ plugin: 'dungeon-master', key: 'confirming' } as const, null as string | null)
108const CONFIRM_MS = 6000
109// Whether the app is in light mode, as far as the mod can tell, every 30 s:
110// the desktop app's own appearance setting (userThemeMode in its config.json,
111// the only value read from it), else, when that follows the system, the
112// Mac's appearance. Elsewhere it cannot tell, and stays dark.
113const isLightApp = atom({ plugin: 'dungeon-master', key: 'isLightApp' } as const, false)
114const APPEARANCE_MS = 30_000
115
116async function checkAppearance($: Engine): Promise<void> {
117 let isLight: boolean | null = null
118 if (homeDir !== null) {
119 const text = await $.fs.read(`${homeDir}/Library/Application Support/Claude/config.json`).then(String).catch(() => null)
120 const mode = text === null ? undefined : appModeOf(text)
121 if (mode === 'light' || mode === 'dark') isLight = mode === 'light'
122 }
123 if (isLight === null) {
124 const ran = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle']).catch(() => null)
125 if (ran === null) return
126 // "Dark" in dark mode; the key is missing (an error) in light mode.
127 isLight = !(ran.exitCode === 0 && /dark/i.test(String(ran.stdout)))
128 }
129 if ((await read($, isLightApp)) !== isLight) await update($, isLightApp, () => isLight)
130}
131
132// The palette for drawing now: the theme, light or dark as the app is.
133async function lookNow($: Engine, theme: ThemeName) {
134 return paletteOf(theme, await read($, isLightApp))
135}
136// A session used this recently is never called asleep, so never swept up.
137const QUIET_FOR_ASLEEP_MS = 15 * 60_000
138const hasAutoOpened = atom({ plugin: 'dungeon-master', key: 'hasAutoOpened' } as const, false)
139const openGrave = atom({ plugin: 'dungeon-master', key: 'openGrave' } as const, null as string | null)
140const view = atom({ plugin: 'dungeon-master', key: 'view' } as const, 'party' as View)
141const fame = atom({ plugin: 'dungeon-master', key: 'fame' } as const, {} as Record<string, Fame>)
142const IDLE_OVERLORD: Overlord = { status: 'idle', activity: '', tools: 0, turns: 0, startedAt: 0, ask: null, lastWords: '', usage: null }
143const overlord = atom({ plugin: 'dungeon-master', key: 'overlord' } as const, IDLE_OVERLORD)
144// Every switch, the theme and the hidden panel sections.
145const settings = atom({ plugin: 'dungeon-master', key: 'prefs' } as const, DEFAULT_SETTINGS as Settings)
146// The tool rows the person has opened to see the call and its result.
147const opened = atom({ plugin: 'dungeon-master', key: 'opened' } as const, [] as OpenedRows)
148// The plan's usage windows (5-hour, weekly), as stamina bars.
149const stamina = atom({ plugin: 'dungeon-master', key: 'stamina' } as const, [] as Stamina[])
150// The main thread's finished turns, newest last, as the quest log shows them.
151const quests = atom({ plugin: 'dungeon-master', key: 'quests' } as const, [] as Quest[])
152// Background work: shell commands, monitors, workflows, wake-ups and crons.
153const camp = atom({ plugin: 'dungeon-master', key: 'camp' } as const, [] as CampTask[])
154// The desktop app's own list of open sessions (titles, last activity).
155const appSessions = atom({ plugin: 'dungeon-master', key: 'appSessions' } as const, [] as AppSession[])
156// The map's folded sections, kept in the store across sessions. Asleep and
157// Archived start folded: they are long and rarely needed.
158const DEFAULT_FOLDED = ['asleep', 'archived', 'tome-credits']
159const mapFolded = atom({ plugin: 'dungeon-master', key: 'mapFolded' } as const, DEFAULT_FOLDED as string[])
160const STORE_FOLDED = 'mapFolded'
161// How many rows each long section shows; more on request. A section with
162// hundreds of rows made the panel too big to redraw.
163const PAGE_ROWS = 20
164const mapShown = atom({ plugin: 'dungeon-master', key: 'mapShown' } as const, {} as Record<string, number>)
165// The heroes in the hall of fame whose record is opened out.
166const fameOpen = atom({ plugin: 'dungeon-master', key: 'fameOpen' } as const, [] as number[])
167// Whether the map's ⓘ note on allowing Archive is open.
168const showArchiveHelp = atom({ plugin: 'dungeon-master', key: 'showArchiveHelp' } as const, false)
169// The sessions already archived, for the map's Archived list (folded by default).
170const archivedSessions = atom({ plugin: 'dungeon-master', key: 'archivedSessions' } as const, [] as AppSession[])
171// Sessions the app refused to archive (open in a window, or in use): they
172// join the pinned ones in the map's Pinned band, with no Archive button.
173const keptByApp = atom({ plugin: 'dungeon-master', key: 'keptByApp' } as const, [] as string[])
174const UNARCHIVE_SESSION = 'mcp__ccd_session_mgmt__unarchive_session'
175// The desktop app's session tools, which the map calls on the person's behalf.
176const LIST_SESSIONS = 'mcp__ccd_session_mgmt__list_sessions'
177const ARCHIVE_SESSION = 'mcp__ccd_session_mgmt__archive_session'
178const APP_LIST_MS = 30_000
179let lastListReport = 'not asked yet'
180// The one setting Archive needs in auto mode, shown in the map's ⓘ note.
181const ARCHIVE_RULE_MARKDOWN =
182 '```json\n"permissions": {\n "allow": [\n "mcp__ccd_session_mgmt__archive_session",\n "mcp__ccd_session_mgmt__unarchive_session",\n "mcp__ccd_session_mgmt__list_sessions",\n "TaskStop"\n ]\n}\n```'
183// Every other open session on this machine, as the map shows them.
184const realm = atom({ plugin: 'dungeon-master', key: 'realm' } as const, [] as Kingdom[])
185// Showcase mode: until this time every page draws made-up content (showcase.ts).
186const showcaseUntil = atom({ plugin: 'dungeon-master', key: 'showcaseUntil' } as const, 0)
187
188const STORE_FAME = 'fame'
189const STORE_SETTINGS = 'settings'
190
191// The panel's id, written here so the engine reads it off the matcher.
192const PANE = 'dungeon-master'
193const TITLE = 'Dungeon Master'
194// The Overlord in the HUD: the art at 1x, so it fits the band.
195const HUD_LORD_SIZE = { width: 62, height: 74 } as const
196// A party hero standing in for the Overlord: the same height, its own shape.
197const HUD_HERO_SIZE = { width: 40, height: 75 } as const
198// The party page's heroes: a quarter bigger than the sprite's 2x, followers 1.5x.
199const PARTY_HERO_SIZE = { width: 40, height: 75 } as const
200const FOLLOWER_SIZE = { width: 24, height: 45 } as const
201// Each kingdom's overlord on the realm map, a little smaller.
202const REALM_LORD_SIZE = { width: 24, height: 29 } as const
203// The d20 beside the working line.
204const DIE_SIZE = 22
205// One breakpoint for the whole DM screen, in the panel's own columns: wider,
206// every row keeps its words and buttons side by side; narrower, every row
207// puts its buttons under its words, all at once, so the screen stays tidy.
208const NARROW_COLUMNS = 64
209// The strip's own: narrower, its button moves under the words.
210const NARROW_STRIP_COLUMNS = 90
211// The panel's width as last drawn, for /dm status (to tune the breakpoint).
212let lastPaneColumns = 0
213
214type Engine = EngineInterface
215type Change = { before: Pal; after: Pal }
216type Sound = 'set-out' | 'done' | 'needs-you' | 'error' | 'stopped' | 'level-up' | 'critical'
217type Size = { readonly width: number; readonly height: number }
218
219// --- sound -----------------------------------------------------------------
220
221// Calm sound effects: soft bell tones the mod ships in sounds/. One mute
222// switch silences them all; a burst of events plays one tone, not a chord.
223// "Needs you" always sounds; the rest wait this long after any tone.
224const QUIET_MS = 1500
225
226let lastAt = Number.NEGATIVE_INFINITY
227
228// The Volume row's Test button: the quest-won sound at the chosen volume,
229// whatever the quiet window says.
230async function playTest($: EngineInterface): Promise<void> {
231 lastAt = Number.NEGATIVE_INFINITY
232 await chime($, 'done')
233}
234
235async function chime($: EngineInterface, sound: Sound): Promise<void> {
236 if (!(await read($, settings)).sound) return
237 const now = await $.clock.now()
238 if (sound !== 'needs-you' && now - lastAt < QUIET_MS) return
239 lastAt = now
240 const volume = (await read($, settings)).volume
241 if (volume <= 0) return
242 $.audio.play({ asset: `sounds/${sound}.wav` }, { gain: volume / 100 }).catch(() => undefined)
243}
244
245// --- loading -------------------------------------------------------------
246
247// On every load: drop pals in an old shape, colour live ones, bring the hall
248// of fame back from the store, and read the settings.
249async function restore($: Engine): Promise<void> {
250 await update($, pals, list => withColours(list.every(isCurrent) ? list : list.filter(isCurrent)))
251 const stored = await $.store.get(STORE_FAME).catch(() => undefined)
252 await update($, fame, () => readFame(stored))
253 await loadSettings($)
254 const storedEarned = await $.store.get(STORE_EARNED).catch(() => undefined)
255 await update($, earned, () => readEarned(storedEarned))
256 const folded = await $.store.get(STORE_FOLDED).catch(() => undefined)
257 if (Array.isArray(folded)) await update($, mapFolded, () => folded.filter((one): one is string => typeof one === 'string'))
258}
259
260async function toggleFold($: Engine, key: string): Promise<void> {
261 const next = await update($, mapFolded, list => (list.includes(key) ? list.filter(one => one !== key) : [...list, key]))
262 await $.store.set(STORE_FOLDED, next).catch(() => undefined)
263 // Opening a long list asks the app for the whole of it, in the background,
264 // so the section opens at once.
265 if (!next.includes(key) && (key === 'asleep' || key === 'archived')) {
266 $.clock.after(0, () => {
267 refreshAppSessions($, true).catch(() => undefined)
268 })
269 }
270}
271
272// Before 3.0 the mod kept its files in <config>/realm: carry the settings and
273// session names across once, if this folder has none yet.
274async function adoptEarlierFolder($: Engine): Promise<void> {
275 if (dataDir === null) return
276 const earlier = dataDir.replace(/\/dungeon-master$/, '/realm')
277 for (const name of ['settings.json', 'names.json']) {
278 if (await $.fs.exists(`${dataDir}/${name}`).catch(() => true)) continue
279 const text = await $.fs.read(`${earlier}/${name}`).then(String).catch(() => null)
280 if (text !== null) await $.fs.write(`${dataDir}/${name}`, text).catch(() => undefined)
281 }
282}
283
284// --- settings ------------------------------------------------------------------
285
286// The record is ~/.claude/realm/settings.json, so a change in one session
287// reaches every open one; the store is the fallback.
288async function loadSettings($: Engine): Promise<void> {
289 const dir = dataDir
290 const text = dir === null ? null : await $.fs.read(`${dir}/settings.json`).then(String).catch(() => null)
291 const next = text !== null ? readSettings(text) : readSettings(await $.store.get(STORE_SETTINGS).catch(() => undefined))
292 await applySettings($, next)
293}
294
295async function writeSettings($: Engine, next: Settings): Promise<void> {
296 const dir = dataDir
297 if (dir !== null) await $.fs.write(`${dir}/settings.json`, JSON.stringify(next, null, 2)).catch(() => undefined)
298 await $.store.set(STORE_SETTINGS, next).catch(() => undefined)
299}
300
301// Takes new settings in: the drawings that read them redraw by themselves;
302// the visual-answers text in the system prompt is redrawn by hand.
303async function applySettings($: Engine, next: Settings): Promise<void> {
304 const before = await read($, settings)
305 if (!sameSettings(before, next)) await update($, settings, () => next)
306}
307
308async function changeSettings($: Engine, fn: (current: Settings) => Settings): Promise<Settings> {
309 const next = fn(await read($, settings))
310 await applySettings($, next)
311 await writeSettings($, next)
312
313 return next
314}
315
316// --- the party -----------------------------------------------------------
317
318async function change($: Engine, id: string, fn: (pal: Pal) => Pal | null): Promise<Change | null> {
319 let seen: Change | null = null
320 await update($, pals, list => {
321 seen = null
322 return list.map(pal => {
323 if (pal.id !== id) return pal
324 const after = fn(pal)
325 if (after === null) return pal
326 seen = { before: pal, after }
327 return after
328 })
329 })
330
331 return seen
332}
333
334async function addPal($: Engine, id: string, description: string, type: string, parentId?: string): Promise<void> {
335 const now = await $.clock.now()
336 let isAdded = false
337 await update($, pals, list => {
338 isAdded = false
339 if (list.some(pal => pal.id === id)) return list
340 isAdded = true
341 const live = list.filter(isActive)
342 const colours = live.map(pal => pal.color)
343 const color = BORDERS.findIndex((_, index) => !colours.includes(index))
344 const pal: Pal = {
345 id,
346 description: description || 'A quest',
347 type,
348 hero: assignHero(id, type, live.map(one => one.hero)),
349 color: color === -1 ? live.length % BORDERS.length : color,
350 parentId: parentId ?? null,
351 status: 'working',
352 activity: 'setting off',
353 tools: 0,
354 startedAt: now,
355 endedAt: null,
356 ask: null,
357 epitaph: '',
358 lastWords: '',
359 usage: null,
360 }
361 return trimmed([...list, pal])
362 })
363 if (isAdded) await chime($, 'set-out')
364 if (isAdded && !id.startsWith('demo-') && (await read($, pals)).filter(isActive).length >= FULL_PARTY) await unlock($, 'full-party')
365}
366
367// Ends a quest once; a later report of the same end only adds the last words
368// the first one lacked (the status poll often sees the end before the answer).
369async function finish($: Engine, id: string, status: PalStatus, lastWords: string): Promise<void> {
370 const now = await $.clock.now()
371 const seen = await change($, id, pal => {
372 if (isActive(pal)) {
373 return {
374 ...pal,
375 status,
376 activity: '',
377 ask: null,
378 endedAt: now,
379 epitaph: epitaph(pal.hero, ENDING[status], pal.id),
380 lastWords,
381 xp: xpFor(status, pal.tools),
382 }
383 }
384 return pal.lastWords === '' && lastWords !== '' ? { ...pal, lastWords } : null
385 })
386 if (seen === null || !isActive(seen.before)) return
387 const pal = seen.after
388 const hero = heroOf(pal.hero)
389 const isCritical = status === 'done' && pal.tools >= CRITICAL_STEPS && !pal.id.startsWith('demo-')
390 await recordFame($, pal.hero, record => withQuest(record, status, pal.xp ?? 0))
391 if (isCritical) await criticalRoll($, 20, hero.name)
392 else await chime($, status === 'done' ? 'done' : status === 'failed' ? 'error' : 'stopped')
393 if (status === 'failed') await criticalRoll($, 1, hero.name)
394 announce($, pal)
395 if (pal.id.startsWith('demo-')) return
396 if (status === 'done') await unlock($, 'first-blood')
397 if (status === 'failed') await unlock($, 'fallen')
398 if (status === 'done' && now - pal.startedAt >= MARATHON_MS) await unlock($, 'marathon')
399 if (status === 'done' && isNightOwlHour(now)) await unlock($, 'night-owl')
400}
401
402async function ask($: Engine, id: string, say: (pal: Pal) => string): Promise<boolean> {
403 const seen = await change($, id, pal => (isActive(pal) ? { ...pal, status: 'waiting', ask: say(pal) } : null))
404 if (seen === null) return false
405 if (seen.before.status !== 'waiting') {
406 await chime($, 'needs-you')
407 announce($, seen.after)
408 }
409
410 return true
411}
412
413async function resume($: Engine, id: string): Promise<void> {
414 await change($, id, pal => (pal.status === 'waiting' ? { ...pal, status: 'working', ask: null } : null))
415}
416
417// A turn's usage, priced, onto the pal and its hero's lifetime spend.
418async function addUsage($: Engine, id: string, usage: ModelUsage & { model: string }): Promise<void> {
419 const one = priced(usage, usage.model)
420 const seen = await change($, id, pal => ({ ...pal, usage: added(pal.usage, one) }))
421 if (seen) await recordFame($, seen.after.hero, record => withSpend(record, tokensOf(one), one.cost))
422}
423
424function announce($: Engine, pal: Pal): void {
425 const hero = heroOf(pal.hero)
426 const text = pal.status === 'waiting' ? (pal.ask ?? OUTCOME.waiting) : pal.epitaph
427 $.ui.toast(`${hero.name} the ${hero.title}: ${text}`, { timeoutMs: 6000 })
428 if (pal.status === 'waiting' || pal.status === 'failed') {
429 // Desktop notifications reach a terminal session only; anywhere else the
430 // toast and the strip carry it, so a missing or failing notify is fine.
431 try {
432 $.ui.notify(text, { title: `${hero.name} · ${pal.description}` }).catch(() => undefined)
433 } catch {
434 // no notification channel here
435 }
436 }
437}
438
439// Opens the panel once a session, at its start (unless switched off in
440// Settings), so the party is on show before any subagent sets out. A surface that cannot seat it yet (a narrow
441// terminal, a desktop still attaching) holds it open and seats it later.
442async function autoOpen($: Engine): Promise<void> {
443 if (await read($, hasAutoOpened)) return
444 await update($, hasAutoOpened, () => true)
445 if (!(await read($, settings)).panelAtStart) return
446 $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
447}
448
449// --- the overlord: the main thread ------------------------------------------
450
451// A main-thread turn this long ends with a chime, so a long job is heard
452// finishing; a quick reply stays quiet.
453const LONG_TURN_MS = 30_000
454
455async function changeOverlord($: Engine, fn: (lord: Overlord) => Overlord | null): Promise<{ before: Overlord; after: Overlord } | null> {
456 let seen: { before: Overlord; after: Overlord } | null = null
457 await update($, overlord, lord => {
458 const after = fn(lord)
459 seen = after === null ? null : { before: lord, after }
460 return after ?? lord
461 })
462
463 return seen
464}
465
466async function overlordStarts($: Engine): Promise<void> {
467 const now = await $.clock.now()
468 await changeOverlord($, lord => ({ ...lord, status: 'working', activity: 'thinking', tools: 0, startedAt: now, ask: null }))
469}
470
471async function overlordActs($: Engine, activity: string): Promise<void> {
472 await changeOverlord($, lord => (lord.status === 'idle' ? null : { ...lord, activity, tools: lord.tools + 1 }))
473}
474
475async function overlordResumes($: Engine): Promise<void> {
476 await changeOverlord($, lord => (lord.status === 'waiting' ? { ...lord, status: 'working', ask: null } : null))
477}
478
479async function overlordAsks($: Engine, text: string): Promise<void> {
480 const seen = await changeOverlord($, lord => ({ ...lord, status: 'waiting', ask: text }))
481 if (seen && seen.before.status !== 'waiting') await chime($, 'needs-you')
482}
483
484async function overlordRests($: Engine, lastWords: string, durationMs: number, usage?: ModelUsage & { model: string }): Promise<void> {
485 const one = usage === undefined ? null : priced(usage, usage.model)
486 await changeOverlord($, lord => ({
487 ...lord,
488 status: 'idle',
489 activity: '',
490 ask: null,
491 turns: lord.turns + 1,
492 lastWords: lastWords || lord.lastWords,
493 usage: one === null ? lord.usage : added(lord.usage, one),
494 }))
495 const now = await $.clock.now()
496 const name = (await read($, settings)).mainName
497 if (durationMs >= CRITICAL_TURN_MS) await criticalRoll($, 20, name)
498 else if (durationMs >= LONG_TURN_MS) await chime($, 'done')
499 if (durationMs >= MARATHON_MS) await unlock($, 'marathon')
500 if (durationMs >= LONG_TURN_MS && isNightOwlHour(now)) await unlock($, 'night-owl')
501}
502
503// --- the panel sections -------------------------------------------------------
504
505async function toggleSection($: Engine, key: Section): Promise<void> {
506 await changeSettings($, now => ({ ...now, hidden: now.hidden.includes(key) ? now.hidden.filter(one => one !== key) : [...now.hidden, key] }))
507}
508
509// --- the camp: background work ------------------------------------------------
510
511// This session's id and folder, for finding its background output files.
512let sessionId = ''
513let sessionCwd = ''
514// Where this session's background output lives; undefined until looked for.
515let tasksDir: string | null | undefined
516const outputSizes = new Map<string, number>()
517
518async function changeCamp($: Engine, fn: (tasks: CampTask[]) => CampTask[]): Promise<void> {
519 const before = await read($, camp)
520 const after = fn(before)
521 if (JSON.stringify(before) !== JSON.stringify(after)) await update($, camp, () => after)
522}
523
524// <tmp>/claude-<uid>/<cwd slug>/<session>/tasks: found once, by looking.
525async function findTasksDir($: Engine): Promise<string | null> {
526 if (tasksDir !== undefined) return tasksDir
527 if (sessionId === '' || sessionCwd === '') return null
528 for (const root of ['/private/tmp', '/tmp']) {
529 const entries = await $.fs.list(root).catch(() => [])
530 for (const entry of entries.filter(one => one.name.startsWith('claude-'))) {
531 const dir = `${root}/${entry.name}/${cwdSlug(sessionCwd)}/${sessionId}/tasks`
532 if (await $.fs.exists(dir).catch(() => false)) {
533 tasksDir = dir
534 return dir
535 }
536 }
537 }
538 return null
539}
540
541// Every few seconds: forget what finished long ago, and read the newest
542// output line of what is still running (only when its file has grown).
543async function campTick($: Engine): Promise<void> {
544 const now = await $.clock.now()
545 await changeCamp($, tasks => pruneCamp(tasks, now))
546 const running = (await read($, camp)).filter(one => one.status === 'running' && (one.kind === 'shell' || one.kind === 'monitor'))
547 for (const id of outputSizes.keys()) if (!running.some(one => one.id === id)) outputSizes.delete(id)
548 if (running.length === 0) return
549 const dir = await findTasksDir($)
550 if (dir === null) return
551 for (const one of running) {
552 const path = `${dir}/${one.id}.output`
553 const stat = await $.fs.stat(path).catch(() => null)
554 if (stat === null || stat.size === outputSizes.get(one.id)) continue
555 outputSizes.set(one.id, stat.size)
556 if (stat.size > MAX_PEEK_BYTES) continue
557 const output = await $.fs.read(path).then(String).catch(() => '')
558 const line = lastLine(output)
559 if (line !== '' && line !== one.lastLine) await changeCamp($, tasks => tasks.map(task => (task.id === one.id ? { ...task, lastLine: line } : task)))
560 }
561}
562
563// What a tool call started or ended in the background (from its result).
564async function noteCamp($: Engine, tool: string, input: unknown, ran: { result?: unknown }, agentId: string | undefined): Promise<void> {
565 if (ran.result === undefined) return
566 const now = await $.clock.now()
567 const made = campFromCall(tool, input, ran.result, now, agentId)
568 if (made !== null) await changeCamp($, tasks => applyChange(tasks, made, now))
569}
570
571// Every tool the panel calls runs only when the person's own rules already
572// allow it: anything else would go to a permission dialog or, in auto mode,
573// to the mode's checker, a model call. The mod never spends tokens.
574async function toolIsAllowed($: Engine, tool: string, input: Record<string, unknown>): Promise<boolean> {
575 const answer = await $.tool.check({ tool, input }).catch(() => null)
576 if (answer?.decision === 'allow') return true
577 $.ui.toast(`To keep the DM screen free, it only uses tools you allow. Add "${tool}" to permissions.allow in ~/.claude/settings.json.`, { timeoutMs: 10000 })
578 return false
579}
580
581async function stopCampTask($: Engine, one: CampTask): Promise<void> {
582 if (!(await toolIsAllowed($, 'TaskStop', { task_id: one.id }))) return
583 try {
584 const ran = await $.tool.call({ tool: 'TaskStop', task_id: one.id })
585 if ('deny' in ran && ran.deny !== undefined) throw new Error(ran.deny)
586 if ('isError' in ran && ran.isError === true) throw new Error('text' in ran ? String(ran.text) : 'it refused')
587 } catch (error) {
588 $.ui.toast(`Couldn't stop it: ${error instanceof Error ? error.message : 'it refused'}`)
589 return
590 }
591 const now = await $.clock.now()
592 await changeCamp($, tasks => applyChange(tasks, { end: one.id, status: 'stopped' }, now))
593}
594
595// --- stamina and the quest log ---------------------------------------------
596
597// The plan windows after a response; a toast when one crosses 80% or 95%.
598async function noteStamina($: Engine, limits: readonly Stamina[]): Promise<void> {
599 const now = await $.clock.now()
600 const before = await read($, stamina)
601 const next = shownStamina(limits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, ...(resetsAt ? { resetsAt } : {}) })))
602 if (JSON.stringify(before) === JSON.stringify(next)) return
603 await update($, stamina, () => next)
604 for (const limit of next) {
605 const was = before.find(one => one.kind === limit.kind)?.percentUsed
606 const at = crossing(was, limit.percentUsed)
607 if (at !== null) $.ui.toast(`${staminaLabel(limit.kind)} limit ${at}% used. ${staminaWords(limit, now)}.`)
608 }
609}
610
611// The main thread's turn in progress; module state, so a reload mid-turn
612// loses only that one receipt.
613let currentQuest: Quest | null = null
614let contextAtStart: number | null = null
615
616async function questBegins($: Engine, text: string): Promise<void> {
617 const lord = await read($, overlord)
618 currentQuest = newQuest(lord.turns + 1, firstLine(text, 80), await $.clock.now())
619 contextAtStart = lord.context?.tokens ?? null
620}
621
622async function questEnds($: Engine, durationMs: number): Promise<void> {
623 if (currentQuest === null) return
624 const lord = await read($, overlord)
625 const after = lord.context?.tokens ?? null
626 const done: Quest = {
627 ...currentQuest,
628 durationMs,
629 contextGained: after !== null && contextAtStart !== null ? after - contextAtStart : null,
630 }
631 currentQuest = null
632 await update($, quests, list => [...list, done].slice(-KEPT_QUESTS))
633}
634
635// --- compaction ------------------------------------------------------------
636
637const CONDENSING = 'Condensing memories'
638
639// Marks who is compacting: the Overlord when no agent is named, else that
640// subagent's hero. An id no hero carries (the engine's own forks) changes nothing.
641async function compacting($: Engine, agentId: string | undefined, since: number | null, result?: SessionCompactResult): Promise<void> {
642 // Done: the bar refills to what the compaction left, or waits for the next
643 // response to say, when the engine did not count it.
644 const refilled = (context: Context | null | undefined): Context | null | undefined => {
645 if (since !== null || !context || result === undefined || result.skip !== undefined) return context
646 return { ...context, tokens: typeof result.tokensAfter === 'number' ? result.tokensAfter : null }
647 }
648 if (agentId === undefined) {
649 await changeOverlord($, lord => ({ ...lord, compactingSince: since, context: refilled(lord.context) }))
650 return
651 }
652 await change($, agentId, pal =>
653 isActive(pal) || since === null ? { ...pal, compactingSince: since, context: refilled(pal.context) } : null,
654 )
655}
656
657// After every model response: how full that agent's window now is. The main
658// thread's figure is the engine's own (the status line's); a subagent's is
659// what its request was answered over, against its model's window.
660async function noteContext($: Engine, agentId: string | undefined, usage: ModelUsage, model: string): Promise<void> {
661 const answered = contextTokens(usage)
662 if (agentId === undefined) {
663 const live = await $.session.usage().catch(() => null)
664 const window = live?.context.window ?? windowFor(model, null)
665 const used = live?.context.tokens ?? answered
666 await changeOverlord($, lord => ({ ...lord, context: { tokens: used, window, model } }))
667 if (live) await noteStamina($, live.rateLimits)
668 return
669 }
670 const lord = await read($, overlord)
671 await change($, agentId, pal => (isActive(pal) ? { ...pal, context: { tokens: answered, window: windowFor(model, lord.context), model } } : null))
672}
673
674const isCompacting = (who: { compactingSince?: number | null }) => typeof who.compactingSince === 'number'
675
676// --- the demo (/dm demo) -----------------------------------------------------
677
678// A scripted show of the context bars and compaction, with made-up heroes
679// (ids "demo-…", kept out of the hall of fame) and the Overlord's own card.
680// The Overlord's real figure comes back with its next response.
681const DEMO = [
682 { id: 'demo-1', description: 'Demo: guard the eastern gate', type: 'general-purpose', activity: 'studying the gate plans', used: 0.35 },
683 { id: 'demo-2', description: 'Demo: scout the old mine', type: 'Explore', activity: 'scouting for tunnels', used: 0.7 },
684 { id: 'demo-3', description: 'Demo: inspect the bridge', type: 'code-reviewer', activity: 'testing every plank', used: 0.91 },
685] as const
686const DEMO_WINDOW = 200_000
687
688async function demo($: Engine, holdMs = 40_000): Promise<void> {
689 const now = await $.clock.now()
690 for (const one of DEMO) {
691 await addPal($, one.id, one.description, one.type)
692 await change($, one.id, pal => ({
693 ...pal,
694 activity: one.activity,
695 context: { tokens: Math.round(DEMO_WINDOW * one.used), window: DEMO_WINDOW, model: 'demo' },
696 }))
697 }
698 const lord = await read($, overlord)
699 const lordBefore = lord.context ?? null
700 const window = lordBefore?.window ?? 1_000_000
701 await changeOverlord($, current => ({ ...current, context: { tokens: Math.round(window * 0.82), window, model: lordBefore?.model ?? 'demo' } }))
702 // The camp: a server left burning, a test run that finishes, a wake-up.
703 const campDemo = (id: string, kind: CampTask['kind'], title: string, detail: string, lastLine: string, wakesAt?: number): CampTask => ({
704 id, kind, title, detail, status: 'running', startedAt: now - 90_000, endedAt: null, lastLine, ...(wakesAt === undefined ? {} : { wakesAt }),
705 })
706 await changeCamp($, tasks => [
707 ...tasks.filter(one => !one.id.startsWith('demo-')),
708 campDemo('demo-camp-server', 'shell', 'Demo: the forge fire', 'keep the forge lit', 'the forge burns hot'),
709 campDemo('demo-camp-tests', 'shell', 'Demo: count the treasury', 'count every coin', '412 coins counted · checking the last chest'),
710 campDemo('demo-camp-wake', 'wakeup', 'Demo: the night watch', 'wake the night watch', '', now + 18 * 60_000),
711 ])
712
713 // 4 s in, the nearly-full hero compacts; 8 s in, the Overlord does.
714 $.clock.after(4000, () => {
715 $.clock.now().then(at => compacting($, 'demo-3', at)).catch(() => undefined)
716 })
717 $.clock.after(8000, () => {
718 $.clock.now().then(at => compacting($, undefined, at)).catch(() => undefined)
719 })
720 $.clock.after(12_000, () => {
721 $.clock.now()
722 .then(at => changeCamp($, tasks => applyChange(tasks, { end: 'demo-camp-tests', status: 'done', summary: '418 coins' }, at)))
723 .catch(() => undefined)
724 })
725 $.clock.after(16_000, () => {
726 compacting($, 'demo-3', null, { messages: [], tokensAfter: 24_000 }).catch(() => undefined)
727 })
728 $.clock.after(22_000, () => {
729 compacting($, undefined, null, { messages: [], tokensAfter: Math.round(window * 0.09) }).catch(() => undefined)
730 })
731 $.clock.after(holdMs, () => {
732 changeCamp($, tasks => tasks.filter(one => !one.id.startsWith('demo-')))
733 .then(() => update($, pals, list => list.filter(pal => !pal.id.startsWith('demo-'))))
734 .then(() => changeOverlord($, current => ({ ...current, context: lordBefore })))
735 .catch(() => undefined)
736 })
737}
738
739// --- what the person does from the panel ----------------------------------
740
741async function stop($: Engine, pal: Pal): Promise<void> {
742 const hero = heroOf(pal.hero)
743 if (!(await toolIsAllowed($, 'TaskStop', { task_id: pal.id }))) return
744 try {
745 const ran = await $.tool.call({ tool: 'TaskStop', task_id: pal.id })
746 if ('deny' in ran && ran.deny !== undefined) throw new Error(ran.deny)
747 if ('isError' in ran && ran.isError === true) throw new Error('text' in ran ? String(ran.text) : 'it refused')
748 } catch (error) {
749 $.ui.toast(`Couldn't stop ${hero.name}: ${error instanceof Error ? error.message : 'it refused'}`)
750 return
751 }
752 await finish($, pal.id, 'stopped', '')
753}
754
755async function setSwitch($: Engine, key: Switch, isOn: boolean): Promise<void> {
756 await changeSettings($, now => ({ ...now, [key]: isOn }))
757}
758
759// --- the hall of fame ----------------------------------------------------
760
761async function recordFame($: Engine, hero: number, fn: (record: Fame | undefined) => Fame): Promise<void> {
762 // Several sessions share the hall: read what is stored now, apply this
763 // quest to it, and write that back, so no session's XP overwrites another's.
764 const key = String(hero)
765 const stored = await $.store.get(STORE_FAME).catch(() => undefined)
766 const base = stored === undefined ? await read($, fame) : readFame(stored)
767 const before = base[key]?.xp ?? 0
768 const next: Record<string, Fame> = { ...base, [key]: fn(base[key]) }
769 await $.store.set(STORE_FAME, next).catch(() => undefined)
770 await update($, fame, () => next)
771 const level = levelOf(next[String(hero)]?.xp ?? 0)
772 if (level > levelOf(before)) await levelUp($, hero, level)
773 if (HEROES.every((_, index) => (next[String(index)]?.xp ?? 0) > 0)) await unlock($, 'whole-guild')
774}
775
776// --- glory: level-ups, critical rolls and achievements ---------------------------
777
778async function levelUp($: Engine, hero: number, level: number): Promise<void> {
779 const now = await $.clock.now()
780 const who = heroOf(hero)
781 await update($, levelUps, all => ({ ...all, [String(hero)]: now + LEVEL_GLOW_MS }))
782 $.ui.toast(`${who.name} the ${who.title} reached level ${level}!`, { timeoutMs: 6000 })
783 lastAt = Number.NEGATIVE_INFINITY
784 await chime($, 'level-up')
785 if (level >= 3) await unlock($, 'veteran')
786 if (level >= 5) await unlock($, 'legend')
787}
788
789// A natural 20 for a long quest won, a natural 1 for a hero who falls: the
790// strip shows the die on that face for a few seconds.
791async function criticalRoll($: Engine, face: 20 | 1, who: string): Promise<void> {
792 const now = await $.clock.now()
793 await update($, critical, () => ({ face, who, until: now + CRITICAL_SHOW_MS }))
794 $.clock.after(CRITICAL_SHOW_MS, () => {
795 update($, critical, current => (current !== null && current.until <= now + CRITICAL_SHOW_MS ? null : current)).catch(() => undefined)
796 })
797 if (face === 20) {
798 $.ui.toast(`Natural 20! ${who} wins a long quest.`, { timeoutMs: 6000 })
799 lastAt = Number.NEGATIVE_INFINITY
800 await chime($, 'critical')
801 await unlock($, 'natural-20')
802 } else {
803 $.ui.toast(`Natural 1. ${who} falls.`, { timeoutMs: 6000 })
804 }
805}
806
807async function unlock($: Engine, id: AchievementId): Promise<void> {
808 if ((await read($, earned))[id] !== undefined) return
809 const now = await $.clock.now()
810 // Merged with what other sessions have earned since this one loaded.
811 const stored = readEarned(await $.store.get(STORE_EARNED).catch(() => undefined))
812 const wasEarned = stored[id] !== undefined
813 const next = { ...(await read($, earned)), ...stored, ...(wasEarned ? {} : { [id]: now }) }
814 await $.store.set(STORE_EARNED, next).catch(() => undefined)
815 await update($, earned, () => next)
816 if (wasEarned) return
817 const one = ACHIEVEMENTS.find(item => item.id === id)
818 if (one !== undefined) $.ui.toast(`Achievement unlocked: ${one.name}. ${one.how}`, { timeoutMs: 8000 })
819}
820
821async function countArchive($: Engine): Promise<void> {
822 const tally = (await $.store.get(STORE_TALLY).catch(() => undefined)) as { archived?: number } | undefined
823 const archived = (typeof tally?.archived === 'number' ? tally.archived : 0) + 1
824 await $.store.set(STORE_TALLY, { ...(tally ?? {}), archived }).catch(() => undefined)
825 if (archived >= ARCHIVES_FOR_SWEEP) await unlock($, 'clean-sweep')
826}
827
828async function forgetFame($: Engine): Promise<void> {
829 await update($, fame, () => ({}))
830 await $.store.set(STORE_FAME, {}).catch(() => undefined)
831}
832
833// --- the status poll -----------------------------------------------------
834
835// Catches agents no spawn hook saw (started before a reload, forks), ends the
836// ones the engine says are over, and ticks the clocks on the cards.
837async function sync($: Engine): Promise<void> {
838 const agents = await $.agent.list().catch(() => [])
839 const list = await read($, pals)
840 for (const agent of agents) {
841 const known = list.find(pal => pal.id === agent.id)
842 const isRunning = agent.status === 'running' || agent.status === 'pending'
843 if (known === undefined) {
844 if (isRunning) await addPal($, agent.id, agent.description, agent.type, agent.parentId)
845 continue
846 }
847 if (!isActive(known)) {
848 // A teammate goes idle between turns and wakes again on a message.
849 if (isRunning && agent.teammateId !== undefined) {
850 await change($, agent.id, pal => ({ ...pal, status: 'working', activity: 'back on the road', endedAt: null, ask: null }))
851 }
852 continue
853 }
854 if (agent.status === 'completed' || agent.status === 'idle') await finish($, agent.id, 'done', '')
855 if (agent.status === 'failed') await finish($, agent.id, 'failed', '')
856 if (agent.status === 'killed') await finish($, agent.id, 'stopped', '')
857 }
858 const now = await $.clock.now()
859 const lord = await read($, overlord)
860 if (lord.status !== 'idle' || list.some(pal => isActive(pal) || now - (pal.endedAt ?? 0) < RECENT_MS)) {
861 await update($, tick, n => n + 1)
862 }
863}
864
865// --- the realm: the shared look and every open session ----------------------
866
867// Who this session is on the map; set at session.start, kept by the module.
868let selfId = ''
869let selfName = ''
870// The mod's own folder (see dataDirFrom), and the home folder, set at session.start.
871let dataDir: string | null = null
872let homeDir: string | null = null
873
874// The desktop app's appearance from its config.json: light, dark, or
875// anything else (follow the system), read leniently.
876function appModeOf(text: string): string | undefined {
877 try {
878 const value = (JSON.parse(text) as { userThemeMode?: unknown }).userThemeMode
879 return typeof value === 'string' ? value.toLowerCase() : undefined
880 } catch {
881 return undefined
882 }
883}
884let selfLink: string | undefined
885let lastWritten: Kingdom | null = null
886let lastWriteAt = Number.NEGATIVE_INFINITY
887
888// Every few seconds: read the shared settings, check this session in, and read
889// every other session's check-in. All of it best effort: a failed read or
890// write leaves the panel as it was.
891async function realmTick($: Engine): Promise<void> {
892 const dir = dataDir
893 if (dir === null) return
894 const now = await $.clock.now()
895
896 const settingsText = await $.fs.read(`${dir}/settings.json`).then(String).catch(() => null)
897 if (settingsText !== null) await applySettings($, readSettings(settingsText))
898 const names = readNames(await $.fs.read(`${dir}/names.json`).then(String).catch(() => null))
899 if (JSON.stringify(names) !== JSON.stringify(await read($, sessionNames))) await update($, sessionNames, () => names)
900
901 if (selfId !== '') {
902 const working = (await read($, camp)).filter(isRunningWork).length
903 const mine = kingdomOf(selfId, selfName, await read($, overlord), (await read($, pals)).filter(isActive), now, working, selfLink)
904 if (!sameKingdom(lastWritten, mine) || now - lastWriteAt >= HEARTBEAT_MS) {
905 await $.fs.write(kingdomFile(dir, selfId), JSON.stringify(mine)).catch(() => undefined)
906 lastWritten = mine
907 lastWriteAt = now
908 }
909 }
910
911 // The app's list is asked for only while the map is on screen: one full
912 // read first, small asks after.
913 if ((await read($, view)) === 'map' && (await read($, isPaneOpen))) await refreshAppSessions($, lastFullAt === Number.NEGATIVE_INFINITY).catch(() => undefined)
914
915 const entries = await $.fs.list(`${dir}/sessions`).catch(() => [])
916 const files = freshFiles(entries, now, `${selfId}.json`)
917 const texts = await Promise.all(files.map(entry => $.fs.read(`${dir}/sessions/${entry.name}`).then(String).catch(() => '')))
918 const others = kingdomsFrom(texts, now)
919 const before = await read($, realm)
920 if (JSON.stringify(before) !== JSON.stringify(others)) await update($, realm, () => others)
921}
922
923// "Go there": hand the session's own app link to macOS, which switches the
924// desktop app to it, as clicking that link anywhere else would.
925async function goToKingdom($: Engine, k: Kingdom): Promise<void> {
926 if (!isSessionLink(k.link)) return
927 const ran = await $.process.run(['open', k.link]).catch(() => null)
928 if (ran === null || ran.exitCode !== 0) $.ui.toast(`Couldn't switch to ${k.name}. Open it from the sidebar.`)
929}
930
931// The app's own list: every session open in its sidebar, running or not.
932// A long history (hundreds of sessions) is too big for one tool answer: the
933// app saves it to a file and says where, so the full list is read from that
934// file. It is asked for when the person opens the map or one of its folded
935// lists, at most every five minutes; in between, a small ask of the 30 most
936// recent keeps names and activity fresh every 30 s while the map shows.
937let lastListAt = Number.NEGATIVE_INFINITY
938let lastFullAt = Number.NEGATIVE_INFINITY
939const FULL_LIST_MS = 5 * 60_000
940const SMALL_LIST = 30
941const FULL_LIST = 400
942
943async function listAnswer($: Engine, args: Record<string, unknown>): Promise<unknown> {
944 const ran = await $.tool.call({ tool: LIST_SESSIONS, ...args } as never).catch(() => null)
945 if (ran === null || ('deny' in ran && ran.deny !== undefined)) return null
946 const answer = (ran as { result?: unknown }).result ?? (ran as { text?: unknown }).text
947 // Only the engine's own "too big" notice, as the whole answer, naming a
948 // file in its own tool-results folder: never a path from a session title.
949 const text = Array.isArray(answer) && answer.length === 1 ? (answer[0] as { text?: unknown }).text : answer
950 const notice = typeof text === 'string' ? /^Error: result \([^)]*\) exceeds maximum allowed tokens\. Output has been saved to (\/[^\s]+\.txt)\./.exec(text) : null
951 if (notice === null) return answer
952 const saved = notice[1] ?? ''
953 const config = dataDir === null ? null : dataDir.replace(/\/dungeon-master$/, '')
954 if (config === null || !saved.startsWith(`${config}/projects/`) || !saved.includes('/tool-results/') || saved.includes('/../')) return null
955 const stat = await $.fs.stat(saved).catch(() => null)
956 if (stat === null || stat.kind !== 'file' || stat.size > 4 * 1024 * 1024) return null
957
958 return $.fs.read(saved).then(String).catch(() => null)
959}
960
961// Whether the app's session list runs without asking anyone. In auto mode an
962// "ask" goes to the mode's checker, a model call: the map never spends that
963// in the background, so then it asks only when the person opens the map.
964let listDecision: 'allow' | 'ask' | 'deny' | null = null
965
966async function listIsFree($: Engine): Promise<boolean> {
967 if (listDecision === null) {
968 const answer = await $.tool.check({ tool: LIST_SESSIONS, input: { limit: SMALL_LIST } }).catch(() => null)
969 listDecision = answer?.decision ?? 'ask'
970 }
971 return listDecision === 'allow'
972}
973
974async function refreshAppSessions($: Engine, full = false): Promise<void> {
975 const now = await $.clock.now()
976 const isFull = full && now - lastFullAt >= FULL_LIST_MS
977 if (!isFull && now - lastListAt < APP_LIST_MS) return
978 const isFree = await listIsFree($)
979 if (listDecision === 'deny' || (!isFree && !isFull)) return
980 lastListAt = now
981 if (isFull) {
982 lastFullAt = now
983 const answer = await listAnswer($, { limit: FULL_LIST, include_archived: true })
984 const open = answer === null ? [] : appSessionsFrom(answer)
985 const archived = answer === null ? [] : appSessionsFrom(answer, 'archived')
986 lastListReport = `full list: ${open.length} open, ${archived.length} archived${answer === null ? ' (no answer)' : ''}`
987 if (open.length > 0 && JSON.stringify(open) !== JSON.stringify(await read($, appSessions))) await update($, appSessions, () => open)
988 if (answer !== null && JSON.stringify(archived) !== JSON.stringify(await read($, archivedSessions))) await update($, archivedSessions, () => archived)
989 return
990 }
991 // The small ask: fresh names and activity for the most recent, merged in.
992 const answer = await listAnswer($, { limit: SMALL_LIST })
993 const recent = answer === null ? [] : appSessionsFrom(answer)
994 lastListReport = `${lastListReport.split(';')[0]}; recent ${recent.length}`
995 if (recent.length === 0) return
996 await update($, appSessions, list => {
997 const fresh = new Map(recent.map(one => [one.id, one]))
998 const kept = list.filter(one => !fresh.has(one.id))
999 return [...recent, ...kept]
1000 })
1001}
1002
1003// Close = the app's archive: the session's process stops and it moves to the
1004// sidebar's Archived list, from where it can be reopened. One press, no
1005// confirm (the person asked: it can always come back). The last refusal is
1006// kept for /dm status.
1007const CLOSE_TIMEOUT_MS = 20_000
1008// The last archive or restore the app refused, for /dm status (no titles).
1009let lastRefusal = ''
1010
1011function noteRefusal(what: string, answer: { ok: boolean; text: string }): void {
1012 if (!answer.ok) lastRefusal = `${what}: ${answer.text.slice(0, 200)}`
1013}
1014
1015// What the app said, as text: an error, a refusal ("not archived…"), or done.
1016function archiveAnswer(ran: unknown): { ok: boolean; text: string } {
1017 const value = ran as { deny?: string; isError?: boolean; text?: unknown; result?: unknown }
1018 if (value.deny !== undefined) return { ok: false, text: value.deny }
1019 const text = JSON.stringify(value.result ?? value.text ?? '').slice(0, 300)
1020 if (/keeping it for the user/i.test(text)) {
1021 return { ok: false, text: 'the app keeps this one (it is pinned, or open in a window). Archive it from the sidebar.' }
1022 }
1023 if (/auto mode classifier/i.test(text)) {
1024 return { ok: false, text: 'auto mode cannot approve a click. Press ⓘ Archive not working? on the map for the setting it needs.' }
1025 }
1026 if (value.isError === true) return { ok: false, text }
1027 // The app's own success line first, so a title that happens to contain
1028 // "pinned" or "denied" never reads as a refusal.
1029 if (/^\W*(Archived session|Restored|Unarchived)/i.test(text.replace(/^\[\{"type":"text","text":/, ''))) return { ok: true, text }
1030 if (/not archived|not restored|still working|open on screen|refus|denied|declined|^"?Error/i.test(text)) return { ok: false, text }
1031
1032 return { ok: true, text }
1033}
1034
1035async function archiveOne($: Engine, id: string, title: string): Promise<{ ok: boolean; text: string }> {
1036 if (id.startsWith('demo-')) return { ok: false, text: 'it is a showcase session, made up for pictures' }
1037 if (!(await toolIsAllowed($, ARCHIVE_SESSION, { session_id: id }))) return { ok: false, text: 'archiving is not in your allow list yet' }
1038 let timer: { cancel: () => void } | undefined
1039 const timeout = new Promise<{ ok: boolean; text: string }>(resolve => {
1040 timer = $.clock.after(CLOSE_TIMEOUT_MS, () => resolve({ ok: false, text: `no answer from the app after ${CLOSE_TIMEOUT_MS / 1000}s (waiting for an approval?)` }))
1041 })
1042 const call = $.tool
1043 .call({ tool: ARCHIVE_SESSION, session_id: id, reason: 'Closed from the Dungeon Master map' } as never)
1044 .then(archiveAnswer)
1045 .catch((error: unknown) => ({ ok: false, text: error instanceof Error ? error.message : String(error) }))
1046 const answer = await Promise.race([call, timeout])
1047 timer?.cancel()
1048 noteRefusal('archive', answer)
1049 if (!answer.ok && answer.text.startsWith('the app keeps this one')) await update($, keptByApp, list => (list.includes(id) ? list : [...list, id]))
1050 if (answer.ok) {
1051 const was = (await read($, appSessions)).find(one => one.id === id)
1052 await update($, appSessions, list => list.filter(one => one.id !== id))
1053 await update($, realm, list => list.filter(k => hostIdOf(k.link) !== id))
1054 await countArchive($).catch(() => undefined)
1055 await update($, archivedSessions, list => [{ id, title: was?.title ?? title, lastActivityAt: was?.lastActivityAt ?? 0, isPinned: false, isArchived: true }, ...list.filter(one => one.id !== id)])
1056 }
1057
1058 return answer
1059}
1060
1061async function closeSession($: Engine, id: string, title: string): Promise<void> {
1062 $.ui.toast(`Archiving ${title}…`)
1063 const answer = await archiveOne($, id, title)
1064 $.ui.toast(answer.ok ? `Archived ${title}. It is under Archived in the sidebar.` : `Couldn't archive ${title}: ${answer.text}`, { timeoutMs: 8000 })
1065}
1066
1067// A session's own name: written to the shared file (read fresh first, so two
1068// sessions renaming at once both land); empty puts the app's title back.
1069async function nameSession($: Engine, id: string, text: string): Promise<void> {
1070 const dir = dataDir
1071 const name = cleanSessionName(text)
1072 const file = dir === null ? null : `${dir}/names.json`
1073 const now = file === null ? await read($, sessionNames) : readNames(await $.fs.read(file).then(String).catch(() => null))
1074 const next = { ...now }
1075 if (name === '') delete next[id]
1076 else next[id] = name
1077 if (file !== null) await $.fs.write(file, JSON.stringify(next, null, 2)).catch(() => undefined)
1078 await update($, sessionNames, () => next)
1079 await update($, renaming, () => null)
1080}
1081
1082// Restore: the app's unarchive, the same way round; the session comes back
1083// to the sidebar and onto the map.
1084async function restoreSession($: Engine, one: AppSession): Promise<void> {
1085 if (one.id.startsWith('demo-')) return
1086 if (!(await toolIsAllowed($, UNARCHIVE_SESSION, { session_id: one.id }))) return
1087 $.ui.toast(`Restoring ${one.title}…`)
1088 const answer = await $.tool
1089 .call({ tool: UNARCHIVE_SESSION, session_id: one.id } as never)
1090 .then(archiveAnswer)
1091 .catch((error: unknown) => ({ ok: false, text: error instanceof Error ? error.message : String(error) }))
1092 noteRefusal('restore', answer)
1093 if (answer.ok) {
1094 await update($, archivedSessions, list => list.filter(s => s.id !== one.id))
1095 await update($, appSessions, list => [...list, { ...one, isArchived: false }])
1096 }
1097 $.ui.toast(answer.ok ? `Restored ${one.title}. It is back in the sidebar.` : `Couldn't restore ${one.title}: ${answer.text}`, { timeoutMs: 8000 })
1098}
1099
1100async function askFirst($: Engine, what: string): Promise<void> {
1101 await update($, confirming, () => what)
1102 $.clock.after(CONFIRM_MS, () => {
1103 update($, confirming, current => (current === what ? null : current)).catch(() => undefined)
1104 })
1105}
1106
1107// Close all: the same archive, one after another, pinned ones skipped.
1108async function closeAll($: Engine, band: 'idle' | 'asleep', targets: ReadonlyArray<{ id: string; title: string }>): Promise<void> {
1109 await update($, confirming, () => null)
1110 $.ui.toast(`Archiving ${targets.length} ${band} ${targets.length === 1 ? 'session' : 'sessions'}…`)
1111 let closed = 0
1112 const failed: string[] = []
1113 for (const one of targets) {
1114 const answer = await archiveOne($, one.id, one.title)
1115 if (answer.ok) closed += 1
1116 else failed.push(`${one.title} (${answer.text.slice(0, 80)})`)
1117 }
1118 $.ui.toast(
1119 failed.length === 0
1120 ? `Archived ${closed} ${band} ${closed === 1 ? 'session' : 'sessions'}. They are under Archived in the sidebar.`
1121 : `Archived ${closed}; ${failed.length} would not archive: ${failed.slice(0, 2).join('; ')}${failed.length > 2 ? '…' : ''}`,
1122 { timeoutMs: 10_000 },
1123 )
1124}
1125
1126// Closing: off the map at once rather than after the stale window.
1127async function leaveRealm($: Engine): Promise<void> {
1128 const dir = dataDir
1129 if (dir === null || lastWritten === null) return
1130 await $.fs.write(kingdomFile(dir, selfId), JSON.stringify({ ...lastWritten, hasEnded: true })).catch(() => undefined)
1131}
1132
1133// A moving drawing: fn draws frame n.
1134//
1135// At rest it is one self-animating picture, and now (the clock) keeps its
1136// loop going across the odd redraw (animatedSvg says why). At work it is the
1137// still frame of step (the frame timer's count): the strip then redraws on
1138// every tool step, the desktop rebuilds each picture on every redraw, and a
1139// rebuilt loop always started a little late, so bursts of tool steps slowed
1140// the walk and quiet spells sped it up again. One frame per timer tick is an
1141// even pace however often the strip redraws; a redraw between ticks draws the
1142// same frame again, which shows as nothing.
1143const looped = (draw: (frame: number) => string, now: number, step?: number) =>
1144 step === undefined ? animatedSvg(LOOP_FRAMES.map(draw), FRAME_MS, now) : draw(step % LOOP_FRAMES.length)
1145
1146// Whether anyone is at work, so the frame timer runs and the moving drawings step on it.
1147const isMoving = (list: readonly Pal[], lord: Overlord) => list.some(isActive) || lord.status !== 'idle' || isCompacting(lord)
1148
1149// --- the strip above the prompt -------------------------------------------
1150
1151async function drawBand($: Engine, e: RenderInput<'AbovePrompt'>): Promise<RenderElement> {
1152 const allPals = await read($, pals)
1153 await read($, tick)
1154 const chosen = await read($, settings)
1155 const isOpen = await read($, isPaneOpen)
1156 const crit = await read($, critical)
1157 // Narrower than the breakpoint, the button sits under the words.
1158 const isNarrowStrip = (e.props.bodyColumns ?? 120) < NARROW_STRIP_COLUMNS
1159 const now = await $.clock.now()
1160 const isShowcase = (await read($, showcaseUntil)) > now
1161 const list = isShowcase ? allPals.filter(pal => pal.id.startsWith('demo-')) : allPals
1162 const lord = isShowcase ? showcaseLord(await read($, overlord)) : await read($, overlord)
1163 const frame = Math.floor(now / FRAME_MS)
1164 const isTerminal = e.surface === 'terminal'
1165 const step = isMoving(allPals, await read($, overlord)) ? await read($, frameTick) : undefined
1166 const live = list.filter(isActive)
1167 const recent = list.filter(pal => !isActive(pal) && now - (pal.endedAt ?? 0) < RECENT_MS)
1168 const look = await lookNow($, chosen.theme)
1169 const hiddenInHud = chosen.hidden
1170 const limits = hiddenInHud.includes('stamina') ? [] : await read($, stamina)
1171 const inCamp = hiddenInHud.includes('camp') ? 0 : (await read($, camp)).filter(one => !isShowcase || one.id.startsWith('demo-')).filter(isRunningWork).length
1172 const { Box, Text, Button } = $.ui.resolve(e)
1173 // Always drawn, so the way into the panel is there from the first prompt:
1174 // the party when one is out, else the Overlord (the main thread) alone.
1175 const mainName = chosen.mainName
1176 const condensing = [...(isCompacting(lord) ? [mainName] : []), ...live.filter(isCompacting).map(pal => heroOf(pal.hero).name)]
1177 const left = leftOf(lord.context)
1178 // Your character leads, saying what it is doing, with the party after it:
1179 // a helper setting out never hides the main thread's own step.
1180 const mainWords = `${mainName} · ${
1181 lord.status === 'working'
1182 ? `${lord.activity || 'thinking'} · ${elapsed(now - lord.startedAt)}`
1183 : lord.status === 'waiting'
1184 ? 'needs you'
1185 : OVERLORD_VOICE.idle
1186 }${isTerminal && left !== null ? ` · ${Math.round(left * 100)}% context left` : ''}`
1187 const partyWords =
1188 live.length > 0
1189 ? summary(list)
1190 : recent.length > 0
1191 ? `${plural(recent.length, 'quest', 'quests')} just ended: ${recent.map(pal => heroOf(pal.hero).name).join(', ')}`
1192 : ''
1193 const words = condensing.length > 0 ? `${CONDENSING}: ${condensing.join(', ')}` : [mainWords, partyWords].filter(Boolean).join(' · ')
1194 const campWords = inCamp > 0 ? ` · ${inCamp} in camp` : ''
1195
1196 // The HUD: a dungeon-stone panel, the Overlord striding in its corner and
1197 // its context bar under the words (the terminal keeps the words alone).
1198 const svg = (source: string, alt: string, size: Size) => {
1199 if (e.surface === 'terminal') return null
1200 const { Svg } = $.ui.resolve(e)hooks/activity.ts 81 lines1// What a pal is doing, in a few plain words, from the tool call it made.
2
3const clip = (text: string, max: number) =>
4 text.length > max ? `${text.slice(0, max - 1)}…` : text
5
6const baseName = (path: unknown) =>
7 typeof path === 'string' ? (path.split('/').pop() ?? path) : 'a file'
8
9const str = (value: unknown) => (typeof value === 'string' ? value : '')
10
11export function describeCall(tool: string, input: Record<string, unknown>): string {
12 switch (tool) {
13 case 'Read':
14 return `reading ${baseName(input.file_path)}`
15 case 'Edit':
16 case 'MultiEdit':
17 return `editing ${baseName(input.file_path)}`
18 case 'Write':
19 return `writing ${baseName(input.file_path)}`
20 case 'NotebookEdit':
21 return `editing ${baseName(input.notebook_path)}`
22 case 'Bash': {
23 // A command's own description already reads as words ("Pause five seconds…").
24 const said = str(input.description)
25 if (said) return clip(said.charAt(0).toLowerCase() + said.slice(1), 64)
26 return `running ${clip(str(input.command).split('\n')[0] ?? '', 48)}`
27 }
28 case 'Grep':
29 return `searching for “${clip(str(input.pattern), 32)}”`
30 case 'Glob':
31 return `looking for ${clip(str(input.pattern), 32)}`
32 case 'WebFetch': {
33 const host = /^https?:\/\/([^/]+)/.exec(str(input.url))?.[1]
34 return `reading ${host ?? 'a web page'}`
35 }
36 case 'WebSearch':
37 return `searching the web for “${clip(str(input.query), 32)}”`
38 case 'Agent':
39 case 'Task':
40 return 'briefing a helper'
41 case 'TodoWrite':
42 case 'TaskCreate':
43 case 'TaskUpdate':
44 return 'updating its to-do list'
45 case 'Skill':
46 return `using the ${str(input.skill) || 'a'} skill`
47 case 'ToolSearch':
48 return 'looking through its toolbox'
49 }
50 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
51 if (mcp) return `using ${(mcp[2] ?? '').replace(/_/g, ' ')} (${mcp[1] ?? ''})`
52
53 return `using ${tool}`
54}
55
56export function elapsed(ms: number): string {
57 const seconds = Math.max(0, Math.floor(ms / 1000))
58 if (seconds < 60) return `${seconds}s`
59 const minutes = Math.floor(seconds / 60)
60 if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
61
62 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
63}
64
65// The first sentence-like line of an answer: headings are skipped while any
66// plain line follows them, so "## Findings" never stands in for the finding.
67export function firstLine(text: string, max = 120): string {
68 const lines = text.split('\n').map(one => one.trim()).filter(one => one.length > 0)
69 const line = lines.find(one => !one.startsWith('#')) ?? lines[0]
70
71 return line === undefined ? '' : clip(line.replace(/^[#>*\-\s]+/, '').trim(), max)
72}
73
74// Long unbroken words (paths, ids, commands) get invisible break points after
75// / . _ - : and every 20 characters, so they wrap instead of running off the card.
76export function softBreak(text: string): string {
77 return text.replace(/\S{21,}/g, word =>
78 word.replace(/([/._\-:])/g, '$1\u200B').replace(/([^\u200B]{20})(?=[^\u200B])/g, '$1\u200B'),
79 )
80}
81hooks/context.ts 94 lines1// The context bar: how much of its context window each agent has left, drawn
2// as a pixel health bar that drains as the conversation grows. Compacting
3// turns it violet with a shimmer, and the bar refills to what is left after.
4
5import type { ModelUsage } from 'claude-code'
6
7import type { Context } from '../types'
8import { tokens } from './cost'
9
10// A subagent on a model other than the main thread's: the usual window.
11export const DEFAULT_WINDOW = 200_000
12
13// What a request was answered over: uncached, cache-written and cache-read.
14export const contextTokens = (usage: ModelUsage) =>
15 usage.input_tokens + usage.cache_read_input_tokens + (usage.cache_creation_input_tokens ?? 0)
16
17// The window a model works in: a 1M model says so in its id; the main thread's
18// own window (which the engine reports) holds for any agent on the same model.
19export function windowFor(model: string, main: Context | null | undefined): number {
20 if (/\[1m\]|-1m\b/i.test(model)) return 1_000_000
21 if (main && main.model === model) return main.window
22
23 return DEFAULT_WINDOW
24}
25
26// The share of the window still free, 0 to 1; null before the first response.
27export function leftOf(context: Context | null | undefined): number | null {
28 if (!context || context.tokens === null || context.window <= 0) return null
29
30 return Math.min(1, Math.max(0, 1 - context.tokens / context.window))
31}
32
33export function contextWords(context: Context | null | undefined, isCompacting: boolean): string {
34 if (isCompacting) return 'Context: condensing'
35 const left = leftOf(context)
36 if (left === null || !context || context.tokens === null) return 'Context: fresh'
37
38 return `Context ${Math.round(left * 100)}% left · ${tokens(context.tokens)} of ${tokens(context.window)}`
39}
40
41// Healthy, wary, low: green, amber, crimson, by how much is left.
42const FILLS = { high: '#22C55E', mid: '#F59E0B', low: '#DC2626', condensing: '#8B5CF6' } as const
43
44export function fillOf(left: number | null, isCompacting: boolean): string {
45 if (isCompacting) return FILLS.condensing
46 if (left === null || left > 0.5) return FILLS.high
47
48 return left > 0.25 ? FILLS.mid : FILLS.low
49}
50
51// The art is 60 by 5 pixels; drawn at 2x on cards, 1x for followers.
52export const BAR_SIZE = { width: 120, height: 10 } as const
53export const MINI_BAR_SIZE = { width: 60, height: 5 } as const
54const W = 60
55const H = 5
56
57type Size = { readonly width: number; readonly height: number }
58
59export function contextBarSvg(context: Context | null | undefined, frame: number, isCompacting: boolean, size: Size = BAR_SIZE): string {
60 const left = leftOf(context)
61 const inner = W - 2
62 const filled = Math.round(inner * (left ?? 1))
63 const fill = fillOf(left, isCompacting)
64 // Nearly empty: the bar pulses, as a hero's does on its last hit points.
65 const opacity = !isCompacting && left !== null && left < 0.15 && frame % 4 < 2 ? 0.6 : 1
66 let ticks = ''
67 for (let step = 1; step < 10; step += 1) {
68 const x = 1 + Math.round((inner * step) / 10)
69 ticks += `<rect x="${x}" y="1" width="0.5" height="${H - 2}" fill="#000" opacity="0.25"/>`
70 }
71 const shimmer = isCompacting && filled > 0 ? `<rect x="${1 + ((frame * 4) % Math.max(filled, 1))}" y="1" width="3" height="${H - 2}" fill="#EDE9FE" opacity="0.7"/>` : ''
72
73 return (
74 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${size.width}" height="${size.height}" shape-rendering="crispEdges">` +
75 `<rect x="0" y="0" width="${W}" height="${H}" fill="#1F2937"/>` +
76 `<rect x="1" y="1" width="${inner}" height="${H - 2}" fill="#4B5563"/>` +
77 (filled > 0
78 ? `<rect x="1" y="1" width="${filled}" height="${H - 2}" fill="${fill}" opacity="${opacity}"/>` +
79 `<rect x="1" y="1" width="${filled}" height="1" fill="#FFFFFF" opacity="0.35"/>`
80 : '') +
81 shimmer +
82 ticks +
83 `</svg>`
84 )
85}
86
87// The terminal's bar: ten blocks, full ones for what is left.
88export function contextBarText(context: Context | null | undefined, isCompacting: boolean): string {
89 const left = leftOf(context) ?? 1
90 const full = Math.round(left * 10)
91
92 return `${isCompacting ? 'Condensing' : 'Context'} ${'▰'.repeat(full)}${'▱'.repeat(10 - full)}`
93}
94hooks/cost.ts 85 lines1// What a subagent's tokens would cost at Anthropic's API list prices, in US
2// dollars per million tokens (cached 2026-10-06). An estimate: a Claude
3// subscription bills differently, and a model not listed gets no price.
4
5import type { ModelUsage } from 'claude-code'
6
7import type { Usage } from '../types'
8
9type Rate = { input: number; output: number; cacheRead: number }
10
11// Most specific first: the first pattern the model id matches prices it.
12const RATES: ReadonlyArray<readonly [RegExp, Rate]> = [
13 [/fable-5-1|mythos-5-1/, { input: 10, output: 50, cacheRead: 0.25 }],
14 [/fable-5|mythos-5/, { input: 10, output: 50, cacheRead: 1 }],
15 [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
16 [/opus-(5|4-[5-9])/, { input: 5, output: 25, cacheRead: 0.5 }],
17 [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2 }],
18 [/sonnet-4/, { input: 3, output: 15, cacheRead: 0.3 }],
19 [/haiku-5-5/, { input: 0.1, output: 0.5, cacheRead: 0.01 }],
20 [/haiku-4/, { input: 1, output: 5, cacheRead: 0.1 }],
21]
22
23// A five-minute cache write costs a quarter more than plain input.
24const CACHE_WRITE = 1.25
25
26export function rateOf(model: string): Rate | null {
27 return RATES.find(([pattern]) => pattern.test(model))?.[1] ?? null
28}
29
30export function priced(usage: ModelUsage, model: string): Usage {
31 const rate = rateOf(model)
32 const cost =
33 rate === null
34 ? null
35 : (usage.input_tokens * rate.input +
36 usage.cache_creation_input_tokens * rate.input * CACHE_WRITE +
37 usage.cache_read_input_tokens * rate.cacheRead +
38 usage.output_tokens * rate.output) /
39 1_000_000
40
41 return {
42 input: usage.input_tokens,
43 output: usage.output_tokens,
44 cacheRead: usage.cache_read_input_tokens,
45 cacheWrite: usage.cache_creation_input_tokens,
46 cost,
47 model,
48 }
49}
50
51export function added(a: Usage | null | undefined, b: Usage): Usage {
52 if (a === null || a === undefined) return b
53 return {
54 input: a.input + b.input,
55 output: a.output + b.output,
56 cacheRead: a.cacheRead + b.cacheRead,
57 cacheWrite: a.cacheWrite + b.cacheWrite,
58 cost: a.cost === null || b.cost === null ? (a.cost ?? b.cost) : a.cost + b.cost,
59 model: b.model,
60 }
61}
62
63export const tokensOf = (usage: Usage) => usage.input + usage.output + usage.cacheRead + usage.cacheWrite
64
65export function tokens(n: number): string {
66 if (n < 1000) return `${n}`
67 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
68
69 return `${(n / 1_000_000).toFixed(1)}M`
70}
71
72export function dollars(cost: number | null): string {
73 if (cost === null) return ''
74 if (cost < 0.01) return '≈ <$0.01'
75
76 return `≈ $${cost.toFixed(2)}`
77}
78
79export function spend(usage: Usage | null | undefined): string {
80 if (usage === null || usage === undefined) return ''
81 const price = dollars(usage.cost)
82
83 return `${tokens(tokensOf(usage))} tokens${price ? ` ${price}` : ''}`
84}
85hooks/fame.ts 75 lines1// The hall of fame: each hero's lifetime XP and record across sessions.
2// XP comes from quests: a finished one pays most, more for a longer one.
3
4import type { Fame, PalStatus } from '../types'
5
6export const EMPTY_FAME: Fame = { xp: 0, quests: 0, fell: 0, stopped: 0, tokens: 0, cost: 0 }
7
8export function xpFor(status: PalStatus, steps: number): number {
9 if (status === 'done') return 10 + Math.min(20, Math.floor(steps / 2))
10 if (status === 'stopped') return 2
11 if (status === 'failed') return 3
12
13 return 0
14}
15
16// Level n is reached at 25 * (n - 1)^2 XP: 25, 100, 225, 400 ...
17export const levelOf = (xp: number) => Math.floor(Math.sqrt(Math.max(0, xp) / 25)) + 1
18export const xpForLevel = (level: number) => 25 * (level - 1) ** 2
19
20// Ten pips of progress from this level to the next.
21export function progressBar(xp: number): string {
22 const level = levelOf(xp)
23 const from = xpForLevel(level)
24 const to = xpForLevel(level + 1)
25 const filled = Math.round(((xp - from) / (to - from)) * 10)
26
27 return '▰'.repeat(filled) + '▱'.repeat(10 - filled)
28}
29
30// The XP bar: progress from this level to the next as a pixel bar, gold on
31// a dark trough, ten notches.
32export function xpBarSvg(xp: number, width: number, gold: string, trough: string): string {
33 const level = levelOf(xp)
34 const from = xpForLevel(level)
35 const to = xpForLevel(level + 1)
36 const inner = width - 2
37 const filled = Math.round(((xp - from) / (to - from)) * inner)
38 const notches = Array.from({ length: 9 }, (_, i) => `<rect x="${1 + Math.round(((i + 1) * inner) / 10)}" y="1" width="1" height="4" fill="#000" opacity="0.25"/>`).join('')
39 return (
40 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 6" width="${width}" height="6" shape-rendering="crispEdges">` +
41 `<rect x="0" y="0" width="${width}" height="6" fill="#111"/><rect x="1" y="1" width="${inner}" height="4" fill="${trough}"/>` +
42 `<rect x="1" y="1" width="${filled}" height="4" fill="${gold}"/><rect x="1" y="1" width="${filled}" height="1" fill="#fff" opacity="0.35"/>${notches}</svg>`
43 )
44}
45
46export function withQuest(record: Fame | undefined, status: PalStatus, xp: number): Fame {
47 const was = record ?? EMPTY_FAME
48 return {
49 ...was,
50 xp: was.xp + xp,
51 quests: was.quests + (status === 'done' ? 1 : 0),
52 fell: was.fell + (status === 'failed' ? 1 : 0),
53 stopped: was.stopped + (status === 'stopped' ? 1 : 0),
54 }
55}
56
57export function withSpend(record: Fame | undefined, tokens: number, cost: number | null): Fame {
58 const was = record ?? EMPTY_FAME
59 return { ...was, tokens: was.tokens + tokens, cost: was.cost + (cost ?? 0) }
60}
61
62// A stored value is used only if it has the shape; anything else starts afresh.
63export function readFame(value: unknown): Record<string, Fame> {
64 if (typeof value !== 'object' || value === null) return {}
65 const out: Record<string, Fame> = {}
66 for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
67 if (typeof entry !== 'object' || entry === null) continue
68 const e = entry as Record<string, unknown>
69 const num = (k: string) => (typeof e[k] === 'number' && Number.isFinite(e[k]) ? (e[k] as number) : 0)
70 out[key] = { xp: num('xp'), quests: num('quests'), fell: num('fell'), stopped: num('stopped'), tokens: num('tokens'), cost: num('cost') }
71 }
72
73 return out
74}
75hooks/graves.ts 120 lines1// Pixel gravestones for the graveyard, drawn for this mod in 0x72's own wall
2// colours and outline: a gilded cross and flowers for a quest won, a crack and
3// the pack's skull for a hero who fell, moss and a drifting "z" for one you
4// stopped.
5
6import type { Ending } from './heroes'
7import { SKULL } from './sprites'
8
9// The stone ramp and outline are 0x72's own wall colours (wall_mid, column),
10// so the stones sit in the same world as the heroes.
11const PALETTE: Record<string, string> = {
12 '#': '#222222', // outline, as on every sprite in the pack
13 h: '#D3BFA9', // lit edge
14 l: '#AA8D7A', // stone face
15 m: '#775C55', // weathered stone
16 d: '#483B3A', // shadowed side
17 k: '#21141B', // engraving and cracks
18 s: '#483B3A', // the dirt mound
19 S: '#2E2424', // darker dirt
20 y: '#F2C14E', // gold
21 Y: '#B8862B', // gold, shaded
22 v: '#6E8B3D', // moss
23 V: '#94B05A', // moss, lit
24 p: '#E8758A', // a flower
25 f: '#F2C14E', // another
26 g: '#4F7A39', // a stem
27}
28
29function baseRows(): string[][] {
30 const rows = [
31 '.....######.....',
32 '...##hhhhhh##...',
33 '..#hhllllllmm#..',
34 '.#hllllllllmmd#.',
35 '.#hllllkkllmmd#.',
36 '.#hllkkkkkkmmd#.',
37 '.#hllllkkllmmd#.',
38 '.#hllllkkllmmd#.',
39 '.#hllllkkllmmd#.',
40 '.#hlmllkkllmmd#.',
41 '.#hllllllmllmd#.',
42 '.#hllllllllmmd#.',
43 '.#mlllmllllmmd#.',
44 '.#mmmmmmmmmmmd#.',
45 '#dddddddddddddd#',
46 '################',
47 '.sSsssSssssSsss.',
48 ]
49
50 return rows.map(row => [...row])
51}
52
53const put = (rows: string[][], points: ReadonlyArray<readonly [number, number, string]>) => {
54 for (const [x, y, ch] of points) {
55 const row = rows[y]
56 if (row !== undefined && x >= 0 && x < row.length) row[x] = ch
57 }
58}
59
60function pixels(rows: string[][]): string {
61 const byColour = new Map<string, string>()
62 rows.forEach((row, y) => {
63 let x = 0
64 while (x < row.length) {
65 const ch = row[x] as string
66 const fill = PALETTE[ch]
67 if (fill === undefined) {
68 x += 1
69 continue
70 }
71 let run = 1
72 while (row[x + run] === ch) run += 1
73 byColour.set(fill, `${byColour.get(fill) ?? ''}M${x} ${y}h${run}v1h-${run}z`)
74 x += run
75 }
76 })
77
78 return [...byColour].map(([fill, d]) => `<path fill="${fill}" d="${d}"/>`).join('')
79}
80
81// Exactly 2x the art's pixels, so each one lands on whole screen pixels.
82export const GRAVE_SIZE = { width: 36, height: 42 } as const
83
84export function graveSvg(ending: Ending): string {
85 const rows = baseRows()
86 let extra = ''
87 if (ending === 'done') {
88 // A gilded cross, flowers on the mound, a glint.
89 rows.forEach((row, y) => row.forEach((ch, x) => (ch === 'k' ? (row[x] = y === 5 && (x === 5 || x === 10) ? 'Y' : 'y') : undefined)))
90 put(rows, [
91 [2, 16, 'p'], [2, 15, 'g'], [4, 16, 'f'], [12, 16, 'p'], [13, 16, 'f'], [13, 15, 'g'],
92 ])
93 extra =
94 `<g transform="translate(12 -2)"><path fill="#F2C14E" d="M1 0h1v1h1v1h-1v1h-1v-1h-1v-1h1z"><animate attributeName="opacity" values="1;0.2;1" dur="1.6s" repeatCount="indefinite"/></path></g>`
95 }
96 if (ending === 'error') {
97 // Split by a crack, a chip off the top, the fallen hero's skull below.
98 put(rows, [[10, 1, '.'], [11, 1, '#'], [10, 2, '#'], [9, 3, 'k'], [10, 4, 'k'], [9, 5, 'k'], [10, 6, 'k'], [11, 7, 'k'], [11, 8, 'k'], [12, 9, 'k'], [3, 10, 'k'], [4, 11, 'k'], [4, 12, 'k'], [5, 12, 'k']])
99 extra = `<g transform="translate(-1 10) scale(0.55)">${SKULL}</g>`
100 }
101 if (ending === 'stopped') {
102 // Moss creeping over a stone left in peace, and a drifting "z".
103 put(rows, [
104 [5, 0, 'v'], [6, 0, 'V'], [3, 1, 'V'], [4, 1, 'v'], [3, 2, 'v'], [2, 3, 'V'], [2, 4, 'v'], [2, 5, 'v'],
105 [12, 11, 'v'], [12, 12, 'V'], [11, 12, 'v'], [12, 13, 'v'], [11, 13, 'V'],
106 ])
107 extra =
108 `<g fill="#AA8D7A"><path d="M12 -3h3v1h-1v1h-1v1h2v1h-3v-1h1v-1h1v-1h-2z"/>` +
109 `<animateTransform attributeName="transform" type="translate" values="0 2;1 -2" dur="2.4s" repeatCount="indefinite"/>` +
110 `<animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="indefinite"/></g>`
111 }
112
113 return (
114 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -4 18 21" width="${GRAVE_SIZE.width}" height="${GRAVE_SIZE.height}" shape-rendering="crispEdges">` +
115 pixels(rows) +
116 extra +
117 `</svg>`
118 )
119}
120hooks/camp.ts 183 lines1// The camp: background work the session left running (shell commands,
2// monitors, workflows) and what it scheduled (wake-ups, crons). Pure rules;
3// the hooks that feed them are in register.tsx.
4
5import type { CampKind, CampStatus, CampTask } from '../types'
6
7import type { ItemName } from './items'
8
9// A finished task stays this long, so you see how it ended.
10export const KEEP_FINISHED_MS = 5 * 60_000
11// An output file bigger than this is not read whole every few seconds.
12export const MAX_PEEK_BYTES = 1024 * 1024
13
14const text = (value: unknown) => (typeof value === 'string' ? value : '')
15const clip = (value: string, max = 90) => (value.length > max ? `${value.slice(0, max - 1)}…` : value)
16const firstLine = (value: string) => (value.split('\n')[0] ?? '').trim()
17
18export type CampChange =
19 | { add: CampTask }
20 | { end: string; status: CampStatus; summary?: string }
21 | { remove: string }
22
23const task = (id: string, kind: CampKind, title: string, detail: string, now: number, agentId?: string, wakesAt?: number): CampTask => ({
24 id,
25 kind,
26 title: clip(title || detail || kind),
27 detail: clip(detail, 140),
28 status: 'running',
29 startedAt: now,
30 endedAt: null,
31 lastLine: '',
32 ...(agentId === undefined ? {} : { agentId }),
33 ...(wakesAt === undefined ? {} : { wakesAt }),
34})
35
36// What one tool call did to the camp, read from its input and its result.
37export function campFromCall(tool: string, input: unknown, result: unknown, now: number, agentId?: string): CampChange | null {
38 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
39 const out = (typeof result === 'object' && result !== null ? result : {}) as Record<string, unknown>
40 switch (tool) {
41 case 'Bash': {
42 const id = text(out.backgroundTaskId)
43 if (id === '') return null
44 return { add: task(id, 'shell', text(args.description), firstLine(text(args.command)), now, agentId) }
45 }
46 case 'Monitor': {
47 const id = text(out.taskId)
48 if (id === '') return null
49 return { add: task(id, 'monitor', text(args.description), firstLine(text(args.command)), now, agentId) }
50 }
51 case 'Workflow': {
52 const id = text(out.taskId)
53 if (id === '') return null
54 const name = text(out.workflowName) || text(args.name)
55 return { add: task(id, 'workflow', text(out.summary) || name, name, now, agentId) }
56 }
57 case 'ScheduleWakeup': {
58 if (out.stopped === true) return { remove: 'wakeup' }
59 const at = typeof out.scheduledFor === 'number' ? out.scheduledFor : null
60 if (at === null) return null
61 const wakesAt = at < 1e12 ? at * 1000 : at
62 return { add: task('wakeup', 'wakeup', text(args.reason) || 'Wake-up', firstLine(text(args.prompt)), now, agentId, wakesAt) }
63 }
64 case 'CronCreate': {
65 const id = text(out.id)
66 if (id === '') return null
67 return { add: task(id, 'cron', firstLine(text(args.prompt)) || 'Scheduled task', text(out.humanSchedule), now, agentId) }
68 }
69 case 'CronDelete':
70 return text(out.id) === '' ? null : { remove: text(out.id) }
71 case 'TaskStop': {
72 const id = text(out.task_id) || text(args.task_id)
73 return id === '' ? null : { end: id, status: 'stopped' }
74 }
75 default:
76 return null
77 }
78}
79
80const tag = (body: string, name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(body)?.[1]?.trim() ?? ''
81
82// A finished task's notice: "<task-notification><task-id>…</task-id><status>completed</status>…".
83export function parseNotification(body: string): { id: string; status: CampStatus; summary: string } | null {
84 if (!body.includes('<task-notification>')) return null
85 const id = tag(body, 'task-id')
86 if (id === '') return null
87 const said = tag(body, 'status').toLowerCase()
88 const summary = tag(body, 'summary')
89 const exit = /exit code (\d+)/i.exec(summary)?.[1]
90 const status: CampStatus =
91 /kill|stop|cancel/.test(said) ? 'stopped' : /fail|error/.test(said) || (exit !== undefined && exit !== '0') ? 'failed' : 'done'
92
93 return { id, status, summary: clip(summary, 140) }
94}
95
96export function applyChange(tasks: readonly CampTask[], change: CampChange, now: number): CampTask[] {
97 if ('add' in change) return [...tasks.filter(one => one.id !== change.add.id), change.add]
98 if ('remove' in change) return tasks.filter(one => one.id !== change.remove)
99
100 return tasks.map(one =>
101 one.id === change.end && one.status === 'running'
102 ? { ...one, status: change.status, endedAt: now, ...(change.summary ? { summary: change.summary } : {}) }
103 : one,
104 )
105}
106
107export type RunningSummary = { id: string; type: string; description: string; command?: string }
108export type CronSummary = { id: string; schedule: string; recurring: boolean; prompt: string }
109
110// At the end of a turn the engine lists what is still running and what is
111// scheduled: anything of ours it no longer lists has finished; anything it
112// lists that we missed (started before a reload) joins the camp.
113export function reconcile(tasks: readonly CampTask[], running: readonly RunningSummary[], crons: readonly CronSummary[], now: number): CampTask[] {
114 const live = new Set(running.map(one => one.id))
115 const scheduled = new Set(crons.map(one => one.id))
116 const kept = tasks.map(one => {
117 if (one.status !== 'running') return one
118 if (one.kind === 'wakeup') return one
119 if (one.kind === 'cron') return one
120 return live.has(one.id) ? one : { ...one, status: 'done' as const, endedAt: now }
121 }).filter(one => one.kind !== 'cron' || scheduled.has(one.id))
122 const known = new Set(kept.map(one => one.id))
123 const kindOf = (type: string): CampKind | null => (type === 'shell' ? 'shell' : type === 'monitor' ? 'monitor' : type === 'workflow' ? 'workflow' : null)
124 const missed = running
125 .filter(one => !known.has(one.id) && kindOf(one.type) !== null)
126 .map(one => task(one.id, kindOf(one.type) as CampKind, one.description, firstLine(one.command ?? ''), now))
127 const crons_ = crons.filter(one => !known.has(one.id)).map(one => task(one.id, one.recurring ? 'cron' : 'wakeup', firstLine(one.prompt) || 'Scheduled task', one.schedule, now))
128
129 return [...kept, ...missed, ...crons_]
130}
131
132// Finished tasks leave after a while; a wake-up leaves once it has fired.
133export function pruneCamp(tasks: readonly CampTask[], now: number): CampTask[] {
134 return tasks.filter(one => {
135 if (one.status !== 'running') return now - (one.endedAt ?? now) < KEEP_FINISHED_MS
136 if (one.kind === 'wakeup' && one.wakesAt !== undefined) return now < one.wakesAt + 60_000
137 return true
138 })
139}
140
141// The newest line worth showing: colour codes and blank lines dropped.
142export function lastLine(output: string): string {
143 // eslint-disable-next-line no-control-regex
144 const lines = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n').split('\n')
145 for (let index = lines.length - 1; index >= 0; index -= 1) {
146 const line = (lines[index] ?? '').trim()
147 if (line !== '') return clip(line, 120)
148 }
149 return ''
150}
151
152// Where background output lives: <tmp>/claude-<uid>/<cwd as a slug>/<session>/tasks.
153export const cwdSlug = (cwd: string) => cwd.replace(/[^A-Za-z0-9-]/g, '-')
154
155const ICONS: Record<CampKind, ItemName> = { shell: 'staff', monitor: 'bow', workflow: 'banner', wakeup: 'coin', cron: 'banner' }
156const ENDINGS: Record<Exclude<CampStatus, 'running'>, ItemName> = { done: 'chest', failed: 'skull', stopped: 'heart' }
157
158export const campIcon = (one: CampTask) => (one.status === 'running' ? ICONS[one.kind] : ENDINGS[one.status])
159
160export function untilWords(at: number, now: number): string {
161 const minutes = Math.max(0, Math.round((at - now) / 60_000))
162 if (minutes < 1) return 'any moment'
163 if (minutes < 60) return `in ${minutes}m`
164 return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`
165}
166
167const RUNNING_VERB: Record<CampKind, string> = { shell: 'burning', monitor: 'on watch', workflow: 'marching', wakeup: 'wakes', cron: 'repeats' }
168
169// The second line of a camp card: how long, or when, or how it ended.
170export function campState(one: CampTask, now: number, elapsed: (ms: number) => string): string {
171 if (one.status !== 'running') {
172 const ago = elapsed(now - (one.endedAt ?? now))
173 const word = one.status === 'done' ? 'done' : one.status === 'failed' ? 'failed' : 'stopped'
174 return `${word} ${ago} ago${one.summary ? ` · ${one.summary}` : ''}`
175 }
176 if (one.kind === 'wakeup') return one.wakesAt === undefined ? 'scheduled' : `wakes ${untilWords(one.wakesAt, now)}`
177 if (one.kind === 'cron') return `repeats · ${one.detail}`
178
179 return `${RUNNING_VERB[one.kind]} ${elapsed(now - one.startedAt)}`
180}
181
182export const isRunningWork = (one: CampTask) => one.status === 'running' && (one.kind === 'shell' || one.kind === 'monitor' || one.kind === 'workflow')
183hooks/icons.ts 26 lines1// The tool rows' pixel items, from the same tileset as the party's heroes.
2// Long weapons lie at 45 degrees, as an inventory shows them, so they fill
3// their square instead of standing as a thin line.
4
5import { ITEMS, type ItemName } from './items'
6
7export const ICON_SIZE = 20
8
9export function iconSvg(name: ItemName, size = ICON_SIZE): string {
10 const item = ITEMS[name]
11 const isLong = item.height > item.width * 1.6
12 if (!isLong) {
13 const side = Math.max(item.width, item.height)
14 const dx = (side - item.width) / 2
15 const dy = (side - item.height) / 2
16 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-dx} ${-dy} ${side} ${side}" width="${size}" height="${size}" shape-rendering="crispEdges">${item.paths}</svg>`
17 }
18 const side = Math.ceil((item.width + item.height) * Math.SQRT1_2) + 1
19 const half = side / 2
20
21 return (
22 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" width="${size}" height="${size}" shape-rendering="crispEdges">` +
23 `<g transform="translate(${half} ${half}) rotate(45) translate(${-item.width / 2} ${-item.height / 2})">${item.paths}</g></svg>`
24 )
25}
26hooks/quests.ts 90 lines1// The quest log: a short receipt of each main-thread turn, what was read,
2// changed and run, what was asked, and what it cost in context.
3
4import type { Quest } from '../types'
5
6import type { ItemName } from './items'
7import { tokens } from './cost'
8
9export const KEPT_QUESTS = 20
10export const SHOWN_QUESTS = 5
11
12export function newQuest(turn: number, quest: string, startedAt: number): Quest {
13 return { turn, quest, startedAt, durationMs: 0, reads: [], edits: [], commands: [], searches: 0, ravens: 0, heroes: 0, asked: 0, failed: 0, contextGained: null }
14}
15
16const text = (value: unknown) => (typeof value === 'string' ? value : '')
17const baseName = (path: string) => path.split('/').pop() ?? path
18const addOnce = (list: readonly string[], item: string) => (item === '' || list.includes(item) ? [...list] : [...list, item])
19
20// One tool call of the turn, counted by what it does.
21export function withCall(quest: Quest, tool: string, input: unknown, isErrored: boolean): Quest {
22 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
23 const next = { ...quest, failed: quest.failed + (isErrored ? 1 : 0) }
24 switch (tool) {
25 case 'Read':
26 case 'NotebookRead':
27 return { ...next, reads: addOnce(next.reads, baseName(text(args.file_path) || text(args.notebook_path))) }
28 case 'Edit':
29 case 'MultiEdit':
30 case 'Write':
31 case 'NotebookEdit':
32 return { ...next, edits: addOnce(next.edits, baseName(text(args.file_path) || text(args.notebook_path))) }
33 case 'Bash': {
34 const said = text(args.description) || (text(args.command).split('\n')[0] ?? '')
35 return { ...next, commands: [...next.commands, said.length > 60 ? `${said.slice(0, 59)}…` : said] }
36 }
37 case 'Grep':
38 case 'Glob':
39 case 'LS':
40 return { ...next, searches: next.searches + 1 }
41 case 'WebFetch':
42 case 'WebSearch':
43 return { ...next, ravens: next.ravens + 1 }
44 case 'Agent':
45 case 'Task':
46 return { ...next, heroes: next.heroes + 1 }
47 default:
48 return next
49 }
50}
51
52const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
53
54// The tally line: only what happened, in the realm's words.
55// The turn's tally, each part with the pixel item the tool rows use for it.
56export type TallyPart = { item: ItemName; text: string }
57
58export function questParts(quest: Quest): TallyPart[] {
59 const parts: Array<TallyPart | null> = [
60 quest.reads.length > 0 ? { item: 'chest', text: count(quest.reads.length, 'read', 'read') } : null,
61 quest.edits.length > 0 ? { item: 'hammer', text: count(quest.edits.length, 'forged', 'forged') } : null,
62 quest.commands.length > 0 ? { item: 'staff', text: count(quest.commands.length, 'cast', 'cast') } : null,
63 quest.searches > 0 ? { item: 'bow', text: count(quest.searches, 'scout', 'scouts') } : null,
64 quest.ravens > 0 ? { item: 'arrow', text: count(quest.ravens, 'raven', 'ravens') } : null,
65 quest.heroes > 0 ? { item: 'sword', text: count(quest.heroes, 'hero', 'heroes') } : null,
66 quest.asked > 0 ? { item: 'heart', text: count(quest.asked, 'ask', 'asks') } : null,
67 quest.failed > 0 ? { item: 'skull', text: count(quest.failed, 'failed', 'failed') } : null,
68 ]
69 return parts.filter((one): one is TallyPart => one !== null)
70}
71
72export function questTally(quest: Quest): string {
73 const parts = questParts(quest).map(one => one.text)
74
75 return parts.length > 0 ? parts.join(' · ') : 'Words only, no tools'
76}
77
78export function questGain(quest: Quest): string {
79 if (quest.contextGained === null) return ''
80 if (quest.contextGained < 0) return 'context condensed'
81
82 return `+${tokens(quest.contextGained)} context`
83}
84
85// "a.ts, b.ts and 3 more".
86export function listed(items: readonly string[], shown = 3): string {
87 if (items.length <= shown) return items.join(', ')
88 return `${items.slice(0, shown).join(', ')} and ${items.length - shown} more`
89}
90hooks/stamina.ts 50 lines1// Stamina: the plan's usage windows (5-hour, weekly) as bars of what is left,
2// with a warning when one crosses 80% and again at 95% used.
3
4import type { Context, Stamina } from '../types'
5
6export const WARN_AT = [80, 95] as const
7
8const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Week', spend_limit: 'Spend' }
9export const staminaLabel = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
10
11// The bar reuses the context bar's drawing: "used" out of 100.
12export const asContext = (limit: Stamina): Context => ({ tokens: Math.min(100, limit.percentUsed), window: 100, model: limit.kind })
13
14// "in 2h 10m", "in 3d 4h", "in 12m"; empty when unknown or past.
15export function resetsIn(resetsAt: string | undefined, now: number): string {
16 if (resetsAt === undefined) return ''
17 const at = Date.parse(resetsAt)
18 if (Number.isNaN(at) || at <= now) return ''
19 const minutes = Math.round((at - now) / 60_000)
20 const days = Math.floor(minutes / 1440)
21 const hours = Math.floor((minutes % 1440) / 60)
22 const mins = minutes % 60
23 if (days > 0) return `in ${days}d ${hours}h`
24 if (hours > 0) return `in ${hours}h ${mins}m`
25
26 return `in ${mins}m`
27}
28
29export function staminaWords(limit: Stamina, now: number): string {
30 const left = Math.max(0, Math.round(100 - limit.percentUsed))
31 const reset = resetsIn(limit.resetsAt, now)
32
33 return `${staminaLabel(limit.kind)} ${left}% left${reset ? ` · resets ${reset}` : ''}`
34}
35
36// The warning line a crossing earns, or null: only the highest threshold
37// newly crossed since the last reading, so one jump gives one warning.
38export function crossing(before: number | undefined, after: number): number | null {
39 const crossed = WARN_AT.filter(at => (before ?? 0) < at && after >= at)
40 return crossed.length > 0 ? (crossed[crossed.length - 1] as number) : null
41}
42
43// The windows worth showing: the plan's two, in a fixed order.
44export function shownStamina(limits: readonly Stamina[]): Stamina[] {
45 const order = ['five_hour', 'seven_day', 'spend_limit']
46 return [...limits]
47 .filter(limit => typeof limit.percentUsed === 'number')
48 .sort((a, b) => (order.indexOf(a.kind) + 1 || 9) - (order.indexOf(b.kind) + 1 || 9))
49}
50hooks/realm.ts 252 lines1// The realm map: every open session on this machine, as a kingdom. Each
2// session writes its own file under ~/.claude/realm/sessions every few
3// seconds; every session reads them all. Pure rules here; the reading and
4// writing is in register.tsx.
5
6import type { AppSession, Kingdom, Overlord, Pal } from '../types'
7
8// Not heard from in this long: closed or crashed, and left off the map.
9export const STALE_MS = 2 * 60_000
10// Written again at least this often, so the others know it is still open.
11export const HEARTBEAT_MS = 30_000
12// More files than this are not read in one tick.
13export const MAX_KINGDOMS = 20
14
15// The app's link to a session, from its host id (CLAUDE_CODE_HOST_SESSION_ID).
16export function sessionLink(hostId: string | undefined): string | undefined {
17 return hostId !== undefined && /^[A-Za-z0-9_-]+$/.test(hostId) ? `claude://claude.ai/epitaxy/${hostId}` : undefined
18}
19
20// Only the app's own session links are ever opened, whatever a file says.
21export const isSessionLink = (link: unknown): link is string =>
22 typeof link === 'string' && /^claude:\/\/claude\.ai\/epitaxy\/[A-Za-z0-9_-]+$/.test(link)
23
24export const kingdomFile = (dir: string, id: string) => `${dir}/sessions/${id.replace(/[^A-Za-z0-9_-]/g, '')}.json`
25
26// Background helpers (claude-mem's observer sessions, say) run from hidden
27// folders like ~/.claude-mem/...; they are not the person's work, so they stay
28// off the map. Real projects live in ordinary folders.
29export function isBackgroundFolder(cwd: string): boolean {
30 return /\/\.claude-mem(\/|$)|\/\.claude\/plugins(\/|$)/.test(cwd)
31}
32
33// The mod's own folder: <config>/dungeon-master, where <config> is
34// CLAUDE_CONFIG_DIR or ~/.claude. Never inside a project. Null when neither
35// can be told.
36export function dataDirFrom(home: string | undefined, configDir: string | undefined): string | null {
37 const config = configDir !== undefined && configDir.startsWith('/') ? configDir.replace(/\/+$/, '') : home !== undefined && home.startsWith('/') ? `${home.replace(/\/+$/, '')}/.claude` : null
38 return config === null ? null : `${config}/dungeon-master`
39}
40
41// The check-in files worth reading: written within the stale window, newest
42// first. Old ones are never deleted (the engine cannot), only passed over.
43export function freshFiles<T extends { name: string; mtimeMs: number }>(entries: readonly T[], now: number, skip: string): T[] {
44 return entries
45 .filter(entry => entry.name.endsWith('.json') && entry.name !== skip && now - entry.mtimeMs < STALE_MS)
46 .sort((a, b) => b.mtimeMs - a.mtimeMs)
47 .slice(0, MAX_KINGDOMS)
48}
49
50export function folderName(cwd: string): string {
51 return cwd.replace(/\/+$/, '').split('/').pop() || cwd
52}
53
54export function kingdomOf(id: string, name: string, lord: Overlord, live: readonly Pal[], now: number, camp = 0, link?: string): Kingdom {
55 return {
56 id,
57 name,
58 // No prompt text ever leaves the session: the map names sessions by the
59 // app's own titles (or the person's names), never by what was typed.
60 quest: '',
61 opening: '',
62 status: lord.status === 'waiting' || live.some(pal => pal.status === 'waiting') ? 'waiting' : lord.status,
63 activity: lord.activity,
64 context: lord.context ?? null,
65 isCompacting: typeof lord.compactingSince === 'number',
66 party: live.length,
67 camp,
68 ...(link === undefined ? {} : { link }),
69 updatedAt: now,
70 }
71}
72
73// Whether a kingdom changed enough to be worth writing before its heartbeat.
74export function sameKingdom(a: Kingdom | null, b: Kingdom): boolean {
75 if (a === null) return false
76 return JSON.stringify({ ...a, updatedAt: 0 }) === JSON.stringify({ ...b, updatedAt: 0 })
77}
78
79function isKingdom(value: unknown): value is Kingdom {
80 const k = value as Kingdom
81 return (
82 typeof value === 'object' &&
83 value !== null &&
84 typeof k.id === 'string' &&
85 typeof k.name === 'string' &&
86 typeof k.updatedAt === 'number' &&
87 (k.status === 'idle' || k.status === 'working' || k.status === 'waiting')
88 )
89}
90
91// The files as read, turned into the map: valid, live, not ended, waiting
92// ones first (they need you), then working, then resting; newest first within.
93export function kingdomsFrom(texts: readonly string[], now: number): Kingdom[] {
94 const order = { waiting: 0, working: 1, idle: 2 } as const
95 return texts
96 .map(text => {
97 try {
98 return JSON.parse(text) as unknown
99 } catch {
100 return null
101 }
102 })
103 .filter(isKingdom)
104 .filter(k => k.hasEnded !== true && now - k.updatedAt < STALE_MS)
105 .sort((a, b) => order[a.status] - order[b.status] || b.updatedAt - a.updatedAt)
106}
107
108// What a session is called on the map: what it is about, else its folder.
109// A prompt that is only pasted text names nothing: "<pasted_content …>".
110const isNamingText = (text: string | undefined) => text !== undefined && text.trim() !== '' && !text.trim().startsWith('<')
111
112export const kingdomTitle = (k: Kingdom) => [k.opening, k.quest].find(isNamingText) ?? k.name
113
114// --- the desktop app's own list of sessions ----------------------------------
115
116// The host id at the end of a session's app link, or null.
117export function hostIdOf(link: string | undefined): string | null {
118 return isSessionLink(link) ? (link.split('/').pop() ?? null) : null
119}
120
121// The app's session list, from whatever shape the list tool's answer takes:
122// the array itself, or JSON text somewhere inside it (an MCP content block).
123export function appSessionsFrom(value: unknown, keep: 'open' | 'archived' = 'open'): AppSession[] {
124 const rows = findRows(value, 0)
125 const out: AppSession[] = []
126 for (const row of rows) {
127 if (row === null || typeof row !== 'object') continue
128 const one = row as Record<string, unknown>
129 if (typeof one.sessionId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(one.sessionId)) continue
130 if ((one.isArchived === true) !== (keep === 'archived')) continue
131 const at = typeof one.lastActivityAt === 'string' ? Date.parse(one.lastActivityAt) : NaN
132 out.push({
133 id: one.sessionId,
134 title: typeof one.title === 'string' && one.title.trim() !== '' ? one.title.trim().slice(0, 120) : 'Untitled session',
135 lastActivityAt: Number.isFinite(at) ? at : 0,
136 isPinned: one.pinned === true,
137 isArchived: one.isArchived === true,
138 })
139 }
140
141 return out
142}
143
144function findRows(value: unknown, depth: number): unknown[] {
145 if (depth > 4) return []
146 if (Array.isArray(value)) {
147 if (value.some(row => row !== null && typeof row === 'object' && 'sessionId' in row)) return value
148 return value.flatMap(item => findRows(item, depth + 1))
149 }
150 if (typeof value === 'string') {
151 const start = value.indexOf('[')
152 if (start === -1) return []
153 try {
154 return findRows(JSON.parse(value.slice(start)), depth + 1)
155 } catch {
156 return []
157 }
158 }
159 if (value !== null && typeof value === 'object') return Object.values(value).flatMap(item => findRows(item, depth + 1))
160
161 return []
162}
163
164// "2h ago", "3d ago": how long since a session last did anything.
165export function agoWords(ms: number): string {
166 const minutes = Math.max(0, Math.floor(ms / 60_000))
167 if (minutes < 1) return 'just now'
168 if (minutes < 60) return `${minutes}m ago`
169 const hours = Math.floor(minutes / 60)
170 if (hours < 24) return `${hours}h ago`
171
172 return `${Math.floor(hours / 24)}d ago`
173}
174
175export function kingdomWords(k: Kingdom): string {
176 if (k.isCompacting) return 'Condensing memories'
177 if (k.status === 'waiting') return 'Needs you'
178 const party =
179 (k.party > 0 ? ` · ${k.party} ${k.party === 1 ? 'hero' : 'heroes'} out` : '') + ((k.camp ?? 0) > 0 ? ` · ${k.camp} in camp` : '')
180 if (k.status === 'working') return `${k.activity || 'thinking'}${party}`
181
182 return `Idle${party}`
183}
184
185// One check-in per app session: a session that reloaded its mod (or ran an
186// older one) can leave a second file under a new id. The newest wins.
187export function uniqueKingdoms(list: readonly Kingdom[], hereId: string | null): Kingdom[] {
188 const best = new Map<string, Kingdom>()
189 for (const k of list) {
190 const key = hostIdOf(k.link) ?? k.id
191 if (key === hereId) continue
192 const seen = best.get(key)
193 if (seen === undefined || k.updatedAt > seen.updatedAt) best.set(key, k)
194 }
195
196 return [...best.values()]
197}
198
199const orderKey = (k: Kingdom) => `${kingdomTitle(k).toLowerCase()}\u0000${hostIdOf(k.link) ?? k.id}`
200
201// The map's three bands: who needs the person, who is busy, who is idle.
202export type MapGroups = { waiting: Kingdom[]; working: Kingdom[]; idle: Kingdom[] }
203
204export function mapGroups(list: readonly Kingdom[]): MapGroups {
205 // A fixed order (by name, then id): check-ins rewrite every few seconds,
206 // so ordering by them made the rows jump about.
207 const settled = (a: Kingdom, b: Kingdom) => orderKey(a).localeCompare(orderKey(b))
208 return {
209 waiting: list.filter(k => k.status === 'waiting').sort(settled),
210 working: list.filter(k => k.status !== 'waiting' && (k.status === 'working' || k.isCompacting || k.party > 0)).sort(settled),
211 idle: list.filter(k => k.status !== 'waiting' && k.status !== 'working' && !k.isCompacting && k.party === 0).sort(settled),
212 }
213}
214
215// "2 need you · 1 working · 6 idle · 8 asleep", the parts that are not zero.
216export function mapSummary(groups: MapGroups, asleep: number, pinned = 0, alsoIdle = 0): string {
217 const idle = groups.idle.length + alsoIdle
218 const parts = [
219 pinned > 0 ? `${pinned} pinned` : '',
220 groups.waiting.length > 0 ? `${groups.waiting.length} need${groups.waiting.length === 1 ? 's' : ''} you` : '',
221 groups.working.length > 0 ? `${groups.working.length} working` : '',
222 idle > 0 ? `${idle} idle` : '',
223 asleep > 0 ? `${asleep} asleep` : '',
224 ].filter(Boolean)
225
226 return parts.length === 0 ? 'Only this session' : parts.join(' · ')
227}
228
229// --- the person's own names for sessions -------------------------------------
230
231export const MAX_SESSION_NAME = 60
232
233// names.json as written, read leniently: only app ids and short names.
234export function readNames(text: string | null): Record<string, string> {
235 if (text === null) return {}
236 try {
237 const value = JSON.parse(text) as unknown
238 if (value === null || typeof value !== 'object') return {}
239 const out: Record<string, string> = {}
240 for (const [id, name] of Object.entries(value as Record<string, unknown>)) {
241 if (/^[A-Za-z0-9_-]+$/.test(id) && typeof name === 'string' && name.trim() !== '') out[id] = cleanSessionName(name)
242 }
243 return out
244 } catch {
245 return {}
246 }
247}
248
249export function cleanSessionName(text: string): string {
250 return text.replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_SESSION_NAME).trim()
251}
252hooks/settings.ts 173 lines1// The realm's settings: every part can be switched off on its own. The record
2// is ~/.claude/realm/settings.json, shared by every open session (each reads
3// it every few seconds), with the mod's store as the fallback when that folder
4// cannot be found. Read leniently: anything missing or unknown is the default.
5
6import type { HeadingFont, Section, Settings } from '../types'
7
8import { DEFAULT_THEME, asThemeName } from './themes'
9
10export type { Section, Settings }
11
12// The main thread's character, until the person names it.
13export const DEFAULT_MAIN_NAME = 'The Overlord'
14export const MAX_NAME = 32
15
16// A name as typed, made safe to show: no control characters, one space
17// between words, at most 32 characters; empty means the default.
18export function cleanName(text: string): string {
19 const clean = text.replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_NAME).trim()
20 return clean === '' ? DEFAULT_MAIN_NAME : clean
21}
22
23export const DEFAULT_SETTINGS: Settings = {
24 panelAtStart: true,
25 hud: true,
26 sound: true,
27 rows: true,
28 dice: true,
29 volume: 60,
30 headingFont: 'fantasy',
31 mainName: DEFAULT_MAIN_NAME,
32 mainLook: -1,
33 theme: DEFAULT_THEME,
34 hidden: [],
35}
36
37export type Switch = Exclude<keyof Settings, 'theme' | 'hidden' | 'headingFont' | 'mainName' | 'mainLook' | 'volume'>
38
39export const HEADING_FONTS: ReadonlyArray<{ key: HeadingFont; label: string }> = [
40 { key: 'fantasy', label: 'Fantasy' },
41 { key: 'app', label: 'App font' },
42]
43
44// The switches as the Settings page groups and words them.
45export const SWITCH_GROUPS: ReadonlyArray<{ title: string; switches: ReadonlyArray<{ key: Switch; label: string; hint: string }> }> = [
46 {
47 title: 'At the table',
48 switches: [
49 { key: 'hud', label: 'Strip above the message box', hint: 'Your character, how much context is left and your plan limits.' },
50 { key: 'rows', label: 'Steps told as deeds', hint: 'Tool steps read as deeds: Studied, Forged, Cast, Scouted.' },
51 { key: 'dice', label: 'Rolling d20', hint: 'A d20 tumbles beside the working line while Claude thinks.' },
52 { key: 'sound', label: 'Sounds', hint: 'Soft chimes when a hero sets out, returns or needs you.' },
53 ],
54 },
55 {
56 title: 'The look',
57 switches: [],
58 },
59 {
60 title: 'The DM screen',
61 switches: [{ key: 'panelAtStart', label: 'Open at session start', hint: 'The DM screen opens with every new session. Otherwise use /dm or the strip.' }],
62 },
63]
64
65export const SECTIONS: ReadonlyArray<{ key: Section; label: string }> = [
66 { key: 'party', label: 'The party' },
67 { key: 'camp', label: 'The camp (background work)' },
68 { key: 'quests', label: 'Quest log' },
69 { key: 'graveyard', label: 'Graveyard' },
70 { key: 'stamina', label: 'Stamina (plan limits)' },
71]
72
73const SWITCH_KEYS = SWITCH_GROUPS.flatMap(group => group.switches.map(one => one.key))
74const SECTION_KEYS = new Set<string>(SECTIONS.map(one => one.key))
75
76export function readSettings(value: unknown): Settings {
77 let raw: unknown = value
78 if (typeof value === 'string') {
79 try {
80 raw = JSON.parse(value)
81 } catch {
82 raw = null
83 }
84 }
85 if (raw === null || typeof raw !== 'object') return DEFAULT_SETTINGS
86 const given = raw as Record<string, unknown>
87 const settings: Settings = { ...DEFAULT_SETTINGS }
88 for (const key of SWITCH_KEYS) if (typeof given[key] === 'boolean') settings[key] = given[key]
89 const theme = asThemeName(given.theme)
90 if (theme !== null) settings.theme = theme
91 if (typeof given.volume === 'number' && Number.isFinite(given.volume)) settings.volume = Math.max(0, Math.min(100, Math.round(given.volume / 10) * 10))
92 if (typeof given.mainName === 'string') settings.mainName = cleanName(given.mainName)
93 if (typeof given.mainLook === 'number' && Number.isInteger(given.mainLook) && given.mainLook >= -1 && given.mainLook < 64) settings.mainLook = given.mainLook
94 if (HEADING_FONTS.some(one => one.key === given.headingFont)) settings.headingFont = given.headingFont as HeadingFont
95 if (Array.isArray(given.hidden)) settings.hidden = given.hidden.filter((one): one is Section => typeof one === 'string' && SECTION_KEYS.has(one))
96
97 return settings
98}
99
100export const sameSettings = (a: Settings, b: Settings) => JSON.stringify(a) === JSON.stringify(b)
101
102// "/dm …": what the person asked for.
103export type RealmAsk =
104 | { kind: 'open' }
105 | { kind: 'settings' }
106 | { kind: 'demo' }
107 | { kind: 'status' }
108 | { kind: 'showcase' }
109 | { kind: 'theme'; theme: string }
110 | { kind: 'font'; font: string }
111 | { kind: 'name'; name: string }
112 | { kind: 'volume'; volume: number | null }
113 | { kind: 'switch'; key: Switch; on: boolean }
114 | { kind: 'unknown' }
115
116const SWITCH_WORDS: Record<string, Switch> = {
117 hud: 'hud',
118 strip: 'hud',
119 rows: 'rows',
120 sound: 'sound',
121 sounds: 'sound',
122 dice: 'dice',
123 d20: 'dice',
124 panel: 'panelAtStart',
125}
126
127const ON = ['on', 'yes', 'enable', 'true']
128const OFF = ['off', 'no', 'disable', 'false']
129
130export function readRealmAsk(args: string): RealmAsk {
131 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
132 if (words.length === 0) return { kind: 'open' }
133 if (words[0] === 'settings' && words.length === 1) return { kind: 'settings' }
134 if (words[0] === 'demo' && words.length === 1) return { kind: 'demo' }
135 if (words[0] === 'status' && words.length === 1) return { kind: 'status' }
136 if (words[0] === 'showcase' && words.length === 1) return { kind: 'showcase' }
137 if (words[0] === 'theme') return { kind: 'theme', theme: words[1] ?? '' }
138 if (words[0] === 'font') return { kind: 'font', font: words[1] ?? '' }
139 if (words[0] === 'volume') {
140 const n = Number.parseInt(words[1] ?? '', 10)
141 return { kind: 'volume', volume: Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : null }
142 }
143 // The name keeps the person's own capitals.
144 if (words[0] === 'name') return { kind: 'name', name: args.trim().replace(/^name\s*/i, '') }
145 const key = Object.hasOwn(SWITCH_WORDS, words[0]!) ? SWITCH_WORDS[words[0]!] : undefined
146 const state = words[1] ?? ''
147 if (key !== undefined && (ON.includes(state) || OFF.includes(state))) return { kind: 'switch', key, on: ON.includes(state) }
148
149 return { kind: 'unknown' }
150}
151
152export function switchLabel(key: Switch): string {
153 for (const group of SWITCH_GROUPS) for (const one of group.switches) if (one.key === key) return one.label
154
155 return key
156}
157
158// One line: what is on and what is off.
159export function settingsWords(settings: Settings): string {
160 const all = SWITCH_GROUPS.flatMap(group => group.switches)
161 const on = all.filter(one => settings[one.key]).map(one => one.label)
162 const off = all.filter(one => !settings[one.key]).map(one => one.label)
163
164 return [
165 on.length > 0 ? `On: ${on.join(', ')}` : '',
166 off.length > 0 ? `Off: ${off.join(', ')}` : '',
167 `Theme: ${settings.theme}`,
168 `Headings: ${settings.headingFont}`,
169 ]
170 .filter(Boolean)
171 .join('. ')
172}
173