SLOPSHOPPER

Under Management

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…

newbandcommandprocessnetworktimer
A shopper browsing a rack in a slop shop
README

Under Management, while Claude works

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.

Claude Code with the strip above the prompt, and Under Management open in its own window beside it

Install

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.

How it works

A real session, recorded in a real terminal. The project is a toy with a 25-second test suite.

Claude Code: the strip appears after 20 seconds of work, 1 is pressed, and Claude's answer arrives

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 game running with "Claude is working" at the top, then pausing itself with a card: Claude has finished

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.

The game

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.

Commands

CommandWhat it does
/under-managementOpen 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 helpList these, and show the current settings

Settings are saved and apply to every session.

The strip, as it appears above the prompt: "Claude is still working. 1: Play Under Management"

Turning it off

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.
  • Uninstalling it from /plugin removes it.

What it sends

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:

  • Nothing at all until you open the game window in a session. Install it, never press 1, and the mod never makes a request.
  • After the window is open, once per turn: 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.
  • Never the prompt, never the answer, never a file name or anything else about your work. The server keeps no IP addresses.

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.

More houses

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.

Status

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.

Developing

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.

About

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.

Source 2 files
hooks/register.ts 227 lines
1/**
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}
227
hooks/lib.ts 166 lines
1/**
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