Play Under Management while Claude works on a long turn. After 20 s of work, press 1: the game opens in a 960 x 600 window of its own and pauses itself when…

A Claude Code mod. Claude starts a long turn. Twenty seconds in, a strip appears above the prompt: Claude is still working. 1: Play Under Management. Press 1 and the game opens in a window of its own beside your terminal. When Claude finishes, the game pauses itself and tells you.

In Claude Code:
/plugin marketplace add Interesting-Systems/under-management-claude
/plugin install under-management@interesting-systems
That's it. The next time Claude works for more than 20 seconds, the strip appears. Press 1.
You need Claude Code 2.1.287 or later in a local session, in the terminal or the desktop app. A cloud session can't open a window on your machine. Chrome or Edge gives you the app window; without them the game opens in your default browser. An organization's policy can block installed mods.
A real session, recorded in a real terminal. The project is a toy with a 25-second test suite.

Pressing 1 really did open the game window. The recorder only sees the terminal, so here is the other side: the game, with Claude's clock in the strip at the top, and what happens when Claude finishes.

The Warder has marked the place in the ledger. He has also written down the time.
The game also pauses whenever its window loses focus, so the house isn't raided while you read Claude's answer. Saves live in the window's own browser profile and keep between sessions.
Under Management is a 3D isometric dungeon-management game. You run the house: dig it out, build rooms, hire staff who live and work there. Adventurers from the Guild arrive to take the gold, and your job is to see that they don't. There are no unit orders. Your architecture is your tactics. Real time, pausable.
The companion edition has four houses: Marrow's Rest, Ashfell, Penhallow and Undercliff. The rest of the game's houses are behind a "More houses" button; see below. You can try the companion edition without the mod at under-management.vercel.app/companion.
| Command | What it does | |
|---|---|---|
/under-management | Open the game now, without waiting for the strip | |
| `/under-management auto on\ | off` | Open the game on its own after the delay, no strip (off by default) |
/under-management delay <s> | Seconds of work before the offer: 20 by default, 5 to 600 | |
| `/under-management strip on\ | off` | Show or hide the strip |
/under-management help | List these, and show the current settings |
Settings are saved and apply to every session.

