SLOPSHOPPER

GAME MODE · Clear Time

A turn ends with CLEAR m:ss · 행동 n · 이번 턴 ₩x · 평소보다 ±m:ss, against the median of the project's last 20 turns

newspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-clear-time
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✦ CLEAR 0:42 · 행동 9 · 이번 턴 ₩0 › /clear-time ⎿ game-clear-time: CLEAR TIME · last 1 turns of this project ⎿ game-clear-time: 평소 (median) 0:42 ⎿ game-clear-time: 최단 0:42 · 최장 0:42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✦ CLEAR 0:42 · 행동 9 · 이번 턴 ₩0
README

GAME MODE · Clear Time

The line that closes a turn says how it went, against how turns usually go in this project:

✦ CLEAR 0:42 · 행동 7 · 이번 턴 ₩312 · 평소보다 −0:13
  • 행동 n: the main loop's tool calls in the turn
  • 이번 턴: the session's cost after the turn minus before it
  • 평소보다 ±m:ss: against the median of the project's last 20 finished turns (kept across sessions; shown from three turns on record). Faster in green, slower in orange. Interrupted turns are shown but not kept as usual turns.

/clear-time prints the project's usual turn, the fastest and the slowest.

Part of the GAME MODE pack. 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-clear-time@bitgame-mods

Settings

intensity (casual)off · casual · hardcore (adds the median it compares against)
palette (nes)nes · gameboy · amber · theme
currency (krw) · krwPerUsd (1400)how the cost is shown

한국어

턴이 끝나는 줄을 CLEAR 걸린 시간 · 행동 수 · 이번 턴 비용 · 평소보다 ±시간으로 바꿉니다. 평소는 이 프로젝트의 최근 20턴 중앙값입니다.

