SLOPSHOPPER

pane-tools

/mods 모드 허브 — 세션의 컨텍스트·비용 게이지, claude agents 세션 상태·활동 피드, 메모·테트리스·포켓몬(도감·체육관·사천왕 배틀). tmux 패널·새 창·사용자 명령(cmux 등) 중 원하는 곳에 띄운다. Mods hub with gauges, memo and…

commandtoastprocesstimer
v0.18.0MITupdated 2026-10-23Shin-junyeob/working_place/pane-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pane-tools
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mods ⎿ pane-tools: 모드 허브를 띄웠습니다. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

working_place

A small set of Claude Code mods. Type /mods and a hub opens next to your session: live context/cost gauges, the status of every claude agents session, a session activity feed, a memo you can send to the session, Tetris, and an unofficial Pokémon collection game that grows with the tokens you spend.

The mods hub next to a Claude session

Every screen switches between English and Korean with the hub's [ 한/EN ] button.

What's inside

Plugin / modeWhat it does
pane-tools/mods opens the hub — even while the session is busy. Gauges, a partner Pokémon with a speech bubble, the awake sessions, and one button per mode.
├ feedSession activity — when each claude agents session finishes ✔, asks ▲ or starts ●, newest first, with its one-line summary.
├ memoA memo for the session. send queues it until the session is idle, then submits it. One memo per session.
├ tetrisTetris with hold (c). Draws a half-height board in short panes.
└ pokemonCatch, evolve and battle with the tokens you spend — a fan-made game, see the notice.
long-run-watchEstimates how long a Bash command will take (median of its last runs, or "(about 30 min)" in the description), shows it in the status line and warns when it overruns.

New modes are one file: drop a curses program at pane-tools/bin/<name>.py and the hub grows a [ <name> ] button. Files starting with _ are helpers, not buttons. Text goes through from _lang import L → L('한국어', 'English').