Any of these:
/under-management strip off keeps the mod but hides the offer. /under-management still works./plugin disable under-management@interesting-systems turns the mod off./plugin removes it.The mod can't run a server, and a web page can't read the mod's files, so the two talk through a relay on the game's site. Here is the whole of it:
working with the time the turn started, then ready with how long it took. That goes to https://under-management.vercel.app/api/companion under a random 16-character code made for the session. The code is the only key to that record.The game page itself behaves like the public web build: by default it sends an anonymous record of play, meaning the game's own commands, which is how the houses get balanced. Press Esc in the game and turn off "Send an anonymous record of play" to stop that.
The source for the whole exchange is in hooks/register.ts, which is short.
The companion stops at Undercliff. If about fifty people ask, the remaining houses go in. Asking is a thumbs-up on the pinned issue, More houses?, or the "More houses" button in the game, which counts once per browser.
The Warder has opened a file for requests. It is currently thin.
Tested live on macOS. The Windows launcher (Edge, falling back to the default browser) and the Linux launcher (the first Chromium-family browser on the PATH, else xdg-open) are written and unit-tested, but have not yet been tried on those machines. If you try one, an issue either way would help.
31 tests run under claude plugin test, against both the terminal and desktop surfaces. CI validates the manifests strictly and runs the tests on every push.
claude --plugin-dir ./plugins/under-management # run Claude Code with this checkout loaded
claude plugin test plugins/under-management # the tests
claude plugin validate --strict . # the manifests
UNDER_MANAGEMENT_URL points the mod at another copy of the game, for example http://localhost:5174/companion. The game's source isn't in this repo; the mod only knows the URL.
The layout: plugins/under-management/hooks/register.ts is the mod (the hooks, the strip, the window, the relay) and hooks/lib.ts is the plain part (the session code, the launchers for each platform, the command's arguments). Tests are in plugins/under-management/tests.
Under Management is made by Claude Code (and one person): the code, the graphics, the sound, and this mod.
MIT license. Under Management is a game from Interesting Systems.
hooks/register.ts 227 lines1/**
2 * Under Management, while Claude works.
3 *
4 * After Claude has worked on a turn for a while (20 s by default), a strip above the prompt offers
5 * the game: press 1 and it opens in a window of its own, 960 × 600, beside Claude Code. When the
6 * turn ends, the game pauses itself and says Claude has finished.
7 *
8 * Mods can't draw a game inside Claude Code, so the game is a web page in a Chrome or Edge app
9 * window. To tell it when Claude finishes, the mod posts to the game's site, under a random code
10 * made for this session: "working" and when it started, or "ready" and how long it took. Nothing
11 * else: no prompt, no answer, no file. Nothing is sent at all until the window has been opened.
12 */
13import type { EngineInterface, On } from 'claude-code'
14import {
15 DEFAULTS,
16 describe,
17 GAME_URL,
18 HELP,
19 launchers,
20 newCode,
21 type Platform,
22 parseArgs,
23 readSettings,
24 relayUrl,
25 type Settings,
26 windowUrl,
27} from './lib'
28
29const STORE_KEY = 'settings'
30
31let settings: Settings = { ...DEFAULTS }
32/** This session's code for the relay. */
33let code = ''
34/** A person is at the prompt. In a `-p` run there's nobody to play, so the mod does nothing. */
35let interactive = false
36let gameUrl = GAME_URL
37let platform: Platform | null = null
38let profile = ''
39
40/** The main loop's turn is running, since when (by `$.clock.now()`). */
41let working = false
42let turnStartedAt = 0
43/** The strip is up for this turn. */
44let offered = false
45/** The window has been opened in this session: from then on the relay hears every turn. */
46let opened = false
47let timer: { cancel(): void } | null = null
48
49export function register(on: On) {
50 on('session.start', async ($, e, next) => {
51 interactive = e.isInteractive
52 if (!interactive) return next(e)
53 code = newCode()
54 settings = readSettings(await $.store.get(STORE_KEY))
55 // For trying the mod against a local copy of the game.
56 const url = await $.env.get('UNDER_MANAGEMENT_URL')
57 if (url) gameUrl = url
58 try {
59 await $.command.register({
60 name: 'under-management',
61 description: 'Open Under Management in its own window, or change when it offers itself',
62 argumentHint: '[auto on|off] [delay <seconds>] [strip on|off] [help]',
63 immediate: true,
64 })
65 } catch {
66 // Taken by another plugin: the strip still works.
67 }
68 return next(e)
69 })
70
71 on('turn.start', async ($, e, next) => {
72 if (!interactive) return next(e)
73 working = true
74 offered = false
75 turnStartedAt = await $.clock.now()
76 timer?.cancel()
77 timer = $.clock.after(settings.delaySec * 1000, () => offer($))
78 if (opened) relay($, { state: 'working', since: turnStartedAt })
79 return next(e)
80 })
81
82 on('turn.complete', async ($, e, next) => {
83 // A subagent's run ends with a turn.complete of its own; only the main loop's counts.
84 if (!interactive || e.agentId || !working) return next(e)
85 working = false
86 timer?.cancel()
87 timer = null
88 if (offered) {
89 offered = false
90 $.ui.invalidate('ui.render')
91 }
92 if (opened) {
93 const now = await $.clock.now()
94 relay($, { state: 'ready', since: turnStartedAt, workedMs: Math.max(0, now - turnStartedAt) })
95 }
96 return next(e)
97 })
98
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 if (!offered || !settings.strip || !e.props.isWorking || e.props.hasSurvey) return next(e)
101 const { Box, Button, Text } = $.ui.resolve(e)
102 const theirs = await next(e)
103 const wide = e.props.bodyColumns >= 76
104 // One row, no border: a band taller than its maxRows scrolls, and then a bare 1 presses nothing.
105 const strip = Box({
106 key: 'under-management',
107 flexDirection: 'row',
108 justifyContent: 'space-between',
109 paddingLeft: 1,
110 // Claude Code puts the band's own [-] in the top right corner: keep clear of it.
111 paddingRight: 5,
112 children: [
113 Box({
114 flexDirection: 'row',
115 columnGap: 2,
116 children: [
117 Text({ children: ['Claude is still working.'] }),
118 Button({
119 key: 'play',
120 label: opened ? 'Open Under Management again' : 'Play Under Management',
121 hotkey: '1',
122 plain: true,
123 onPress: () => openGame($),
124 }),
125 ],
126 }),
127 ...(wide
128 ? [Text({ dimColor: true, children: [`Opens on its own: ${settings.autoOpen ? 'on' : 'off'} · /under-management`] })]
129 : []),
130 ],
131 })
132 return Box({ flexDirection: 'column', children: [strip, theirs] })
133 })
134
135 on('command.run', { command: 'under-management' }, async ($, e) => {
136 const p = parseArgs(e.args ?? '')
137 if (p.kind === 'help') return { text: `${HELP}\n\nNow: ${describe(settings)}.` }
138 if (p.kind === 'error') return { text: p.text }
139 if (p.kind === 'set') {
140 // Read again before writing: another session may have changed them since this one started.
141 settings = { ...readSettings(await $.store.get(STORE_KEY)), ...p.change }
142 await $.store.set(STORE_KEY, settings)
143 $.ui.invalidate('ui.render')
144 return { text: `Saved. ${describe(settings)}.` }
145 }
146 const ok = await openGame($)
147 return ok ? {} : { text: `No browser window would open. The game is at ${windowUrl(gameUrl, code)}` }
148 })
149}
150
151/** Claude has worked long enough: offer the game, or open it if that's the setting. */
152async function offer($: EngineInterface) {
153 if (!working) return
154 if (settings.autoOpen && !opened) {
155 await openGame($)
156 return
157 }
158 offered = true
159 $.ui.invalidate('ui.render')
160}
161
162/**
163 * Opens the game in its own window: tells the relay where this turn is first, so the window's
164 * first look already has Claude's clock, then tries each launcher until one works.
165 */
166async function openGame($: EngineInterface): Promise<boolean> {
167 offered = false
168 $.ui.invalidate('ui.render')
169 if (working) relay($, { state: 'working', since: turnStartedAt })
170 platform ??= await detectPlatform($)
171 if (!profile) profile = await profileDir($, platform)
172 for (const argv of launchers(platform, windowUrl(gameUrl, code), profile)) {
173 try {
174 const r = await $.process.run(argv, { timeoutMs: 10_000 })
175 if (r.exitCode === 0) {
176 opened = true
177 return true
178 }
179 } catch {
180 // That launcher isn't here; try the next.
181 }
182 }
183 return false
184}
185
186type Relay = { state: 'working'; since: number } | { state: 'ready'; since: number; workedMs: number }
187
188/** The posts in flight, one after another, so a slow "working" can't land after its "ready". */
189let posting: Promise<void> = Promise.resolve()
190
191/**
192 * One post to the relay, queued behind the last and never awaited: a fetch has no timeout, and a
193 * turn mustn't wait on the game's site. If it fails, the game simply doesn't hear.
194 */
195function relay($: EngineInterface, body: Relay) {
196 posting = posting.then(() => post($, body))
197 return posting
198}
199
200async function post($: EngineInterface, body: Relay) {
201 try {
202 await $.http.fetch(relayUrl(gameUrl), {
203 method: 'POST',
204 headers: { 'content-type': 'application/json', 'x-companion-key': code },
205 body: JSON.stringify(body),
206 })
207 } catch {
208 // Offline, or the site is down.
209 }
210}
211
212async function detectPlatform($: EngineInterface): Promise<Platform> {
213 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
214 try {
215 const r = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 })
216 return r.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
217 } catch {
218 return 'linux'
219 }
220}
221
222/** A browser profile of the game's own, so its window is its own and keeps its saves. */
223async function profileDir($: EngineInterface, p: Platform): Promise<string> {
224 if (p === 'windows') return `${(await $.env.get('LOCALAPPDATA')) ?? 'C:\\Temp'}\\UnderManagement\\window`
225 return `${(await $.env.get('HOME')) ?? '/tmp'}/.under-management/window`
226}
227hooks/lib.ts 166 lines1/**
2 * The plain parts of the mod, with no mods API in them, so the tests can call them directly:
3 * the session code, how to open a window on each platform, and the command's arguments.
4 */
5
6/** Where the game is served. The companion edition is the same build at /companion. */
7export const GAME_URL = 'https://under-management.vercel.app/companion'
8
9/** The window: 960 × 600 of game, plus the title bar Chrome draws above it. */
10export const WINDOW = { width: 960, height: 632 }
11
12export type Settings = {
13 /** Open the window without asking once Claude has worked this long. Off by default. */
14 autoOpen: boolean
15 /** Seconds of work before the strip offers the game (or the window opens on its own). */
16 delaySec: number
17 /** Show the strip at all. With it off, /under-management still opens the game. */
18 strip: boolean
19}
20
21export const DEFAULTS: Settings = { autoOpen: false, delaySec: 20, strip: true }
22
23/** The delay is kept between these, in seconds. */
24export const DELAY_MIN = 5
25export const DELAY_MAX = 600
26
27/** Crockford's base32 without I, L, O and U, the alphabet the game's relay checks codes against. */
28const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
29
30/**
31 * A fresh 16-character code for this session: 80 random bits. It's the only key to the relay's
32 * record of "working" or "ready", and it lives only in this session and in the window's address.
33 */
34export function newCode(random: (n: number) => Uint8Array = randomBytes): string {
35 const bytes = random(16)
36 let out = ''
37 for (const b of bytes) out += ALPHABET[b & 31]
38 return out
39}
40
41function randomBytes(n: number): Uint8Array {
42 return crypto.getRandomValues(new Uint8Array(n))
43}
44
45export type Platform = 'mac' | 'windows' | 'linux'
46
47/** What a window needs: an app window of its own, at our size, in a profile of its own. */
48function chromeFlags(url: string, profile: string): string[] {
49 return [
50 `--app=${url}`,
51 `--window-size=${WINDOW.width},${WINDOW.height}`,
52 `--user-data-dir=${profile}`,
53 '--no-first-run',
54 '--no-default-browser-check',
55 ]
56}
57
58/** On Linux, the first Chromium-family browser on the PATH, else the default browser. */
59const LINUX_OPEN = [
60 'url="$1"; shift',
61 'for b in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do',
62 ' if command -v "$b" >/dev/null 2>&1; then "$b" "$@" >/dev/null 2>&1 & exit 0; fi',
63 'done',
64 'xdg-open "$url" >/dev/null 2>&1 &',
65].join('\n')
66
67/**
68 * The commands to try, in order, to open the game. Each returns at once (`$.process.run` waits
69 * for its command to exit, so the browser itself is never the command). The first that exits 0
70 * wins; the last of each list opens the default browser instead.
71 */
72export function launchers(platform: Platform, url: string, profile: string): string[][] {
73 const flags = chromeFlags(url, profile)
74 if (platform === 'mac')
75 return [
76 ...['Google Chrome', 'Microsoft Edge', 'Brave Browser', 'Chromium'].map((app) => [
77 'open',
78 '-na',
79 app,
80 '--args',
81 ...flags,
82 ]),
83 ['open', url],
84 ]
85 if (platform === 'windows')
86 // Edge comes with Windows, so it's the one to ask for; `start` takes the first quoted
87 // argument as a window title, which is what "Under Management" is here.
88 return [
89 ['cmd', '/c', 'start', 'Under Management', 'msedge', ...flags],
90 ['cmd', '/c', 'start', 'Under Management', url],
91 ]
92 return [['sh', '-c', LINUX_OPEN, 'sh', url, ...flags]]
93}
94
95/** The address the window opens: the game, told which session's relay to listen to. */
96export function windowUrl(base: string, code: string): string {
97 const u = new URL(base)
98 u.searchParams.set('companion', code)
99 return u.toString()
100}
101
102/** Where the relay is: the game's own site. */
103export function relayUrl(base: string): string {
104 return `${new URL(base).origin}/api/companion`
105}
106
107export type Parsed =
108 | { kind: 'open' }
109 | { kind: 'help' }
110 | { kind: 'set'; change: Partial<Settings> }
111 | { kind: 'error'; text: string }
112
113const onOff = (w: string | undefined): boolean | null => (w === 'on' ? true : w === 'off' ? false : null)
114
115/** `/under-management [auto on|off] [delay <seconds>] [strip on|off]`, or `help`. */
116export function parseArgs(args: string): Parsed {
117 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
118 if (words.length === 0) return { kind: 'open' }
119 if (words[0] === 'help' || words[0] === 'settings' || words[0] === 'status') return { kind: 'help' }
120 const change: Partial<Settings> = {}
121 for (let i = 0; i < words.length; i += 2) {
122 const [key, value] = [words[i], words[i + 1]]
123 if (key === 'auto' || key === 'strip') {
124 const v = onOff(value)
125 if (v === null) return { kind: 'error', text: `"${key}" takes on or off.` }
126 if (key === 'auto') change.autoOpen = v
127 else change.strip = v
128 } else if (key === 'delay') {
129 const n = Number(value?.replace(/s$/, ''))
130 if (!Number.isFinite(n) || n < DELAY_MIN || n > DELAY_MAX)
131 return { kind: 'error', text: `"delay" takes a number of seconds from ${DELAY_MIN} to ${DELAY_MAX}.` }
132 change.delaySec = Math.round(n)
133 } else {
134 return { kind: 'error', text: `Not a setting: "${key}". Try /under-management help.` }
135 }
136 }
137 return { kind: 'set', change }
138}
139
140/** Settings from the store, with anything missing or malformed at its default. */
141export function readSettings(saved: unknown): Settings {
142 const s = (saved && typeof saved === 'object' ? saved : {}) as Partial<Record<keyof Settings, unknown>>
143 const delay = Number(s.delaySec)
144 return {
145 autoOpen: typeof s.autoOpen === 'boolean' ? s.autoOpen : DEFAULTS.autoOpen,
146 delaySec: Number.isFinite(delay) && delay >= DELAY_MIN && delay <= DELAY_MAX ? delay : DEFAULTS.delaySec,
147 strip: typeof s.strip === 'boolean' ? s.strip : DEFAULTS.strip,
148 }
149}
150
151/** The settings as one line. */
152export function describe(s: Settings): string {
153 return [
154 `Opens on its own: ${s.autoOpen ? 'on' : 'off'}`,
155 `after ${s.delaySec} s of work`,
156 `strip ${s.strip ? 'on' : 'off'}`,
157 ].join(' · ')
158}
159
160export const HELP = [
161 '/under-management open the game in its own window now',
162 '/under-management auto on|off open it on its own once Claude has worked a while (off by default)',
163 '/under-management delay <s> how long Claude works before the offer (20 s by default)',
164 '/under-management strip on|off show the offer above the prompt at all',
165].join('\n')
166