/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…

/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개가 실패합니다. …」 │
╰──────────────────────────────────────────────────────────────────────────────╯
| Screen | Status 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.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-save-point@bitgame-mods
| Command | |
|---|---|
/save | save now (one model call) |
/save <note> | save with a note the summary should keep in mind |
/save show | the latest save again, no model call |
/save list | the 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-MOON | the save with that password, from any project |
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 비밀번호로 다른 프로젝트에서도 불러옵니다.
hooks/register.tsx 666 lines1import { 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}
666hooks/palette.ts 124 lines1// 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}
124types/index.d.ts 42 lines1/** 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