SLOPSHOPPER

waiting-room

Sala d'attesa: Snake giocabile solo mentre Claude lavora

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-06Bayway/LLM-Game/plugins/waiting-room
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waiting-room
│ ┃ Snake · sala d'attesa ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▣ client module ./board.tsx │ waiting-room │ │ ⏺ Read(src/auth.ts) │ Claude ha finito dopo 42s: Snake in pausa │ │ ⎿ Read 6 lines │ fino al prossimo prompt │ │ ⏺ 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 │ │ › /snake │ ⎿ waiting-room: Snake aperto: si sblocca appena Claude inizia a la │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Snake · sala d'attesa
▣ client module ./board.tsx
Source 4 files
hooks/register.tsx 179 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BoardMessage, BoardProps, Game } from '../types'
5
6const PANE = 'snake'
7const TITLE = "Snake · sala d'attesa"
8const COLS = 20
9const ROWS = 12
10const PANE_ROWS = ROWS + 6
11const CLOSE_DELAY_MS = 800
12
13/** Tools that stop the turn until the person answers. */
14const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
15const ASKING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
16
17const phase = atom({ plugin: 'waiting-room', key: 'phase' } as const, 'idle')
18const best = atom({ plugin: 'waiting-room', key: 'best' } as const, 0)
19const isAutoOpen = atom({ plugin: 'waiting-room', key: 'isAutoOpen' } as const, true)
20
21function formatDuration(ms: number): string {
22  const seconds = Math.round(ms / 1000)
23  if (seconds < 60) return `${seconds}s`
24
25  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
26}
27
28function isMessage(data: unknown): data is BoardMessage {
29  return typeof data === 'object' && data !== null && 'type' in data
30}
31
32// Turns running now, the main loop's and its subagents': the room is open while any runs.
33const running = new Set<string>()
34let pendingClose: Timer | undefined
35
36async function needsYou($: EngineInterface): Promise<void> {
37  if (running.size > 0) await update($, phase, () => 'needs-you')
38}
39
40async function backToWork($: EngineInterface): Promise<void> {
41  if (running.size > 0) await update($, phase, current => (current === 'needs-you' ? 'working' : current))
42}
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    await $.command.register({
47      name: 'snake',
48      description: "Apre Snake, giocabile solo mentre Claude lavora (/snake auto on|off)",
49    })
50    const stored = Number((await $.store.get('best')) ?? 0)
51    const isAuto = (await $.store.get('isAutoOpen')) !== false
52    await update($, best, current => Math.max(current, stored))
53    await update($, isAutoOpen, () => isAuto)
54
55    return next(e)
56  })
57
58  on('prompt.submit', async ($, e, next) => {
59    const result = await next(e)
60    pendingClose?.cancel()
61    pendingClose = undefined
62    if (await read($, isAutoOpen)) {
63      void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
64    }
65
66    return result
67  })
68
69  on('turn.start', async ($, e, next) => {
70    running.add(e.turnId)
71    await update($, phase, () => 'working')
72
73    return next(e)
74  })
75
76  on('turn.complete', async ($, e, next) => {
77    const result = await next(e)
78    running.delete(e.turnId)
79    if (running.size > 0 || e.agentId !== undefined) return result
80
81    await update($, phase, () => 'idle')
82    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
83    if (isOpen) {
84      $.ui.toast(`Claude ha finito dopo ${formatDuration(e.durationMs)}: Snake in pausa fino al prossimo prompt`)
85      if (await read($, isAutoOpen)) {
86        // Leaves the board a moment to save the game before the pane goes.
87        pendingClose = $.clock.after(CLOSE_DELAY_MS, () => {
88          pendingClose = undefined
89          void $.ui.close({ id: PANE })
90        })
91      }
92    }
93
94    return result
95  })
96
97  on('classic.PermissionRequest', async ($, e, next) => {
98    await needsYou($)
99
100    return next(e)
101  })
102
103  on('classic.Notification', async ($, e, next) => {
104    if (ASKING_NOTIFICATIONS.has(e.notification_type)) await needsYou($)
105
106    return next(e)
107  })
108
109  on('tool.call', async ($, e, next) => {
110    if (ASKING_TOOLS.has(String(e.tool))) await needsYou($)
111    const ran = await next(e)
112    await backToWork($)
113
114    return ran
115  })
116
117  on('ui.message', async ($, e, next) => {
118    const data = e.data
119    if (isMessage(data) && data.type === 'save') {
120      await $.store.set('saved', data.game)
121    }
122    if (isMessage(data) && data.type === 'over') {
123      await $.store.delete('saved')
124      const record = await read($, best)
125      if (data.score > record) {
126        await update($, best, () => data.score)
127        await $.store.set('best', data.score)
128        $.ui.toast(`🐍 Nuovo record a Snake: ${data.score} punti`)
129      }
130    }
131
132    return next(e)
133  })
134
135  on('command.run', { command: 'snake' }, async ($, e) => {
136    const args = e.args.trim()
137    if (args === 'auto on' || args === 'auto off') {
138      const isAuto = args === 'auto on'
139      await update($, isAutoOpen, () => isAuto)
140      await $.store.set('isAutoOpen', isAuto)
141
142      return {
143        text: isAuto
144          ? 'Snake si aprirà da solo a ogni prompt e si chiuderà quando Claude finisce.'
145          : 'Snake non si aprirà più da solo: usa /snake quando vuoi giocare.',
146      }
147    }
148
149    await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: PANE_ROWS })
150    const isLocked = (await read($, phase)) !== 'working'
151
152    return {
153      text: isLocked
154        ? 'Snake aperto: si sblocca appena Claude inizia a lavorare.'
155        : 'Snake aperto: buon divertimento!',
156    }
157  })
158
159  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
161      const { Text } = $.ui.resolve(e)
162
163      return <Text dimColor>Snake si gioca nel terminale o nell'app desktop.</Text>
164    }
165
166    const { Client } = $.ui.resolve(e)
167    const room = Math.floor((e.props.bodyColumns - 2) / 2)
168    const props: BoardProps = {
169      phase: await read($, phase),
170      best: await read($, best),
171      saved: ((await $.store.get('saved')) as Game | undefined) ?? null,
172      cols: Math.max(8, Math.min(COLS, room)),
173      rows: ROWS,
174    }
175
176    return <Client key="board" module="./board.tsx" props={props} />
177  })
178}
179
hooks/board.tsx 228 lines
1import type { ClientKeyEvent, ClientModule, ClientSurface } from 'claude-code'
2
3import type { BoardMessage, BoardProps, Dir, Game } from '../types'
4import { interval, newGame, step, turn } from './snake'
5
6type Local = {
7  game: Game | null
8  isPaused: boolean
9  isSaved: boolean
10  isRecord: boolean
11}
12
13type Surface = ClientSurface<Local>
14
15const TICK_MS = 30
16const SAVE_EVERY_MS = 2000
17
18const KEYS: Record<string, Dir> = {
19  up: 'up',
20  down: 'down',
21  left: 'left',
22  right: 'right',
23  w: 'up',
24  a: 'left',
25  s: 'down',
26  d: 'right',
27  k: 'up',
28  h: 'left',
29  j: 'down',
30  l: 'right',
31}
32
33const COLORS = {
34  head: '#86efac',
35  body: '#22c55e',
36  food: '#ef4444',
37  locked: '#6b7280',
38}
39
40// One board per plugin: the tick and key handlers read the latest props from here.
41let live: BoardProps = { phase: 'idle', best: 0, saved: null, cols: 20, rows: 12 }
42let sinceMove = 0
43let sinceSave = 0
44
45function newSeed(): number {
46  return (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0
47}
48
49function post(surface: Surface, message: BoardMessage): void {
50  surface.post(message)
51}
52
53function tick(surface: Surface): void {
54  const local = surface.state
55  if (!local?.game || local.game.isOver) return
56
57  if (live.phase !== 'working' || local.isPaused) {
58    if (!local.isSaved) {
59      post(surface, { type: 'save', game: local.game })
60      surface.setState({ ...local, isSaved: true })
61    }
62    return
63  }
64
65  sinceMove += TICK_MS
66  sinceSave += TICK_MS
67  if (sinceMove < interval(local.game.score)) return
68  sinceMove = 0
69
70  const game = step(local.game)
71  if (game.isOver) {
72    post(surface, { type: 'over', score: game.score })
73    surface.setState({ ...local, game, isSaved: true, isRecord: game.score > live.best })
74    return
75  }
76
77  if (sinceSave >= SAVE_EVERY_MS) {
78    sinceSave = 0
79    post(surface, { type: 'save', game })
80    surface.setState({ ...local, game, isSaved: true })
81    return
82  }
83
84  surface.setState({ ...local, game, isSaved: false })
85}
86
87function steer(surface: Surface, dir: Dir): void {
88  const local = surface.state
89  if (!local || live.phase !== 'working') return
90
91  if (!local.game || local.game.isOver) {
92    sinceMove = 0
93    sinceSave = 0
94    const game = newGame(live.cols, live.rows, newSeed(), dir)
95    surface.setState({ game, isPaused: false, isSaved: false, isRecord: false })
96    return
97  }
98
99  surface.setState({ ...local, game: turn(local.game, dir), isPaused: false })
100}
101
102function togglePause(surface: Surface): void {
103  const local = surface.state
104  if (!local?.game || local.game.isOver || live.phase !== 'working') return
105
106  surface.setState({ ...local, isPaused: !local.isPaused })
107}
108
109function onKey(surface: Surface, event: ClientKeyEvent): void {
110  if (event.ctrl || event.meta) return
111  if (event.key === ' ' || event.key === 'space' || event.key === 'p') {
112    togglePause(surface)
113    return
114  }
115
116  const dir = KEYS[event.key] ?? KEYS[event.key.toLowerCase()]
117  if (dir) steer(surface, dir)
118}
119
120type Kind = 'empty' | 'body' | 'head' | 'food'
121
122function kindAt(game: Game | null, x: number, y: number): Kind {
123  if (!game) return 'empty'
124  const head = game.snake[0]
125  if (head && head[0] === x && head[1] === y) return 'head'
126  if (game.snake.some(([sx, sy]) => sx === x && sy === y)) return 'body'
127  if (game.food && game.food[0] === x && game.food[1] === y) return 'food'
128
129  return 'empty'
130}
131
132/** One row of the board as runs of same-kind cells, two columns per cell. */
133function runsOf(game: Game | null, cols: number, y: number): { kind: Kind; width: number }[] {
134  const runs: { kind: Kind; width: number }[] = []
135  for (let x = 0; x < cols; x++) {
136    const kind = kindAt(game, x, y)
137    const last = runs[runs.length - 1]
138    if (last && last.kind === kind) last.width += 2
139    else runs.push({ kind, width: 2 })
140  }
141
142  return runs
143}
144
145function messageOf(props: BoardProps, local: Local): string {
146  if (props.phase === 'idle') {
147    return '🔒 Claude ha finito: si gioca solo mentre lavora. La partita ti aspetta al prossimo prompt.'
148  }
149  if (props.phase === 'needs-you') {
150    return '⚠ Claude ha bisogno di te: rispondi alla sua richiesta, poi si riprende.'
151  }
152
153  const game = local.game
154  if (!game) return 'Premi una freccia (o w a s d) per iniziare. Se i tasti non arrivano, clicca sul campo.'
155  if (game.isOver) {
156    const record = local.isRecord ? ' Nuovo record!' : ''
157    return `💥 Game over: ${game.score} punti.${record} Una freccia per rigiocare.`
158  }
159  if (local.isPaused) return '⏸ In pausa. Una freccia per riprendere.'
160
161  return 'Claude sta lavorando: gioca! Spazio per la pausa.'
162}
163
164const Board: ClientModule<BoardProps, Local> = (props, surface) => {
165  live = props
166  const { Box, Text, Button } = surface.elements
167
168  if (surface.state === undefined) {
169    surface.every(TICK_MS, () => tick(surface))
170    surface.onKey(event => onKey(surface, event))
171    surface.setState({ game: props.saved, isPaused: false, isSaved: true, isRecord: false })
172  }
173
174  const local: Local = surface.state ?? { game: props.saved, isPaused: false, isSaved: true, isRecord: false }
175  const game = local.game
176  const cols = game?.cols ?? props.cols
177  const rows = game?.rows ?? props.rows
178  const isLocked = props.phase !== 'working'
179  const score = game?.score ?? 0
180  const best = Math.max(props.best, score)
181
182  const lines = []
183  for (let y = 0; y < rows; y++) {
184    lines.push(
185      <Box key={`row-${y}`}>
186        {runsOf(game, cols, y).map(run => (
187          <Text
188            color={isLocked ? COLORS.locked : run.kind === 'empty' ? undefined : COLORS[run.kind]}
189            dimColor={isLocked}
190          >
191            {(run.kind === 'empty' ? ' ' : '█').repeat(run.width)}
192          </Text>
193        ))}
194      </Box>,
195    )
196  }
197
198  return (
199    <Box flexDirection="column">
200      <Box gap={2}>
201        <Text bold>🐍 Snake</Text>
202        <Text>punti {score}</Text>
203        <Text dimColor>record {best}</Text>
204      </Box>
205      <Box
206        flexDirection="column"
207        borderStyle="round"
208        borderColor={isLocked ? COLORS.locked : COLORS.body}
209        width={cols * 2 + 2}
210      >
211        {lines}
212      </Box>
213      <Text dimColor={isLocked} wrap="wrap">
214        {messageOf(props, local)}
215      </Text>
216      <Box gap={1}>
217        <Button key="up" label="↑" hotkey="w" plain onPress={() => steer(surface, 'up')} />
218        <Button key="left" label="←" hotkey="a" plain onPress={() => steer(surface, 'left')} />
219        <Button key="down" label="↓" hotkey="s" plain onPress={() => steer(surface, 'down')} />
220        <Button key="right" label="→" hotkey="d" plain onPress={() => steer(surface, 'right')} />
221        <Button key="pause" label="pausa" hotkey="p" plain onPress={() => togglePause(surface)} />
222      </Box>
223    </Box>
224  )
225}
226
227export default Board
228
hooks/snake.ts 124 lines
1import type { Cell, Dir, Game } from '../types'
2
3const DELTA: Record<Dir, Cell> = {
4  up: [0, -1],
5  down: [0, 1],
6  left: [-1, 0],
7  right: [1, 0],
8}
9
10const OPPOSITE: Record<Dir, Dir> = {
11  up: 'down',
12  down: 'up',
13  left: 'right',
14  right: 'left',
15}
16
17const START_LENGTH = 3
18const MAX_QUEUED = 2
19
20function nextSeed(seed: number): number {
21  return (Math.imul(seed, 1664525) + 1013904223) >>> 0
22}
23
24function isOn(cells: readonly Cell[], [x, y]: Cell): boolean {
25  return cells.some(([cx, cy]) => cx === x && cy === y)
26}
27
28function placeFood(
29  snake: readonly Cell[],
30  cols: number,
31  rows: number,
32  seed: number,
33): { food: Cell | null; seed: number } {
34  const free = cols * rows - snake.length
35  if (free <= 0) {
36    return { food: null, seed }
37  }
38
39  const next = nextSeed(seed)
40  let pick = next % free
41  for (let y = 0; y < rows; y++) {
42    for (let x = 0; x < cols; x++) {
43      if (isOn(snake, [x, y])) continue
44      if (pick === 0) return { food: [x, y], seed: next }
45      pick--
46    }
47  }
48
49  return { food: null, seed: next }
50}
51
52export function newGame(cols: number, rows: number, seed: number, dir: Dir = 'right'): Game {
53  const head: Cell = [Math.floor(cols / 2), Math.floor(rows / 2)]
54  const [bx, by] = DELTA[OPPOSITE[dir]]
55  const snake: Cell[] = []
56  for (let i = 0; i < START_LENGTH; i++) {
57    snake.push([head[0] + bx * i, head[1] + by * i])
58  }
59  const placed = placeFood(snake, cols, rows, seed)
60
61  return {
62    cols,
63    rows,
64    snake,
65    dir,
66    queued: [],
67    food: placed.food,
68    score: 0,
69    isOver: false,
70    seed: placed.seed,
71  }
72}
73
74export function turn(game: Game, dir: Dir): Game {
75  const last = game.queued[game.queued.length - 1] ?? game.dir
76  if (game.isOver || dir === last || dir === OPPOSITE[last] || game.queued.length >= MAX_QUEUED) {
77    return game
78  }
79
80  return { ...game, queued: [...game.queued, dir] }
81}
82
83export function step(game: Game): Game {
84  const current = game.snake[0]
85  if (game.isOver || !current) {
86    return game
87  }
88
89  const [dir = game.dir, ...queued] = game.queued
90  const [hx, hy] = current
91  const [dx, dy] = DELTA[dir]
92  const head: Cell = [hx + dx, hy + dy]
93  const isOutside = head[0] < 0 || head[1] < 0 || head[0] >= game.cols || head[1] >= game.rows
94  const eats = game.food !== null && head[0] === game.food[0] && head[1] === game.food[1]
95  const body = eats ? game.snake : game.snake.slice(0, -1)
96
97  if (isOutside || isOn(body, head)) {
98    return { ...game, dir, queued: [], isOver: true }
99  }
100
101  const snake = [head, ...body]
102  if (!eats) {
103    return { ...game, snake, dir, queued }
104  }
105
106  const placed = placeFood(snake, game.cols, game.rows, game.seed)
107
108  return {
109    ...game,
110    snake,
111    dir,
112    queued,
113    food: placed.food,
114    score: game.score + 1,
115    isOver: placed.food === null,
116    seed: placed.seed,
117  }
118}
119
120/** Milliseconds between moves: the snake speeds up as it eats. */
121export function interval(score: number): number {
122  return Math.max(70, 150 - score * 4)
123}
124
types/index.d.ts 36 lines
1export type Phase = 'idle' | 'working' | 'needs-you'
2
3export type Dir = 'up' | 'down' | 'left' | 'right'
4
5export type Cell = [number, number]
6
7export type Game = {
8  cols: number
9  rows: number
10  snake: Cell[]
11  dir: Dir
12  queued: Dir[]
13  food: Cell | null
14  score: number
15  isOver: boolean
16  seed: number
17}
18
19export type BoardProps = {
20  phase: Phase
21  best: number
22  saved: Game | null
23  cols: number
24  rows: number
25}
26
27export type BoardMessage =
28  | { type: 'save'; game: Game }
29  | { type: 'over'; score: number }
30
31declare module 'claude-code' {
32  interface PluginState {
33    'waiting-room': { phase: Phase; best: number; isAutoOpen: boolean }
34  }
35}
36