SLOPSHOPPER

GAME MODE · Save Point

/save writes an 8-bit save screen (cleared, quest log, decisions, inventory, a password with the resume prompt copied); /load <password>; a title screen with…

newbandspinnerrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-save-point
› fix the failing auth test and add an audit log call ╭───────────────────────────╮ │ game-save-point │ ⏺ Read(src/auth.ts) │ ♪ 세이브 완료 · SP02-L9HU-MOON │ ⎿ 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 › /save ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ◆ ★ SAVE POINT ★ SLOT 1/1 · DAY 1 · 08:53 ║ ║ QUEST · save LV 02 ███░░░░░░░ EXP ║ ║ CTX █████░░░░░ 49% ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ ╔═════════════════════════════════════════════════════════╗╔═════════════════════════════════════════════════════════╗ ║ ▣ CLEARED +0 EXP ║║ ? 어떻게 하시겠습니까? ║ ║ (없음) ║║ (없음) ║ ║ ║╚═════════════════════════════════════════════════════════╝ ║ ▢ QUEST LOG 남은 퀘스트 0 ║╔═════════════════════════════════════════════════════════╗ ║ (없음) ║║ ◆ INVENTORY ║ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╔═══════════════════════════════════════════════════════════════════════════════════════════════════ ║ ◆ ★ SAVE POINT ★ SLOT 1/1 ║ QUEST · save LV 0 ║ CT ╚═══════════════════════════════════════════════════════════════════════════════════════════════════ ╔════════════════════════════════════════════════╗╔════════════════════════════════════════════════╗ ║ ▣ CLEARED +0 EXP ║║ ? 어떻게 하시겠습니까? ║ ║ (없음) ║║ (없음) ║ ║ ║╚════════════════════════════════════════════════╝ ║ ▢ QUEST LOG 남은 퀘스트 0 ║╔════════════════════════════════════════════════╗ ║ (없음) ║║ ◆ INVENTORY ║ ╚════════════════════════════════════════════════╝║ ┌────────────────────────────────────────────┐ ║ ║ │ ? ??? │ ║ ║ └────────────────────────────────────────────┘ ║ ╚════════════════════════════════════════════════╝ ╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ PASSWORD SP02-L9HU-MOON COPIED! ▸ /load SP02-L9HU-MOON · 빈 입력창 Tab 이어하기 ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▶ 「OK」 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

GAME MODE · Save Point

/save turns the session into an RPG save screen — the same five things a good hand-off note holds — and the next session in that project opens on a title screen to continue from it.

The save screen, drawn in the transcript (two columns from 90 columns wide):

╔══════════════════════════════════════════════════════════════════════════════╗
║ ◆ ★ SAVE POINT ★                                       SLOT 1/3 · DAY 1 · 00:02 ║
║ QUEST · src 파일 확인 및 npm test 실행                         LV 01 ███░░░░░░░ EXP ║
║                                                                CTX ░░░░░░░░░░ 4% ║
╚══════════════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════╗╔═══════════════════════════════════════╗
║ ▣ CLEARED                +180 EXP ║║ ? 어떻게 하시겠습니까?                     ║
║   ✓ npm test 실행: 1 통과, 2 실패  ║║ ▶ 타임스탬프 단위 불일치                    ║
║ ▢ QUEST LOG         남은 퀘스트 3 ║║     src/auth.js를 초로 바꿀지, 테스트를…     ║
║ MAIN test/auth.test.js:16 수정    ║╚═══════════════════════════════════════╝
║ SIDE README 갱신                  ║║ ◆ INVENTORY  [◆ auth.js] [◆ auth.test.js] [? ???] ║
╚═══════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════════════════╗
║ PASSWORD  SP01-MCS4-MOON   COPIED!   ▸ /load SP01-MCS4-MOON · 빈 입력창 Tab 이어하기 ║
╚══════════════════════════════════════════════════════════════════════════════╝
╭──────────────────────────────────────────────────────────────────────────────╮
│ ▶ 「/tmp/gm-demo에서 npm test를 실행하면 3개 중 2개가 실패합니다. …」             │
╰──────────────────────────────────────────────────────────────────────────────╯
ScreenStatus summary
▣ CLEARED (+EXP)지금까지 한 일
▢ QUEST LOG (MAIN · SIDE)아직 남은 일 (SIDE: marks an optional one)
? 어떻게 하시겠습니까?내가 결정해야 할 것 (choice — what it leads to)
◆ INVENTORY산출물/파일/링크
PASSWORD · ▶ 「…」다음에 이어서 할 때 붙여넣을 프롬프트: copied to the clipboard (COPIED!), and a code (SP07-KTX9-MOON) that /load takes in any project

How it is written: $.model.fork asks the session's own model over the live transcript (the prompt cache serves it), in the language you wrote in. A session resumed with --continue that has not sent anything yet has nothing to fork, so the stored transcript is summarized instead.

Title screen. A new session in a folder with saves (within 14 days) opens on one, above the HUD:

