SLOPSHOPPER

dungeon-master

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…

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dungeon-master
│ ┃ Dungeon Master ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Your Party No one on a quest ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Party ] [ Map ] [ Fame ] [ Settings ] ╭───────────────────────────────────────────────────────────────────╮ │ ┃ │ › Forged auth.ts ▸ Details │ │ ┃ The tavern is quiet. When Claude sends a ╰───────────────────────────────────────────────────────────────────╯ │ ┃ helper off on a task, a hero sets out from ⏺ Bash(bun test) │ ┃ here. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dm │ ⎿ dungeon-master: Opened the DM screen. │ │ ✻ Pondering… ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ The Overlord · Awaiting your command. [ Close DM screen ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ The Overlord · Awaiting your command. [ Close DM screen ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Dungeon Master
Your Party No one on a quest [ Party ] [ Map ] [ Fame ] [ Settings ] The tavern is quiet. When Claude sends a helper off on a task, a hero sets out from here.
Tool row
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › Forged auth.ts ▸ Details │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Dungeon Master

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.

Thornwick the Ranger idling on the character select screen

The strip above the message box: your character, context left and plan limits, and the DM screen toggle

Your PartyThe Realm Map
Pixel heroes on quests with context bars, the camp and the graveyardEvery Claude session sorted into Pinned, Needs you, Working, Idle, Asleep and Archived
Hall of HeroesChoose your character
Each hero's level and XP across sessionsA character select screen with tale and ability scores
Ten themes, light and darkSteps told as deeds
The Tome in the wildwood themeA tool step shown as Forged with a hammer icon

<sub>Pictures taken in showcase mode (/dm showcase): every hero, session and quest in them is made up.</sub>

What is on the DM screen

PageWhat you get
Your PartyA 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 MapEvery 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 HeroesEach hero's XP and level across sessions, their record, and ten achievements to earn.
The DM's TomeSettings: 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.

Install

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.

Updating

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.

The buttons need one setting

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.

Commands

  • /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|off

What it keeps and reads

Mods 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.
  • Plus the mod's own store: the Hall of Heroes, achievements and which sections are folded.

What it reads

  • Those files, from your other open sessions.
  • The app's own session list (and the file the app saves it to when it is long, only from its own tool-results folder).
  • The newest output line of background commands you started in this session, for the camp.
  • The first line of your latest message, kept in memory for the strip and the quest log, never written to disk.
  • Three environment values: 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.
  • The 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:

  • every tool call (to tell each step as a deed and keep the camp);
  • every model response (its token usage fills the context bars);
  • each helper starting and finishing (to draw its hero);
  • the app's own notices, so a hero (or your character) shows when it waits on a permission prompt. The mod has no permission hook at all: you answer in the app's own prompt, as always;
  • background-task notices, and compaction.

Also

  • /dm, its one slash command.
  • A desktop notification when a hero needs you.
  • Its own sound files from 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.

Licence

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.

Credits

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.

For tinkerers

  • 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 sounds
  • tests/: claude plugin test mods/dungeon-master
Source 26 files
hooks/register.tsx 2728 lines
1// 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 lines
1// 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}
81
hooks/context.ts 94 lines
1// 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}
94
hooks/cost.ts 85 lines
1// 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}
85
hooks/fame.ts 75 lines
1// 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}
75
hooks/graves.ts 120 lines
1// 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}
120
hooks/camp.ts 183 lines
1// 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')
183
hooks/icons.ts 26 lines
1// 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}
26
hooks/quests.ts 90 lines
1// 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}
90
hooks/stamina.ts 50 lines
1// 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}
50
hooks/realm.ts 252 lines
1// 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}
252
hooks/settings.ts 173 lines
1// 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