Source 3 files
hooks/register.tsx 164 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ClearRecord, TurnNow } from '../types'
5import { clock, intensityOf, money, paletteOf } from './palette'
6
7// GAME MODE · CLEAR TIME
8//
9// The line that closes a turn says how it went, against how turns usually go in this project:
10//   ✦ CLEAR 0:42 · 행동 7 · 이번 턴 ₩312 · 평소보다 −0:13
11// "평소" is the median of the project's last 20 finished turns, kept across sessions.
12
13const HISTORY = 20
14const KEEP = 100
15const MATCH_MS = 1500
16
17const turn = atom({ plugin: 'game-clear-time', key: 'turn' } as const, { actions: 0, startUsd: null, isOpen: false, seen: [] } as TurnNow)
18const records = atom({ plugin: 'game-clear-time', key: 'records' } as const, [] as ClearRecord[])
19
20export const register: Register = (on, options) => {
21  const intensity = intensityOf(options.intensity)
22  const pal = paletteOf(options.palette)
23  const currency = options.currency === 'usd' ? 'usd' : 'krw'
24  const krwPerUsd = Number(options.krwPerUsd ?? 1400) || 1400
25  let key = 'durations'
26
27  on('session.start', async ($, e, next) => {
28    key = `durations:${await $.session.root()}`
29    await $.command.register({ name: 'clear-time', description: 'GAME MODE clear time: this project\'s usual turn, best and worst' })
30    return next(e)
31  })
32
33  on('turn.start', async ($, e, next) => {
34    const usage = await $.session.usage().catch(() => undefined)
35    await update($, turn, () => ({ actions: 0, startUsd: usage?.cost?.usd ?? null, isOpen: true, seen: [] }))
36    return next(e)
37  }).catch(($, e, next) => next(e))
38
39  // A call counts once, from the model's tool_use row (where a refused call shows too, whichever
40  // plugin refused it) or from tool.call, whichever comes first.
41  on('session.append', async ($, e, next) => {
42    const stored = await next(e)
43    if (e.agentId === undefined && e.door === 'response') {
44      const ids = usesOf(e.message.content)
45      if (ids.length > 0) await update($, turn, t => counted(t, ids)).catch(() => undefined)
46    }
47    return stored
48  }).catch(($, e, next) => next(e))
49
50  on('tool.call', async ($, e, next) => {
51    if (e.agentId === undefined) await update($, turn, t => counted(t, [e.tool_use_id]))
52    return next(e)
53  }).catch(($, e, next) => next(e))
54
55  // A cost reading that lands after the turn closed settles that turn's cost.
56  on('session.measure', async ($, e, next) => {
57    const usd = e.cost?.usd
58    if (usd !== undefined && !(await read($, turn)).isOpen) {
59      await update($, records, list => settle(list, usd))
60    }
61    return next(e)
62  }).catch(($, e, next) => next(e))
63
64  on('turn.complete', async ($, e, next) => {
65    if (e.agentId !== undefined) return next(e)
66    const now = await read($, turn)
67    const kept = await $.store.get(key)
68    const history = Array.isArray(kept) ? kept.filter((n): n is number => typeof n === 'number') : []
69    const median = history.length >= 3 ? medianOf(history) : null
70    const usage = await $.session.usage().catch(() => undefined)
71    const usdNow = usage?.cost?.usd ?? null
72    const record: ClearRecord = {
73      durationMs: e.durationMs,
74      actions: now.actions,
75      startUsd: now.startUsd,
76      usd: usdNow !== null && now.startUsd !== null ? Math.max(0, usdNow - now.startUsd) : null,
77      deltaMs: median === null ? null : e.durationMs - median,
78      medianMs: median,
79    }
80    await update($, records, list => [...list, record].slice(-KEEP))
81    await update($, turn, t => ({ ...t, isOpen: false }))
82    // Only turns that ran to an answer say how long a turn usually takes.
83    if (e.reason === 'answer') await $.store.set(key, [...history, e.durationMs].slice(-HISTORY))
84    return next(e)
85  }).catch(($, e, next) => next(e))
86
87  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
88    if (intensity === 'off') return next(e)
89    const rec = recordFor(await read($, records), e.props.durationMs)
90    const { Box, Text } = $.ui.resolve(e)
91    const delta = rec?.deltaMs ?? null
92    return (
93      <Box flexDirection="row" gap={1}>
94        <Text color={pal.title} bold>
95          ✦ CLEAR
96        </Text>
97        <Text color={pal.text} bold>
98          {clock(e.props.durationMs)}
99        </Text>
100        {rec !== undefined && <Text color={pal.dim}>{`· 행동 ${rec.actions}`}</Text>}
101        {rec !== undefined && rec.usd !== null && <Text color={pal.dim}>{`· 이번 턴 ${money(rec.usd, currency, krwPerUsd)}`}</Text>}
102        {delta !== null && (
103          <Text color={delta <= 0 ? pal.ok : pal.warn}>{`· 평소보다 ${delta <= 0 ? '−' : '+'}${clock(Math.abs(delta))}`}</Text>
104        )}
105        {intensity === 'hardcore' && rec?.medianMs != null && <Text color={pal.dim}>{`(평소 ${clock(rec.medianMs)})`}</Text>}
106      </Box>
107    )
108  })
109
110  on('command.run', { command: 'clear-time' }, async ($) => {
111    const kept = await $.store.get(key)
112    const history = Array.isArray(kept) ? kept.filter((n): n is number => typeof n === 'number') : []
113    if (history.length === 0) return { text: 'No finished turns on record for this project yet.' }
114    const sorted = [...history].sort((a, b) => a - b)
115    return {
116      text: [
117        `CLEAR TIME · last ${history.length} turns of this project`,
118        `평소 (median) ${clock(medianOf(history))}`,
119        `최단 ${clock(sorted[0] ?? 0)} · 최장 ${clock(sorted[sorted.length - 1] ?? 0)}`,
120      ].join('\n'),
121    }
122  })
123}
124
125function counted(t: TurnNow, ids: readonly string[]): TurnNow {
126  const seen = t.seen ?? []
127  const fresh = ids.filter(id => !seen.includes(id))
128  return fresh.length === 0 ? t : { ...t, actions: t.actions + fresh.length, seen: [...seen, ...fresh].slice(-500) }
129}
130
131function usesOf(content: readonly unknown[]): string[] {
132  return content.flatMap(b => (b !== null && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_use' && typeof (b as { id?: unknown }).id === 'string' ? [(b as { id: string }).id] : []))
133}
134
135export function medianOf(values: readonly number[]): number {
136  const sorted = [...values].sort((a, b) => a - b)
137  const mid = Math.floor(sorted.length / 2)
138  return sorted.length % 2 === 1 ? (sorted[mid] ?? 0) : Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
139}
140
141/** The record a closing line belongs to: the same duration, or the nearest within 1.5 s, newest first. */
142export function recordFor(list: readonly ClearRecord[], durationMs: number): ClearRecord | undefined {
143  let best: ClearRecord | undefined
144  let bestGap = MATCH_MS + 1
145  for (let i = list.length - 1; i >= 0; i--) {
146    const r = list[i]
147    if (r === undefined) continue
148    const gap = Math.abs(r.durationMs - durationMs)
149    if (gap === 0) return r
150    if (gap < bestGap) {
151      best = r
152      bestGap = gap
153    }
154  }
155  return bestGap <= MATCH_MS ? best : undefined
156}
157
158/** The newest record's cost, from a reading taken after its turn closed. */
159function settle(list: readonly ClearRecord[], usd: number): ClearRecord[] {
160  const last = list[list.length - 1]
161  if (last === undefined || last.startUsd === null) return [...list]
162  return [...list.slice(0, -1), { ...last, usd: Math.max(0, usd - last.startUsd) }]
163}
164
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 36 lines
1/** The turn running now. */
2export type TurnNow = {
3  /** Calls the main loop made this turn. */
4  actions: number
5  /** The session's cost when the turn started, in US dollars. */
6  startUsd: number | null
7  /** A turn is running (its clear line is not written yet). */
8  isOpen: boolean
9  /** The calls counted this turn, by tool_use_id: each is counted once, however it was seen. */
10  seen: string[]
11}
12
13/** One finished turn, as its clear line shows it. */
14export type ClearRecord = {
15  durationMs: number
16  actions: number
17  /** What the turn cost, in US dollars; null without a cost reading. */
18  usd: number | null
19  /** The session's cost when the turn started, so a late reading can still settle `usd`. */
20  startUsd: number | null
21  /** Against the project's median turn; null until three turns are on record. */
22  deltaMs: number | null
23  /** The median it was compared with. */
24  medianMs: number | null
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'game-clear-time': {
30      turn: TurnNow
31      /** This session's finished turns, newest last (at most 100). */
32      records: ClearRecord[]
33    }
34  }
35}
36