╔══════════════════════════════════════════════════════════════════════════════╗
║ ◆ S A V E   P O I N T ◆          A CLAUDE CODE MOD · REASONOFMOON    PRESS START ║
║ [ ▸ CONTINUE ]  [ NEW GAME ]  [ LOAD ]  빈 입력창 Tab: 이어하기 · ctrl+x tab: 메뉴   ║
║  ▸ SLOT 1          LV 01 · DAY 1   SLOT 2          LV 01 · DAY 1   SLOT 3  ---      ║
║  로그인 버그 수정 · 퀘스트 2        테스트 정리 · CLEAR              - NO DATA -       ║
╚══════════════════════════════════════════════════════════════════════════════╝

CONTINUE puts the latest resume prompt in the prompt box, LOAD shows a button per slot, NEW GAME closes it; so does the first prompt you send. Focus it with a click or ctrl+x tab, then c, n, l, 1–3. Meanwhile the status line says ◆ SAVE 2시간 전 · 로그인 버그 수정 · Tab 이어하기 · /load, the empty prompt box offers the resume prompt (Tab takes it), and the hint line adds ▸ Tab 이어하기 · /load.

Real run: examples/RUN-2026-10-06.md.

Part of the GAME MODE pack; the HUD's 세이브 button runs /save. Requires Claude Code 2.1.287+; built and tested on 2.1.291.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-save-point@bitgame-mods

Commands

Command
/savesave now (one model call)
/save <note>save with a note the summary should keep in mind
/save showthe latest save again, no model call
/save listthe last five saves of this project, with their passwords
/load [n]put save n's resume prompt in the prompt box (1 = latest)
/load SP07-KTX9-MOONthe save with that password, from any project

Settings

intensity (casual)off shows saves as plain text and no title screen; casual/hardcore draw them
palette (nes)nes · gameboy · amber · theme
maxAgeDays (14)how old a save may be and still be offered at start

Saves are kept in the plugin's store per project root (last five), so they survive new sessions.

한국어

/save로 현재 세션을 RPG 세이브 화면(한 일·남은 일·결정할 것·산출물·이어하기 프롬프트)으로 저장하고, 이어하기 프롬프트를 클립보드에 복사하며 비밀번호(SP07-KTX9-MOON)를 줍니다. 같은 프로젝트에서 새 세션을 열면 타이틀 화면(CONTINUE·NEW GAME·LOAD)이 뜨고, 빈 입력창에서 Tab으로 이어갈 수 있습니다. /load 비밀번호로 다른 프로젝트에서도 불러옵니다.

