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

A small set of Claude Code mods. Type
/modsand a hub opens next to your session: live context/cost gauges, the status of everyclaude agentssession, 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.

Every screen switches between English and Korean with the hub's [ 한/EN ] button.
| Plugin / mode | What 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. |
├ feed | Session activity — when each claude agents session finishes ✔, asks ▲ or starts ●, newest first, with its one-line summary. |
├ memo | A memo for the session. send queues it until the session is idle, then submits it. One memo per session. |
├ tetris | Tetris with hold (c). Draws a half-height board in short panes. |
└ pokemon | Catch, evolve and battle with the tokens you spend — a fan-made game, see the notice. |
long-run-watch | Estimates 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').
claude agents sessions on the right, in the same colors as claude agents (needs yellow, working gray, completed green).▀▄ 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).macism the hub switches to an English layout while focused.

<img src="docs/images/tetris.png" alt="Tetris" width="420">
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.

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.

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.

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.


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.

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.
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.
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.
Choose in the hub's setup (s) or in /config → pane-tools; the hub's choice wins and applies from the next /mods.

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

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).
data/v2/csv): names, generations, evolutions, capture rates, baby/hatch info, Pokédex entries, base stats and type matchups;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.python3 pane-tools/bin/_pokedata.py.docs/images/ are captures of this program running in a test environment; the sprites in them were drawn from PokeAPI at runtime.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.version in the plugin's .claude-plugin/plugin.json so marketplace users see the update.MIT — see LICENSE. Pokémon names, PokeAPI data and sprites (Pokémon, badges, items) are not covered.
hooks/register.ts 200 lines1import 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