The hub

  • Gauges — context, cost and rate limits of the session it follows (each session's mod writes them every 2 s).
  • Speech bubble — the partner reacts in one line: a session waiting for input (yellow after 10 minutes), ready to evolve, rewards, medals, gym badges and eggs, a session that just finished (it claps when it shipped a PR or a deploy), or rustling grass.
  • Sessions — the awake claude agents sessions on the right, in the same colors as claude agents (needs yellow, working gray, completed green).
  • Partner art — real sprites: Kitty graphics (cmux, Ghostty, kitty) or Sixel (Windows Terminal 1.22+, WezTerm, foot), detected automatically, ▀▄ blocks otherwise. Inside tmux the hub enables passthrough for its own pane only; add set -as terminal-features ',xterm*:sixel' to ~/.tmux.conf (install.sh --tmux-layout does it).
  • Keys work with a Korean keyboard layout too; on macOS with macism the hub switches to an English layout while focused.

Session activity and memo

Session activity feed

Memo

Tetris

<img src="docs/images/tetris.png" alt="Tetris" width="420">

Pokémon

Tokens you spend in Claude Code turn into encounters. Fill the Pokédex generation by generation (1–9); the next generation opens when the current one is complete.

Pokémon screen with showcased medals and badges

Catching — one explore every 30k tokens. Starters come first, legendaries and mythicals last. Three balls per encounter plus the ones you own: Poké Ball, Great Ball (×1.5), Ultra Ball (×2) and Master Ball (always catches). A Poké Ball catches 35–70% depending on the species' original catch rate. Legendaries and mythicals only fall to Ultra or Master Balls. A species already in your bag shows up rarely and turns into Poké Balls. Trade 100 Poké Balls for an Ultra Ball with the bag's trade button.

Rewards from work — one Great Ball on any day a session finishes with a push, a PR merge or a deploy (read from the claude agents timelines; one per day). Three such days in a row (the 3rd, 6th, 9th day of a streak) bring an Ultra Ball.

Eggs — each wild catch has a 10% chance to come with an egg (up to three; the region conquest gives one too): a hidden first-stage species that hatches after its hatch steps × 5k tokens, shiny at 1/64, always with a bonus ball (Poké ×1 60% · ×2 20% · ×3 10% · Great 7% · Ultra 3%).

Weekly challenge — one task per week (changes on Monday, the same on every PC): catch 8, explore 15 times, win 5 gym/league battles, evolve twice, ship on 4 days or hatch an egg. Finishing it gives 2 Ultra Balls and 3 Great Balls.

Partner and evolution — the partner gains the tokens you spend and evolves past a threshold. It never evolves into a form already in your bag.

Shinies — 1/256 per encounter, counted apart from the normal form, shown in gold. Completing a generation gives that generation's Shiny Charm (I–IX), doubling the shiny rate for its species.

Medals — 41 achievements (catches, generations, shinies, legendaries, evolutions, Great Balls, region conquests, champions), drawn with item sprites. Pick up to five medals or gym badges in the medal screen to show at the top of the Pokémon screen.

Medals

Pokédex — each uncaught entry shows how to get it (wild · evolve from … · legendary/mythical), and the bottom line counts what is left in the wild, evolve-only and legendary, plus who is in the grass right now. Pick a species and press Enter (←→ switches generation): category, types, height and weight, base stats, weaknesses (×4 marked), resistances and immunities, the Pokédex entry and a crisp sprite.

Pokédex detail

Gyms, Elite Four and Champion

Eight gyms per opened generation, in the original type order (Alola uses its trial types). Build a team of up to six with the bag's team button or let the gym screen suggest one (pick, then apply) — it picks the combination from your Pokémon that raises the win rate most.

Gym list with real gym badges

Gym detail with win rate and a suggested team

A challenge costs two explores and plays a fully automatic battle line by line: base stats, level (15 + 12 per evolution stage + one per 50 battle XP, up to +40; a loss gives the team +30 XP, a win +60 + 10 × gym number — the bag shows each Pokémon's progress like Lv16 (10/50)), each Pokémon's own type attack, type matchups and speed. The first win gives that gym's badge (the real badge art); every battle gives the team experience. After a win q goes straight back to the hub, after a loss back to the prep screen.

A gym battle

Each gym's first win gives an Ultra Ball. Clearing all eight gyms of a generation gives the region conquest medal, a Master Ball and an egg — and opens that generation's Elite Four and Champion: five battles in a row (four members with four Pokémon each, a champion with six), four explores per run. The first clear gives the champion medal and a Master Ball — two Master Balls per generation.

Controls — arrow keys, Enter and q only (2026-10-21; there were too many letter shortcuts). Arrows move between buttons and Enter presses the highlighted one; in lists (bag, medals, gyms, Pokédex) ↑↓ picks the row and ←→ the button below it, which acts on that row. In the Pokédex ←→ switches generation, in the detail view the previous/next species. q goes back or closes. The mouse works too.

Install

Marketplace (just use it)

Requires Claude Code and python3 (tmux is optional).

/plugin install pane-tools --marketplace Shin-junyeob/working_place
/plugin install long-run-watch --marketplace Shin-junyeob/working_place

From a shell: claude plugin install pane-tools --marketplace Shin-junyeob/working_place. Then type /mods in any session. Auto-update is off for third-party marketplaces: turn it on in /plugin → Marketplaces → working-place, or run claude plugin update pane-tools@working-place.

Clone (hack on it, keep several PCs in sync)

git clone https://github.com/Shin-junyeob/working_place.git ~/.claude/mods
bash ~/.claude/mods/install.sh                   # load this folder in every new session + self-check
bash ~/.claude/mods/install.sh --tmux-layout     # optional: tmux mouse, Sixel passthrough, the `sett` layout
bash ~/.claude/mods/install.sh --pokemon-backup  # optional: back up the Pokémon save daily at 17:59
bash ~/.claude/mods/install.sh --auto-pull       # optional: pull new commits from GitHub at boot and every 10 minutes

install.sh sets CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, so every new session loads these mods and reloads them when a file changes. Don't enable the marketplace copy at the same time. --auto-pull adds two cron lines: one every 10 minutes and one @reboot that runs as soon as the machine (or WSL) starts, retrying every 2 s for up to 2 minutes until the network is up. It skips when there are local edits or unpushed commits and logs to ~/.claude/pane-tools/autopull.log. An open hub restarts itself when any file in pane-tools/bin/ changes; an open mode screen (Pokémon, Tetris…) gets the new code the next time you open it.

The sett layout (--tmux-layout) is claude agents on the left and, on the right, a spare shell, the activity feed and the hub — both started automatically.

Where the hub opens

Choose in the hub's setup (s) or in /config → pane-tools; the hub's choice wins and applies from the next /mods.

Hub setup

OptionValueBehavior
launcherauto (default)split the current tmux window, or open a new terminal window outside tmux
tmuxtmuxTarget empty → split the current window; set (e.g. dev:0.3) → replace that pane when it is an idle shell or the hub
windowa new terminal window — Windows Terminal on WSL, Terminal on macOS, x-terminal-emulator on Linux
commandyour own command; {cmd} becomes the hub command, e.g. wezterm cli split-pane -- bash -lc {cmd} or zellij run -- bash -lc {cmd}
followPaneempty (default)follow the session that ran /mods
e.g. dev:0.0 / cmux:0follow whichever Claude session is open in that pane
feedTargete.g. dev:0.2/mods also opens the activity feed there when that pane is an idle shell

The same options while installing

Data on disk

Nothing below is in the repository. Everything lives in ~/.claude/pane-tools/: settings.json (language, partner art color, hub setup), pokemon.json (your progress), pokedex.json · pokedex_detail.json · battle.json (data from PokeAPI), sprites/ (Pokémon, shiny/, gym badges/, items/ — fetched from PokeAPI one by one when first shown), memos/, usage/ (gauges and token counts per session), inbox/ (memos waiting to be sent).

Pokémon — unofficial fan project

  • This is an unofficial, non-commercial fan project. It is not affiliated with, endorsed or sponsored by Nintendo, Creatures, GAME FREAK or The Pokémon Company. Pokémon and Pokémon character names are trademarks of their respective owners.
  • No Pokémon data or images ship in this repository. Everything is downloaded on your machine from PokeAPI:
  • data — the public CSVs in PokeAPI/pokeapi (data/v2/csv): names, generations, evolutions, capture rates, baby/hatch info, Pokédex entries, base stats and type matchups;
  • images — PokeAPI/sprites: sprites/pokemon/<id>.png, sprites/pokemon/shiny/<id>.png, sprites/badges/<n>.png and sprites/items/<name>.png, each fetched the first time it is shown.
  • The code only holds sprite numbers and names, the gym/Elite Four type tables and the game rules. Re-download the data with python3 pane-tools/bin/_pokedata.py.
  • The screenshots in docs/images/ are captures of this program running in a test environment; the sprites in them were drawn from PokeAPI at runtime.
  • The Pokémon mode will be taken down on request from the rights holders.

Development

  • Self-checks: python3 pane-tools/bin/<name>.py --selftest for memo, tetris, hub, feed and pokemon (after the Pokédex has been downloaded once); python3 pane-tools/bin/_battle.py (battle rules) and python3 pane-tools/bin/_place.py (hub setup); claude plugin test pane-tools and claude plugin test long-run-watch.
  • Bump version in the plugin's .claude-plugin/plugin.json so marketplace users see the update.

License

MIT — see LICENSE. Pokémon names, PokeAPI data and sprites (Pokémon, badges, items) are not covered.

Source 1 files
hooks/register.ts 200 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// /mods 하나로 들어가는 모드 허브 — bin/hub.py 가 세션의 컨텍스트·비용 게이지와 bin/ 의 도구 버튼(memo, tetris, pokemon …)을 띄운다.
4// 어디에 띄울지는 사용자가 /config 에서 고른다(userConfig): tmux 패널 · 새 터미널 창 · 사용자 명령(cmux·wezterm·zellij …).
5// 메모는 bin/memo.py 가 받는 세션의 수신함 pane-tools/inbox/<sessionId>.json 에 [{id, text}] 로 쌓고,
6// 받는 세션의 이 mod 가 읽어 $.prompt.submit 으로 넣는다 — 작업 중이면 엔진이 한가해질 때까지 기다렸다 넣는다.
7// 게이지는 각 세션의 이 mod 가 pane-tools/usage/<sessionId>.json 에 적어 둔 값을 허브가 읽는다.
8// 글자는 pane-tools/settings.json 의 lang(허브의 [ 한/EN ] 버튼)을 따른다.
9const SHELLS = new Set(['bash', 'sh', 'zsh'])
10// 게임이 키우기 전 높이를 적어 두는 tmux 패널 옵션 — 게임이 도중에 갈아 끼워져도 다음 도구가 되돌린다
11const HEIGHT_OPT = '@pt_h'
12const TICK_MS = 2000
13
14export type Msg = { id: number; text: string }
15
16// 아직 안 넣은 메시지만, 오래된 순으로 — 기준은 마지막으로 넣은 id ($.fs 에 삭제가 없어 파일을 비우지 못한다)
17export function pending(msgs: Msg[], last: number): Msg[] {
18  return msgs.filter(m => m.id > last).sort((a, b) => a.id - b.id)
19}
20
21export function compose(msgs: Msg[]): string {
22  return msgs.map(m => m.text.trim()).filter(Boolean).join('\n\n')
23}
24
25// 갈아 끼워도 되는 패널인가 — 빈 셸이거나, 이 mod 가 띄운 도구(mark = <플러그인>/bin/)일 때만. 사용자가 돌리는 python 작업은 죽이지 않는다
26export function canReplace(current: string, startCommand: string, mark: string): boolean {
27  return SHELLS.has(current) || startCommand.includes(mark)
28}
29
30export type Opts = { launcher: string; tmuxTarget: string; followPane: string; command: string; feedTarget?: string }
31export type Where = { tmux: boolean; wsl?: string; mac: boolean }
32
33// 셸에 한 덩어리로 넘길 수 있게 작은따옴표로 감싼다
34export const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
35
36// 허브를 어떻게 띄울지 — 실행할 argv 와, 정해진 tmux 패널을 갈아 끼우는 경우 그 패널(slot)
37export function plan(o: Opts, cmd: string, w: Where): { argv: string[]; slot?: string } | { error: 'nocmd' } {
38  const mode = o.launcher === 'tmux' || o.launcher === 'window' || o.launcher === 'command' ? o.launcher : w.tmux ? 'tmux' : 'window'
39  if (mode === 'command') {
40    if (!o.command.includes('{cmd}')) return { error: 'nocmd' }
41    return { argv: ['bash', '-lc', o.command.split('{cmd}').join(shq(cmd))] }
42  }
43  if (mode === 'tmux') {
44    if (o.tmuxTarget) return { argv: ['tmux', 'respawn-pane', '-k', '-t', o.tmuxTarget, `${cmd}; exec bash`], slot: o.tmuxTarget }
45    return { argv: ['tmux', 'split-window', '-h', '-l', '45%', cmd] } // 허브를 닫으면 패널도 닫힌다
46  }
47  if (w.wsl) return { argv: ['wt.exe', '-w', 'new', 'wsl.exe', '-d', w.wsl, '-e', 'bash', '-lc', cmd] }
48  if (w.mac) return { argv: ['osascript', '-e', `tell application "Terminal" to do script ${JSON.stringify(cmd)}`] }
49  return { argv: ['x-terminal-emulator', '-e', 'bash', '-lc', cmd] }
50}
51
52const home = async ($: EngineInterface) => (await $.env.get('HOME')) ?? '/tmp'
53
54// 한/영 — 허브의 [ 한/EN ] 버튼이 적는 설정을 읽는다
55async function L($: EngineInterface, ko: string, en: string) {
56  try {
57    return JSON.parse(await $.fs.read(`${await home($)}/.claude/pane-tools/settings.json`)).lang === 'en' ? en : ko
58  } catch {
59    return ko
60  }
61}
62
63// 허브의 [ 설정 ] 이 같은 파일에 적은 띄울 곳 — /mods 마다 읽어 /config 값보다 먼저 쓴다(다시 불러올 필요 없게)
64const PLACE = ['launcher', 'tmuxTarget', 'followPane', 'command', 'feedTarget'] as const
65export async function prefs($: EngineInterface): Promise<Partial<Opts>> {
66  try {
67    const s = JSON.parse(await $.fs.read(`${await home($)}/.claude/pane-tools/settings.json`))
68    return Object.fromEntries(PLACE.filter(k => typeof s[k] === 'string').map(k => [k, s[k]]))
69  } catch {
70    return {}
71  }
72}
73
74let busy = false
75async function deliver($: EngineInterface) {
76  if (busy) return
77  busy = true
78  try {
79    const sid = await $.session.id()
80    const base = `${await home($)}/.claude/pane-tools/inbox/${sid}`
81    if (!(await $.fs.exists(`${base}.json`))) return
82    let msgs: Msg[]
83    try {
84      msgs = JSON.parse(await $.fs.read(`${base}.json`))
85    } catch {
86      return // 메모장이 쓰는 도중이면 다음 틱에
87    }
88    const key = `last:${sid}`
89    const todo = pending(msgs, ((await $.store.get(key)) as number | undefined) ?? 0)
90    const top = todo.at(-1)?.id
91    if (top === undefined) return
92    const last = ((await $.store.get(key)) as number | undefined) ?? 0
93    // 기준을 먼저 올린다 — 다시 불러온 뒤 타이머가 겹쳐도 두 번 넣지 않게
94    await $.store.set(key, top)
95    const text = compose(todo)
96    // 한가해져 실제로 들어간 뒤에야 확인 파일을 쓴다 — 메모장의 '✓ 전달했습니다' 가 거짓이 되지 않게
97    const r = text ? await $.prompt.submit({ text, asUser: true }) : undefined
98    if (r?.drop !== undefined) {
99      await $.store.set(key, last) // 거절됐으면 되돌려 다음 틱에 다시 넣는다
100      $.ui.toast(`${await L($, '메모를 넣지 못해 다시 시도합니다', 'Could not deliver the memo, retrying')}: ${r.drop}`)
101      return
102    }
103    await $.fs.write(`${base}.ack`, String(top))
104  } finally {
105    busy = false
106  }
107}
108
109// 설정대로 허브를 띄우고, 사용자에게 보일 한 줄을 돌려준다
110async function openHub($: EngineInterface, o: Opts) {
111  const root = $.plugin.root
112  const mark = `${root}/bin/`
113  let cmd = `python3 ${shq(`${root}/bin/hub.py`)} --session ${await $.session.id()}`
114  if (o.followPane) cmd += ` --follow-pane ${shq(o.followPane)}`
115  const where: Where = {
116    tmux: (await $.process.run(['tmux', 'has-session']).catch(() => ({ exitCode: 1 }))).exitCode === 0,
117    wsl: await $.env.get('WSL_DISTRO_NAME'),
118    mac: (await $.process.run(['uname', '-s']).catch(() => ({ stdout: '' }))).stdout.trim() === 'Darwin',
119  }
120  const p = plan(o, cmd, where)
121  if ('error' in p) return L($, '사용자 명령에 {cmd} 자리가 없습니다 — /config 의 pane-tools command 를 확인해 주세요',
122    'The custom command has no {cmd} placeholder — check pane-tools "command" in /config')
123  if (p.slot) {
124    const info = await $.process.run(['tmux', 'display', '-p', '-t', p.slot, '#{pane_current_command}\t#{pane_start_command}'])
125    // 물어보는 것 자체가 실패하면 패널이 바쁜 게 아니라 tmux 에 못 닿은 것 — 소켓 파일이 지워지면 이렇다(2026-10-21 실제로 겪음)
126    if (info.exitCode !== 0) return L($, `tmux 패널 ${p.slot} 에 연결할 수 없습니다: ${info.stderr.trim()} — 패널 이름을 확인하거나, 소켓이 사라졌다면 셸에서 pkill -USR1 -o -x tmux 로 되살리세요.`,
127      `Cannot reach tmux pane ${p.slot}: ${info.stderr.trim()} — check the pane name, or if the socket vanished run pkill -USR1 -o -x tmux.`)
128    const [cur = '', start = ''] = info.stdout.trim().split('\t')
129    if (!canReplace(cur, start, mark)) return L($, `패널 ${p.slot} 에서 ${cur || '다른 프로그램'}이 돌고 있어 열지 않았습니다. 끝낸 뒤 다시 입력해 주세요.`,
130      `Pane ${p.slot} is running ${cur || 'another program'}, so the hub was not opened. Try again when it finishes.`)
131    const h = (await $.process.run(['tmux', 'show', '-pv', '-t', p.slot, HEIGHT_OPT])).stdout.trim()
132    if (h) {
133      await $.process.run(['tmux', 'resize-pane', '-t', p.slot, '-y', h])
134      await $.process.run(['tmux', 'set', '-pu', '-t', p.slot, HEIGHT_OPT])
135    }
136  }
137  const r = await $.process.run(p.argv).catch((e: unknown) => ({ exitCode: 1, stderr: String(e) }))
138  if (r.exitCode !== 0) return `${await L($, '모드 허브를 열지 못했습니다', 'Could not open the mods hub')} (${p.argv[0]}): ${r.stderr.trim()}`
139  return (await L($, '모드 허브를 띄웠습니다.', 'Opened the mods hub.')) + (await openFeed($, o.feedTarget, root))
140}
141
142// 세션 활동 피드를 지정한 tmux 패널에도 — 빈 셸일 때만 띄우고, 이미 피드가 돌면 그대로(스크롤을 잃지 않게), 다른 건 건드리지 않는다
143async function openFeed($: EngineInterface, target: string | undefined, root: string) {
144  if (!target) return ''
145  const info = await $.process.run(['tmux', 'display', '-p', '-t', target, '#{pane_current_command}\t#{pane_tty}'])
146  const [cur = '', tty = ''] = info.stdout.trim().split('\t')
147  if (info.exitCode !== 0 || !SHELLS.has(cur)) return '' // 패널이 없거나 다른 프로그램
148  // respawn 으로 띄운 피드는 겉의 bash -c 가 보여 tmux 가 'bash' 라고 한다 — 그 tty 에 feed.py 가 도는지 직접 본다
149  const ps = await $.process.run(['ps', '-t', tty, '-o', 'args='])
150  if (ps.stdout.includes('feed.py')) return ''
151  const r = await $.process.run(['tmux', 'respawn-pane', '-k', '-t', target, `python3 ${shq(`${root}/bin/feed.py`)}; exec bash`])
152  return r.exitCode === 0 ? L($, ` 피드도 ${target} 에 띄웠습니다.`, ` Also opened the feed in ${target}.`) : ''
153}
154
155// 허브 게이지용 — 컨텍스트·비용·사용 한도를 2초마다, 바뀌었을 때만 적는다.
156// gained: 컨텍스트 토큰이 늘어난 만큼의 누적(압축으로 줄면 무시하고 다시 늘면 또 센다) — pokemon 모드의 경험치.
157// 다시 불러와도 이어지게 $.store 에 두고, 불러온 직후 첫 값은 기준으로만 삼는다(두 번 세지 않게).
158let lastUsage = ''
159let lastTokens: number | undefined
160let gained: number | undefined
161export function grow(prev: number | undefined, now: number | undefined): number {
162  return prev !== undefined && now !== undefined && now > prev ? now - prev : 0
163}
164async function writeUsage($: EngineInterface) {
165  const u = await $.session.usage()
166  const sid = await $.session.id()
167  gained ??= ((await $.store.get(`gain:${sid}`)) as number | undefined) ?? 0
168  const add = grow(lastTokens, u.context.tokens)
169  lastTokens = u.context.tokens ?? lastTokens
170  if (add) {
171    gained += add
172    await $.store.set(`gain:${sid}`, gained)
173  }
174  const text = JSON.stringify({ context: u.context, cost: u.cost?.usd ?? null, rateLimits: u.rateLimits, gained })
175  if (text === lastUsage) return
176  lastUsage = text
177  await $.fs.write(`${await home($)}/.claude/pane-tools/usage/${sid}.json`, text)
178}
179
180export const register: Register = (on, options) => {
181  const o: Opts = {
182    launcher: String(options.launcher ?? 'auto'),
183    tmuxTarget: String(options.tmuxTarget ?? ''),
184    followPane: String(options.followPane ?? ''),
185    command: String(options.command ?? ''),
186    feedTarget: String(options.feedTarget ?? ''),
187  }
188  on('session.start', async ($, e, next) => {
189    // immediate: 작업 중에도 기다리지 않고 바로 연다 (/reload-plugins 처럼)
190    await $.command.register({ name: 'mods', description: '모드 허브 열기 · Open the mods hub (gauges, memo, games)', immediate: true })
191    $.clock.every(TICK_MS, () => {
192      void deliver($)
193      void writeUsage($).catch(() => undefined)
194    })
195    return next(e)
196  })
197
198  on('command.run', { command: 'mods' }, async $ => ({ text: await openHub($, { ...o, ...(await prefs($)) }) }))
199}
200