Source 3 files
hooks/register.tsx 666 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Save, TitleState } from '../types'
5import { bar, intensityOf, paletteOf, windowProps } from './palette'
6
7// GAME MODE · SAVE POINT
8//
9// /save        Claude writes a save of this session (what was cleared, the
10//              quest log, decisions waiting on you, a resume prompt, the
11//              items made) through $.model.fork, over the same transcript,
12//              so the prompt cache serves it. In a session resumed with
13//              --continue that has not sent anything yet, the stored
14//              transcript is summarized instead. Kept per project, last 5.
15//              The resume prompt goes to the clipboard, and the save gets a
16//              password (SP07-KTX9-MOON) that /load takes.
17// /save show   the latest save again · /save list   the last five
18// /load [n|code]  puts a save's resume prompt in the prompt box (1 = latest)
19//
20// Next session in the same project: a title screen above the prompt
21// (CONTINUE · NEW GAME · LOAD, the last three slots), the status line names
22// the save, and the empty prompt box offers its resume prompt on Tab.
23
24const MAX_SAVES = 5
25const HEADER = '◆ SAVE POINT · '
26const CODE_LINE = '[CODE] '
27const CODE_RE = /^SP\d{2}-[A-Z0-9]{4}-MOON$/
28
29const FORK_PROMPT = [
30  'Write a SAVE POINT for this coding session so the user can resume it later in a new session.',
31  'Reply with one JSON object only, no prose and no code fence, in this shape:',
32  '{"title": "...", "done": ["..."], "todo": ["..."], "decide": ["..."], "resume": "...", "artifacts": ["..."]}',
33  '- title: the quest in a few words',
34  '- done: what was finished, short items, at most 6',
35  '- todo: what is left, at most 6, the main next step first; start an optional item with "SIDE: "',
36  '- decide: decisions only the user can make, at most 4, each as "choice — what it leads to" (an empty list when none)',
37  '- resume: one prompt the user can paste into a new session to continue exactly here; name the files and the next concrete step',
38  '- artifacts: files, branches, PRs or links produced, at most 8',
39  'Write the strings in the language the user wrote in. State only what happened in this session.',
40].join('\n')
41
42type Slots = Record<string, Save[]>
43
44const TITLE_OFF: TitleState = { isShown: false, isLoading: false }
45const title = atom({ plugin: 'game-save-point', key: 'title' } as const, TITLE_OFF)
46const hits = atom({ plugin: 'game-save-point', key: 'hits' } as const, 0)
47
48export const register: Register = (on, options) => {
49  const intensity = intensityOf(options.intensity)
50  const pal = paletteOf(options.palette)
51  const maxAgeDays = Math.max(1, Math.min(365, Number(options.maxAgeDays ?? 14) || 14))
52
53  on('session.start', async ($, e, next) => {
54    await $.command.register({
55      name: 'save',
56      description: 'GAME MODE save point: save this session (status summary + resume prompt), or show / list saves',
57      argumentHint: '[note|show|list]',
58    })
59    await $.command.register({
60      name: 'load',
61      description: 'GAME MODE save point: put a save\'s resume prompt in the prompt box (number, or its password)',
62      argumentHint: '[n|SP00-XXXX-MOON]',
63    })
64
65    const root = await $.session.root()
66    const last = (await slotsOf($))[root]?.[0]
67    const now = await $.clock.now()
68    if (last !== undefined && now - last.savedAt <= maxAgeDays * 86_400_000) {
69      $.ui.status(`◆ SAVE ${ago(now - last.savedAt)} · ${last.title} · Tab 이어하기 · /load`)
70      offer($, last.resume, 0)
71      if (intensity !== 'off' && (await $.session.turns()) === 0) await update($, title, () => ({ isShown: true, isLoading: false }))
72    }
73    return next(e)
74  })
75
76  // The first prompt starts the game: the title screen and the status line step aside.
77  on('prompt.submit', async ($, e, next) => {
78    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
79      const t = await read($, title)
80      if (t.isShown) await update($, title, () => TITLE_OFF)
81      $.ui.status(undefined)
82    }
83    return next(e)
84  }).catch(($, e, next) => next(e))
85
86  // LV and EXP on the save screen count what the HUD counts: calls that ran clean.
87  on('tool.call', async ($, e, next) => {
88    const ran = await next(e)
89    if (ran.deny === undefined && ran.isError !== true) await update($, hits, n => n + 1)
90    return ran
91  }).catch(($, e, next) => next(e))
92
93  on('command.run', { command: 'save' }, async ($, e) => {
94    const root = await $.session.root()
95    const slots = await slotsOf($)
96    const saves = slots[root] ?? []
97    const sub = e.args.trim()
98
99    if (sub === 'show') {
100      const last = saves[0]
101      return { text: last ? screenText(last, 1, saves.length, false) : 'No save in this project yet. Type /save to make one.' }
102    }
103    if (sub === 'list') {
104      if (saves.length === 0) return { text: 'No save in this project yet. Type /save to make one.' }
105      return {
106        text:
107          saves.map((s, i) => `${i + 1}. ${stamp(s.savedAt)} · ${s.title}${s.code ? ` · ${s.code}` : ''}`).join('\n') +
108          '\n\n/load <n> or /load <password> puts that save\'s resume prompt in the prompt box.',
109      }
110    }
111
112    const prompt = sub === '' ? FORK_PROMPT : `${FORK_PROMPT}\nThe user's note for this save: ${sub}`
113    const reply = await summarize($, prompt)
114    if (reply.text === undefined) return { text: reply.why, exitCode: reply.isEmpty ? 0 : 1 }
115
116    const usage = await $.session.usage()
117    const savedAt = await $.clock.now()
118    const count = await read($, hits)
119    const firstAt = saves.length === 0 ? savedAt : Math.min(...saves.map(s => s.savedAt))
120    const save: Save = {
121      ...parseSave(reply.text),
122      v: 1,
123      savedAt,
124      root,
125      turns: await $.session.turns(),
126      usd: usage.cost?.usd ?? null,
127      contextUsed: usage.context.percent ?? null,
128      hits: count,
129      day: Math.floor((savedAt - firstAt) / 86_400_000) + 1,
130      code: codeOf(levelOf(count).level, savedAt),
131    }
132    const kept = [save, ...saves].slice(0, MAX_SAVES)
133    await $.store.set('slots', { ...slots, [root]: kept })
134    const copied = await $.ui
135      .copy({ text: save.resume })
136      .then(r => r.isCopied)
137      .catch(() => false)
138    await update($, title, () => TITLE_OFF)
139    $.ui.toast(`♪ 세이브 완료 · ${save.code}`)
140    return { text: screenText(save, 1, kept.length, copied) }
141  })
142
143  on('command.run', { command: 'load' }, async ($, e) => {
144    const root = await $.session.root()
145    const slots = await slotsOf($)
146    const saves = slots[root] ?? []
147    const arg = e.args.trim().toUpperCase()
148    let found: { save: Save; slot: number; of: number } | undefined
149    if (CODE_RE.test(arg)) {
150      // A password restores its save from any project.
151      for (const list of Object.values(slots)) {
152        const i = list.findIndex(s => s.code === arg)
153        if (i !== -1) found = { save: list[i] as Save, slot: i + 1, of: list.length }
154      }
155      if (found === undefined) return { text: `No save has the password ${arg}. /save list shows this project's saves.` }
156    } else {
157      const n = Math.max(1, Number.parseInt(arg || '1', 10) || 1)
158      const save = saves[n - 1]
159      if (save === undefined) return { text: saves.length === 0 ? 'No save in this project yet.' : `There are ${saves.length} saves; /save list shows them.` }
160      found = { save, slot: n, of: saves.length }
161    }
162    const filled = await $.prompt.fill({ text: found.save.resume })
163    $.ui.status(undefined)
164    await update($, title, () => TITLE_OFF)
165    const tail = filled.isFilled ? '\n\nThe resume prompt is in the prompt box: edit it or press Enter.' : `\n\nResume prompt:\n${found.save.resume}`
166    return { text: screenText(found.save, found.slot, found.of, false) + tail }
167  })
168
169  // TITLE: CONTINUE · NEW GAME · LOAD and the last three slots, above whatever else the band holds.
170  // While the resume prompt waits in the prompt box, the hint line says how to take it.
171  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
172    if (intensity === 'off' || e.props.isDraft || e.props.isWorking || !(await read($, title)).isShown) return next(e)
173    const mine = '▸ Tab 이어하기 · /load'
174    const tail = e.props.tail === undefined || e.props.tail === '' ? mine : `${e.props.tail} · ${mine}`
175    return next({ ...e, props: { ...e.props, tail } })
176  })
177
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    if (intensity === 'off' || e.props.hasSurvey) return next(e)
180    const t = await read($, title)
181    if (!t.isShown) return next(e)
182    const root = await $.session.root()
183    const saves = ((await slotsOf($))[root] ?? []).slice(0, 3)
184    const below = await next(e)
185    const latest = saves[0]
186    if (latest === undefined) return below
187
188    const { Box, Text, Button } = $.ui.resolve(e)
189    const cardWidth = Math.max(18, Math.floor((e.props.bodyColumns - 6) / 3) - 1)
190    const load = (save: Save) => () => {
191      void $.prompt.fill({ text: save.resume })
192      void update($, title, () => TITLE_OFF)
193      $.ui.status(undefined)
194    }
195    const close = () => {
196      void update($, title, () => TITLE_OFF)
197    }
198    const toggleLoad = () => {
199      void update($, title, v => ({ ...v, isLoading: !v.isLoading }))
200    }
201
202    // Six rows in all, so the HUD under it still fits the band.
203    const card = (i: number) => {
204      const s = saves[i]
205      const isFirst = i === 0
206      const fill = pal.win === '' ? {} : { backgroundColor: isFirst ? pal.frame : pal.off }
207      if (s === undefined) {
208        return (
209          <Box key={`slot${i}`} flexDirection="column" paddingX={1} width={cardWidth} {...fill}>
210            <Text color={pal.dim}>{`SLOT ${i + 1}  ---`}</Text>
211            <Text color={pal.dim}>- NO DATA -</Text>
212          </Box>
213        )
214      }
215      const left = s.todo.length
216      return (
217        <Box key={`slot${i}`} flexDirection="column" paddingX={1} width={cardWidth} {...fill}>
218          <Box flexDirection="row" justifyContent="space-between">
219            <Text color={isFirst ? pal.title : pal.text} bold>{`${isFirst ? '▸ ' : ''}SLOT ${i + 1}`}</Text>
220            <Text color={pal.title}>{`LV ${String(levelOf(s.hits ?? 0).level).padStart(2, '0')} · DAY ${s.day ?? 1}`}</Text>
221          </Box>
222          <Text color={pal.text} wrap="truncate-end">
223            {`${s.title} · ${left > 0 ? `퀘스트 ${left}` : 'CLEAR'}`}
224          </Text>
225        </Box>
226      )
227    }
228
229    const screen = (
230      <Box {...windowProps(pal)} flexDirection="column">
231        <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
232          <Text color={pal.title} bold>
233            ◆ S A V E   P O I N T ◆
234          </Text>
235          <Text color={pal.dim}>A CLAUDE CODE MOD · REASONOFMOON</Text>
236          <Text color={pal.title} bold>
237            PRESS START
238          </Text>
239        </Box>
240        <Box flexDirection="row" columnGap={2}>
241          <Button key="continue" label="▸ CONTINUE" hotkey="c" variant="primary" onPress={load(latest)} />
242          <Button key="new" label="NEW GAME" hotkey="n" onPress={close} />
243          <Button key="load" label="LOAD" hotkey="l" onPress={toggleLoad} />
244          <Text color={pal.dim} wrap="truncate-end">
245            빈 입력창 Tab: 이어하기 · ctrl+x tab: 메뉴
246          </Text>
247        </Box>
248        <Box flexDirection="row" columnGap={1}>
249          {card(0)}
250          {card(1)}
251          {card(2)}
252        </Box>
253        {t.isLoading && (
254          <Box flexDirection="row" columnGap={1}>
255            {saves.map((s, i) => (
256              <Button key={`load${i + 1}`} label={`${i + 1}: SLOT ${i + 1} 불러오기`} hotkey={String(i + 1)} onPress={load(s)} />
257            ))}
258          </Box>
259        )}
260      </Box>
261    )
262    return (
263      <Box flexDirection="column">
264        {screen}
265        {below}
266      </Box>
267    )
268  })
269
270  // The save screen, drawn from the row's own text so each row shows its own save.
271  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
272    if (intensity === 'off' || (e.props.command !== 'save' && e.props.command !== 'load')) return next(e)
273    const s = parseScreen(e.props.text)
274    if (s === undefined) return next(e)
275    const { Box, Text } = $.ui.resolve(e)
276    const columns = e.viewport?.columns ?? 100
277    const isWide = columns >= 90
278    const lv = levelOf(s.hits ?? 0)
279    const half = isWide ? '50%' : '100%'
280
281    const items = (list: string[], render: (item: string, i: number) => RenderElement): RenderElement | RenderElement[] =>
282      list.length === 0 ? <Text color={pal.dim}>  (없음)</Text> : list.map(render)
283
284    const header = (
285      <Box {...windowProps(pal)} flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
286        <Box flexDirection="column" flexShrink={1}>
287          <Text color={pal.title} bold>
288            ◆ ★ SAVE POINT ★
289          </Text>
290          <Text color={pal.text}>{`QUEST · ${s.title}`}</Text>
291        </Box>
292        <Box flexDirection="column" alignItems="flex-end">
293          <Text color={pal.dim}>{[s.slot, s.day ? `DAY ${s.day}` : '', s.time].filter(x => x !== '').join(' · ')}</Text>
294          {s.hits !== null && <Text color={pal.ok}>{`LV ${String(lv.level).padStart(2, '0')} ${bar((lv.into / lv.span) * 100, 10)} EXP`}</Text>}
295          {s.contextUsed !== null && <Text color={pal.info}>{`CTX ${bar(s.contextUsed, 10)} ${s.contextUsed}%`}</Text>}
296        </Box>
297      </Box>
298    )
299
300    const left = (
301      <Box {...windowProps(pal)} flexDirection="column" width={half}>
302        <Box flexDirection="row" justifyContent="space-between">
303          <Text color={pal.ok} bold>
304            ▣ CLEARED
305          </Text>
306          <Text color={pal.title}>{`+${s.done.length * 60} EXP`}</Text>
307        </Box>
308        {items(s.done, d => (
309          <Box flexDirection="row">
310            <Text color={pal.ok}>{'  ✓ '}</Text>
311            <Box flexShrink={1}>
312              <Text color={pal.text}>{d}</Text>
313            </Box>
314          </Box>
315        ))}
316        <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
317          <Text color={pal.title} bold>
318            ▢ QUEST LOG
319          </Text>
320          <Text color={pal.title}>{`남은 퀘스트 ${s.todo.length}`}</Text>
321        </Box>
322        {items(s.todo, q => {
323          const isSide = /^SIDE:\s*/i.test(q)
324          return (
325            <Box flexDirection="row" columnGap={1}>
326              <Text color={isSide ? pal.dim : pal.title} bold>
327                {isSide ? 'SIDE' : 'MAIN'}
328              </Text>
329              <Box flexShrink={1}>
330                <Text color={pal.text}>{q.replace(/^SIDE:\s*/i, '')}</Text>
331              </Box>
332            </Box>
333          )
334        })}
335      </Box>
336    )
337
338    const right = (
339      <Box flexDirection="column" width={half}>
340        <Box {...windowProps(pal)} flexDirection="column">
341          <Text color={pal.warn} bold>
342            ? 어떻게 하시겠습니까?
343          </Text>
344          {items(s.decide, (d, i) => {
345            const [choice, meaning = ''] = d.split(/\s+—\s+/, 2)
346            return (
347              <Box flexDirection="column">
348                <Box flexDirection="row">
349                  <Text color={pal.title}>{i === 0 ? '▶ ' : '  '}</Text>
350                  <Box flexShrink={1}>
351                    <Text color={i === 0 ? pal.title : pal.text}>{choice}</Text>
352                  </Box>
353                </Box>
354                {meaning !== '' && (
355                  <Box paddingLeft={4}>
356                    <Text color={pal.dim}>{meaning}</Text>
357                  </Box>
358                )}
359              </Box>
360            )
361          })}
362        </Box>
363        <Box {...windowProps(pal)} flexDirection="column">
364          <Text color={pal.info} bold>
365            ◆ INVENTORY
366          </Text>
367          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
368            {[...s.artifacts.slice(0, 7), ''].map(a => (
369              <Box borderStyle="single" borderColor={a === '' ? pal.dim : pal.frame} paddingX={1}>
370                <Text color={a === '' ? pal.dim : pal.text}>{a === '' ? '? ???' : `${iconOf(a)} ${shortName(a)}`}</Text>
371              </Box>
372            ))}
373          </Box>
374        </Box>
375      </Box>
376    )
377
378    const password = (
379      <Box {...windowProps(pal)} flexDirection="row" columnGap={2} flexWrap="wrap">
380        <Text color={pal.dim} bold>
381          PASSWORD
382        </Text>
383        <Text color={pal.info} bold>
384          {s.code ?? '(이어하기 프롬프트)'}
385        </Text>
386        {s.copied && (
387          <Text color={pal.edge} backgroundColor={pal.title} bold>
388            {' COPIED! '}
389          </Text>
390        )}
391        <Text color={pal.title}>{s.code ? `▸ /load ${s.code} · 빈 입력창 Tab 이어하기` : '▸ 빈 입력창 Tab 이어하기 · /load'}</Text>
392      </Box>
393    )
394
395    return (
396      <Box flexDirection="column">
397        {header}
398        <Box flexDirection={isWide ? 'row' : 'column'}>
399          {left}
400          {right}
401        </Box>
402        {password}
403        <Box borderStyle="round" borderColor={pal.edge} paddingX={1}>
404          <Text color={pal.text}>{`▶ 「${s.resume}」`}</Text>
405        </Box>
406        {s.rest !== '' && <Text color={pal.dim}>{s.rest}</Text>}
407      </Box>
408    )
409  })
410}
411
412type Reply = { text: string; why?: undefined; isEmpty?: undefined } | { text?: undefined; why: string; isEmpty: boolean }
413
414/**
415 * The save, written by the model. A fork asks over the live transcript (the
416 * prompt cache serves it). A session resumed with --continue has sent nothing
417 * yet, so there is nothing to fork: the stored transcript is then read and
418 * summarized in one completion on the session's model.
419 */
420async function summarize($: EngineInterface, prompt: string): Promise<Reply> {
421  const forked = await $.model.fork({ prompt })
422  if (forked.isAnswered) return { text: forked.text }
423  if (forked.reason !== 'nothing-to-fork') return { why: `The save failed (${forked.reason}). Try /save again in a moment.`, isEmpty: false }
424
425  const rows = await $.session.messages()
426  if (rows.length === 0) return { why: 'Nothing to save yet: this session has no turns.', isEmpty: true }
427  const transcript = transcriptOf(rows)
428  const ask = `${prompt}\n\nThe session so far:\n<transcript>\n${transcript}\n</transcript>`
429  let model = 'sonnet'
430  try {
431    model = await $.session.model()
432  } catch {
433    // The alias above stands in.
434  }
435  const done = await $.model.complete({ model, prompt: ask, maxTokens: 2000 })
436  if (done.isAnswered) return { text: done.text }
437  return { why: `The save failed (${done.reason}). Try /save again in a moment.`, isEmpty: false }
438}
439
440/** The rows as plain text, newest kept when long: who said what, and what each tool touched. */
441export function transcriptOf(rows: readonly { role: string; text: string; toolUses: readonly { tool: string; input: Record<string, unknown> }[] }[]): string {
442  const lines = rows.map(row => {
443    const tools = row.toolUses.map(use => {
444      const target = ['file_path', 'command', 'path', 'url', 'pattern']
445        .map(key => use.input[key])
446        .find((v): v is string => typeof v === 'string')
447      return target === undefined ? use.tool : `${use.tool}(${target.slice(0, 120)})`
448    })
449    const said = row.text.trim().slice(0, 4000)
450    return `${row.role === 'user' ? 'USER' : 'CLAUDE'}: ${said}${tools.length > 0 ? ` [tools: ${tools.join(', ')}]` : ''}`
451  })
452  const all = lines.join('\n')
453  return all.length > 60_000 ? '…' + all.slice(-60_000) : all
454}
455
456/** Offers the resume prompt once the prompt box is up and empty; a few tries, then gives up. */
457function offer($: EngineInterface, text: string, attempt: number): void {
458  if (attempt >= 5) return
459  $.clock.after(attempt === 0 ? 800 : 2000, () => {
460    void $.prompt
461      .suggest({ text })
462      .then(shown => {
463        if (!shown.isShown) offer($, text, attempt + 1)
464      })
465      .catch(() => undefined)
466  })
467}
468
469async function slotsOf($: EngineInterface): Promise<Slots> {
470  const raw = await $.store.get('slots')
471  return raw !== null && typeof raw === 'object' ? (raw as Slots) : {}
472}
473
474/** Level n starts at 4·(n−1)² successful calls (the HUD's rule). */
475export function levelOf(hits: number): { level: number; into: number; span: number } {
476  const level = Math.floor(Math.sqrt(Math.max(0, hits) / 4)) + 1
477  const floor = 4 * (level - 1) ** 2
478  return { level, into: hits - floor, span: 4 * (2 * level - 1) }
479}
480
481/** `SP<level>-<4 letters from the time>-MOON`: short enough to type into /load. */
482export function codeOf(level: number, savedAt: number): string {
483  const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
484  let n = Math.abs(Math.floor(savedAt / 1000) * 2654435761) % 1_000_000_007
485  let mid = ''
486  for (let i = 0; i < 4; i++) {
487    mid += letters[n % letters.length]
488    n = Math.floor(n / letters.length) + i * 7919
489  }
490  return `SP${String(Math.min(99, level)).padStart(2, '0')}-${mid}-MOON`
491}
492
493function iconOf(item: string): string {
494  if (/^https?:\/\//.test(item)) return '↗'
495  if (/\.(?:md|txt|docx?|pdf|hwpx?)\b/i.test(item)) return '▤'
496  if (/\.(?:[cm]?[jt]sx?|py|go|rs|java|rb|php|cs|swift|kt|sh|json|ya?ml|toml|html|css)\b/i.test(item)) return '◆'
497  return '▣'
498}
499
500function shortName(item: string): string {
501  const plain = item.replace(/\s*\(.*$/, '')
502  // A path shows its file name; anything with words (a branch, a PR) shows as written.
503  const base = /\s/.test(plain) || /^https?:/.test(plain) ? plain : (plain.split(/[\\/]/).pop() ?? plain)
504  return base.length > 18 ? base.slice(0, 17) + '…' : base
505}
506
507const strings = (value: unknown, max: number): string[] =>
508  Array.isArray(value)
509    ? value
510        .filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
511        .map(v => v.trim().replace(/\s+/g, ' ').slice(0, 200))
512        .slice(0, max)
513    : []
514
515/** The fork's reply as a save; a reply that is not the JSON asked for becomes the resume prompt. */
516export function parseSave(text: string): Pick<Save, 'title' | 'done' | 'todo' | 'decide' | 'resume' | 'artifacts'> {
517  const start = text.indexOf('{')
518  const end = text.lastIndexOf('}')
519  if (start !== -1 && end > start) {
520    try {
521      const o = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
522      const resume = typeof o.resume === 'string' ? o.resume.trim().slice(0, 2000) : ''
523      if (resume !== '') {
524        return {
525          title: typeof o.title === 'string' && o.title.trim() !== '' ? o.title.trim().slice(0, 80) : 'untitled quest',
526          done: strings(o.done, 6),
527          todo: strings(o.todo, 6),
528          decide: strings(o.decide, 4),
529          resume,
530          artifacts: strings(o.artifacts, 8),
531        }
532      }
533    } catch {
534      // Not JSON after all: kept as the resume prompt below.
535    }
536  }
537  return { title: 'save', done: [], todo: [], decide: [], resume: text.trim().slice(0, 2000), artifacts: [] }
538}
539
540function pad(n: number): string {
541  return String(n).padStart(2, '0')
542}
543
544function stamp(ms: number): string {
545  const d = new Date(ms)
546  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
547}
548
549function ago(ms: number): string {
550  const minutes = Math.round(ms / 60_000)
551  if (minutes < 60) return `${minutes}분 전`
552  const hours = Math.round(minutes / 60)
553  if (hours < 48) return `${hours}시간 전`
554  return `${Math.round(hours / 24)}일 전`
555}
556
557/** The save as text: what the transcript keeps and Claude reads; the screen is drawn from it. */
558export function screenText(s: Save, slot: number, of: number, copied: boolean): string {
559  const meta = [
560    `SLOT ${slot}/${of}`,
561    stamp(s.savedAt),
562    s.day === undefined ? '' : `DAY ${s.day}`,
563    s.hits === undefined ? '' : `LV ${levelOf(s.hits).level} (${s.hits})`,
564    `TURN ${s.turns}`,
565    s.usd === null ? '' : `$${s.usd.toFixed(2)}`,
566    s.contextUsed === null ? '' : `CTX ${s.contextUsed}%`,
567  ]
568    .filter(x => x !== '')
569    .join(' · ')
570  const list = (items: string[]) => (items.length === 0 ? ['- (none)'] : items.map(i => `- ${i}`))
571  return [
572    HEADER + s.title,
573    meta,
574    ...(s.code === undefined ? [] : [`${CODE_LINE}${s.code}${copied ? ' · COPIED' : ''}`]),
575    '[CLEARED]',
576    ...list(s.done),
577    '[QUEST LOG]',
578    ...list(s.todo),
579    '[DECIDE]',
580    ...list(s.decide),
581    '[PASSWORD]',
582    s.resume,
583    '[INVENTORY]',
584    ...list(s.artifacts),
585  ].join('\n')
586}
587
588export type Screen = {
589  title: string
590  slot: string
591  time: string
592  day: number | null
593  hits: number | null
594  contextUsed: number | null
595  code: string | undefined
596  copied: boolean
597  done: string[]
598  todo: string[]
599  decide: string[]
600  resume: string
601  artifacts: string[]
602  rest: string
603}
604
605/** Reads screenText back; undefined for a row that is not a save. */
606export function parseScreen(raw: string): Screen | undefined {
607  // The row may lead with the answering plugins' names ("game-save-point: ◆ SAVE POINT · …").
608  const start = raw.indexOf(HEADER)
609  if (start === -1 || start > 120 || raw.slice(0, start).includes('\n')) return undefined
610  const text = raw.slice(start)
611  const [head, meta = '', ...lines] = text.split('\n')
612  const num = (re: RegExp) => {
613    const m = re.exec(meta)
614    return m ? Number(m[1]) : null
615  }
616  const out: Screen = {
617    title: (head ?? '').slice(HEADER.length),
618    slot: /SLOT \d+\/\d+/.exec(meta)?.[0] ?? '',
619    time: /\d{4}-\d\d-\d\d (\d\d:\d\d)/.exec(meta)?.[1] ?? '',
620    day: num(/DAY (\d+)/),
621    hits: num(/LV \d+ \((\d+)\)/),
622    contextUsed: num(/CTX (\d+)%/),
623    code: undefined,
624    copied: false,
625    done: [],
626    todo: [],
627    decide: [],
628    resume: '',
629    artifacts: [],
630    rest: '',
631  }
632  const sections: Record<string, keyof Pick<Screen, 'done' | 'todo' | 'decide' | 'artifacts'> | 'resume'> = {
633    '[CLEARED]': 'done',
634    '[QUEST LOG]': 'todo',
635    '[DECIDE]': 'decide',
636    '[PASSWORD]': 'resume',
637    '[INVENTORY]': 'artifacts',
638  }
639  let at: (typeof sections)[string] | undefined
640  const resume: string[] = []
641  const rest: string[] = []
642  for (const line of lines) {
643    if (at === undefined && line.startsWith(CODE_LINE)) {
644      const [code = '', flag = ''] = line.slice(CODE_LINE.length).split(' · ')
645      out.code = code.trim()
646      out.copied = flag.trim() === 'COPIED'
647      continue
648    }
649    const next = sections[line]
650    if (next !== undefined) {
651      at = next
652      continue
653    }
654    if (at === 'resume') resume.push(line)
655    else if (at !== undefined && line.startsWith('- ')) {
656      if (line !== '- (none)') out[at].push(line.slice(2))
657    } else if (line.trim() !== '') {
658      at = undefined
659      rest.push(line)
660    }
661  }
662  out.resume = resume.join('\n').trim()
663  out.rest = rest.join(' ').trim()
664  return out
665}
666
hooks/palette.ts 124 lines
1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13  /** Headings, gold, the level. */
14  title: string
15  ok: string
16  warn: string
17  bad: string
18  info: string
19  dim: string
20  text: string
21  /** A window's border. */
22  frame: string
23  /** A window's fill; '' for none. */
24  win: string
25  /** The bright inner edge of a window, and a highlighted badge's text. */
26  edge: string
27  /** A bar's empty cells. */
28  off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32  nes: {
33    title: '#f8d830',
34    ok: '#58d854',
35    warn: '#ff8a6a',
36    bad: '#f83800',
37    info: '#3cbcfc',
38    dim: '#b8b8f0',
39    text: '#fcfcfc',
40    frame: '#5060d8',
41    win: '#1c2a8a',
42    edge: '#fcfcfc',
43    off: '#2c3a98',
44  },
45  gameboy: {
46    title: '#e0f8d0',
47    ok: '#9bbc0f',
48    warn: '#e0f8d0',
49    bad: '#f8f8a0',
50    info: '#c4e890',
51    dim: '#8bac0f',
52    text: '#e0f8d0',
53    frame: '#9bbc0f',
54    win: '#1e4a1e',
55    edge: '#e0f8d0',
56    off: '#306230',
57  },
58  amber: {
59    title: '#ffd060',
60    ok: '#ffb000',
61    warn: '#ff7040',
62    bad: '#ff5a2a',
63    info: '#ffe0a0',
64    dim: '#c89040',
65    text: '#ffd9a0',
66    frame: '#ffb000',
67    win: '#2a1700',
68    edge: '#ffe0a0',
69    off: '#4a2a00',
70  },
71  theme: {
72    title: 'claude',
73    ok: 'success',
74    warn: 'warning',
75    bad: 'error',
76    info: 'suggestion',
77    dim: 'subtle',
78    text: 'text',
79    frame: 'promptBorder',
80    win: '',
81    edge: 'text',
82    off: 'subtle',
83  },
84}
85
86export function paletteOf(name: unknown): Palette {
87  return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93  return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99  return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104  return pal.win === ''
105    ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106    : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111  const s = Math.max(0, Math.round(ms / 1000))
112  const h = Math.floor(s / 3600)
113  const m = Math.floor((s % 3600) / 60)
114  const sec = s % 60
115  const two = (n: number) => String(n).padStart(2, '0')
116  return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121  if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122  return '$' + usd.toFixed(2)
123}
124
types/index.d.ts 42 lines
1/** One save, as kept in $.store under `slots[<project root>]`, newest first. */
2export type Save = {
3  v: 1
4  /** $.clock.now() when saved. */
5  savedAt: number
6  /** The project the save belongs to ($.session.root()). */
7  root: string
8  title: string
9  /** 지금까지 한 일 → CLEARED */
10  done: string[]
11  /** 아직 남은 일 → QUEST LOG (`SIDE: ` marks an optional one) */
12  todo: string[]
13  /** 내가 결정해야 할 것 → 선택지 (`choice — what it means`) */
14  decide: string[]
15  /** 다음에 이어서 할 때 붙여넣을 프롬프트 → PASSWORD */
16  resume: string
17  /** 산출물/파일/링크 → INVENTORY */
18  artifacts: string[]
19  turns: number
20  usd: number | null
21  contextUsed: number | null
22  /** The password: `SP07-KTX9-MOON`; `/load <code>` restores this save. Absent on saves made before 0.2.0. */
23  code?: string
24  /** Successful tool calls in the session when saved (LV and EXP). */
25  hits?: number
26  /** Days since the project's first save, from 1. */
27  day?: number
28}
29
30/** The title screen at the start of a session in a folder with saves. */
31export type TitleState = {
32  isShown: boolean
33  /** LOAD pressed: the slot buttons show. */
34  isLoading: boolean
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'game-save-point': { title: TitleState; hits: number }
40  }
41}
42