SLOPSHOPPER

commuting-clawders

Keep Clawd running

newpaneprocessnetworktimer
★ 8v0.1.1MITupdated 2026-10-15kyh/kyh.io/apps/commuting-clawders/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commuting-clawders
│ ┃ Commuting Clawders ✕ › fix the failing auth test and add an audit log call │ ┃ Starting Commuting Clawders… │ ┃ ◐ prompt within 5s to keep the run · 0 ● commuting-clawders: commuting-clawders streamer ended (no start): │ ┃ tokens · ● 0 ⏺ 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. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Commuting Clawders
Starting Commuting Clawders… ◐ prompt within 5s to keep the run · 0 tokens · ● 0
README

Commuting Clawders

A Claude Code mod. While Claude works, a pane plays Commuting Clawders, a self-running endless runner starring Clawd. Each run scores the output tokens Claude spends before it goes quiet, and goes up on a shared leaderboard: who can keep Claude going the longest, at <https://clawders.kyh.io>.

The game is a three.js world rendered in a headless Chrome and streamed into the pane. In kitty or Ghostty it is a real picture; in any other terminal (the desktop app's panel included) it is drawn in quadrant blocks, two by two pixels a character, from the same frames.

Requirements

  • macOS or Linux. Windows is not supported: the pane says so and stays empty.
  • Claude Code with mod (function hooks plugin) support, at or past the version packages/claude-code-types pins.
  • Google Chrome installed. The game renders in it, headless; nothing is downloaded. Set CHROME_PATH if it lives somewhere unusual.
  • Node.js on your PATH: it runs the streamer (dist/stream.mjs). No packages to install; the mod ships built.
  • A wide terminal. Start Claude Code with CLAUDE_CODE_NO_FLICKER=1 so the pane docks on the right; otherwise it waits for room.
  • kitty or Ghostty for the sharp picture. Elsewhere you get blocks.

Install

In Claude Code:

/plugin install commuting-clawders --marketplace kyh/kyh.io

Or from a shell:

claude plugin marketplace add kyh/kyh.io
claude plugin install commuting-clawders@kyh

/plugin disables or removes it. Installed it as mod-clawders? Remove that one and install this; the old name gets no more updates. The marketplace is .claude-plugin/marketplace.json at the repo root.

From a checkout

From this repo, pnpm dev:commuting-clawders-mod builds it and runs claude --plugin-dir apps/commuting-clawders/mod. To load the checkout in every session (the desktop app included, which takes no flags), add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json; run pnpm -F @repo/commuting-clawders-mod build after changing world/ or stream/.

Use it

Start Claude Code with the mod loaded and send a prompt. A run starts:

  • Clawd runs: dodging trains, jumping barriers, running up ramps onto train roofs and hopping roof to roof, collecting coins on the track and on top;
  • the run's output tokens show in the pill over the picture, the coins top right (in blocks, in the status line under the game);
  • gold sparkles burst from each coin, dust kicks up where Clawd runs and lands, a crash throws sparks, and wind streaks rush past (pictures only).

Every token Claude generates counts, subagents' included. When Claude finishes, the status line reads ◐ prompt within 5s to keep the run: send another prompt in time and the run carries on. Miss it and the run is over. Clawd stops, turns round and waves; the run (tokens, coins, how long Claude worked) goes to the leaderboard, and the status line shows where you stand. The next prompt starts a fresh game.

Chrome starts when the pane is drawn and stops when it closes, so a closed pane costs nothing. The streamer also exits by itself if the session stops asking for frames for 15 seconds.

Your runs are public

Each finished run goes to the public leaderboard at <https://clawders.kyh.io> under your Player name, which defaults to your git user.name. The status line under the game says where runs go whenever the game is idle, e.g. ○ idle · posting as kai to clawders.kyh.io. Set a different name, or turn Post runs to the leaderboard off, in /config.

Settings

Open /config and find commuting-clawders:

SettingDefaultOptions
Post runs to the leaderboardonoff: runs stay local
Player namegit user.nameany name, up to 32 characters
Leaderboard URLthe shared oneanother deployment of the site; empty: no posting

You can also set them in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "commuting-clawders": { "options": { "playerName": "kyh" } }
  }
}

Leaderboard

The leaderboard is the folder around this one, apps/commuting-clawders (@repo/commuting-clawders): a Next.js app on Vercel with its own Turso database. POST /runs takes { name, tokens, coins, seconds } and answers the player's rank; GET / is the page, each player's best run by tokens; GET /runs the same as JSON.

cp apps/commuting-clawders/.env.example apps/commuting-clawders/.env
pnpm -F @repo/commuting-clawders db:push   # the local file database
pnpm dev:commuting-clawders                           # :3016

Anyone can post, so names are trimmed and every number is bounded; there is no proof a run happened.

Troubleshooting

SymptomCause and fix
No paneThe terminal is too narrow (see Requirements). Widen it.
Pane says ✗ and a reasonThe streamer stopped: usually Chrome not found (set CHROME_PATH) or no node on PATH.
Blocky pictureYour terminal can't show pictures; use kitty or Ghostty for the sharp one.
Nothing happens at allStart with claude --debug and look for lines starting with commuting-clawders:.

Release

Installed copies update only when version in .claude-plugin/plugin.json changes. To ship: rebuild (pnpm -F @repo/commuting-clawders-mod build), commit dist/, bump the version, push to main. CI fails if dist/ doesn't match the source.

Develop

Claude Code watches a --plugin-dir folder in an interactive session: saving a file in hooks/ reloads the mod without a restart.

pnpm -F @repo/commuting-clawders-mod build      # dist/: the world for Chrome and the bundled streamer (commit it)
pnpm -F @repo/commuting-clawders-mod world      # the game alone in a browser, on :3015 (?coarse previews blocks mode)
pnpm -F @repo/commuting-clawders-mod test       # node: the runner, runs, frames, the streamer protocol
pnpm -F @repo/commuting-clawders-mod test:mod   # claude plugin test: the hooks: the pane, runs, the grace, posting
pnpm -F @repo/commuting-clawders-mod typecheck  # pure modules + streamer, the world (DOM), then the hooks against the pinned API types
pnpm -F @repo/commuting-clawders-mod validate   # what Claude Code will load

Tests come in two kinds, split by suffix. test/*.spec.ts cover the pure modules under node and run in pnpm test and CI. test/*.test.ts run the hooks inside Claude Code's own test kit, with the engine beneath mocked (claude-code/testing); they need the claude CLI, so they're local only. claude plugin test loads every *.test.ts in the folder, which is why the node tests can't share the suffix.

The hooks type-check against packages/claude-code-types, the plugin API's declarations pinned from the Claude Code version on that file's first line. After a Claude Code update, refresh it from the copy the engine writes beside a mod each time it loads one:

cp apps/commuting-clawders/mod/.claude-plugin/types/claude-code/index.d.ts packages/claude-code-types/claude-code.d.ts
FileRole
hooks/register.tsxThe hooks: pane, runs, posting, starting the streamer and blitting its frames
hooks/screen.tsThe hooks' side of the streamer: socket, URL, parsing a frame
hooks/game.tsRunner simulation and autopilot, shared with the world
hooks/run.tsA run's state: tokens, the 5s grace, its result and status line
world/The three.js game: scene, Clawd, props, toon materials, HUD, particles (vfx.ts)
stream/stream.tsServes the world to headless Chrome, takes its frames, answers the hooks
stream/chrome.tsWhere Chrome is, and how it runs: headless, throwaway profile, tied to the streamer
stream/files.tsThe built world, served as files
stream/frame.tsFrame sizing, flipped RGBA for pictures, quadrant cells for blocks
dist/Built and committed: the world page and stream.mjs, so installs need no packages
types/index.d.tsThe $.state contract: what the pane shows, the status line
.claude-plugin/plugin.jsonManifest and the /config options
../The leaderboard: Next.js app, Turso schema, run schema, standings, page
Source 4 files
hooks/register.tsx 428 lines
1import { atom, read, update } from "claude-code";
2import type {
3  EngineInterface,
4  HookStream,
5  ProcessSpawnChunk,
6  ProcessSpawnResult,
7  Register,
8} from "claude-code";
9
10import type { View } from "../types";
11
12import {
13  GRACE_MS,
14  addTokens,
15  endTurn,
16  idle,
17  isActive,
18  rankOf,
19  resultOf,
20  startTurn,
21  statusOf,
22} from "./run";
23import type { Result, Run } from "./run";
24import { drawsImages, frameUrl, lastWords, parseFrame, placementOf } from "./screen";
25import type { Frame, Mode } from "./screen";
26
27const PANE = "clawders";
28const SCREEN = "game";
29const TITLE = "Commuting Clawders";
30const FRAME_MS = 33;
31const TALLY_MS = 1000;
32const LOG_MAX = 4000;
33
34const starting: View = { kind: "starting" };
35const view = atom({ key: "view", plugin: "commuting-clawders" } as const, starting);
36const status = atom({ key: "status", plugin: "commuting-clawders" } as const, statusOf(idle, 0));
37
38// The streamer: headless Chrome drawing the world, one per open pane.
39type Streamer =
40  | { kind: "off" | "launching" }
41  | {
42      kind: "starting" | "live";
43      child: HookStream<ProcessSpawnChunk, ProcessSpawnResult>;
44      socketPath: string;
45      imagePath: string;
46    };
47
48interface Size {
49  columns: number;
50  rows: number;
51}
52
53interface Screen {
54  isPulling: boolean;
55  latest: Frame | null;
56  mode: Mode;
57  // What the last drawing mounted; blits go only to that.
58  mounted: (Size & { mode: Mode }) | null;
59  size: Size | null;
60  streamer: Streamer;
61  talliedAt: number;
62}
63
64interface Leaderboard {
65  url: string;
66  name: string;
67}
68
69// Frame-rate values live in the module: a reload starts a fresh run.
70let run: Run = idle;
71// Null when runs are not posted.
72let leaderboard: Leaderboard | null = null;
73
74const postingNote = () =>
75  leaderboard
76    ? `posting as ${leaderboard.name} to ${new URL(leaderboard.url).host}`
77    : "runs stay local";
78const screen: Screen = {
79  isPulling: false,
80  latest: null,
81  mode: "cells",
82  mounted: null,
83  size: null,
84  streamer: { kind: "off" },
85  talliedAt: 0,
86};
87const stopStreamer = async (streamer: Streamer) => {
88  if (!("child" in streamer)) {
89    return;
90  }
91  try {
92    await streamer.child.return({ code: null, signal: null });
93  } catch {
94    // It had ended already.
95  }
96};
97
98// Reads the streamer's output until it ends: `ready` makes it live; an end
99// nobody asked for leaves its last words in the pane.
100const watch = async ($: EngineInterface, streamer: Streamer & { kind: "starting" }) => {
101  let output = "";
102  let ending: ProcessSpawnResult | null = null;
103  try {
104    for (;;) {
105      const piece = await streamer.child.next();
106      if (piece.done) {
107        ending = piece.value;
108        break;
109      }
110      output = (output + piece.value.text).slice(-LOG_MAX);
111      if (
112        screen.streamer === streamer &&
113        piece.value.stream === "stdout" &&
114        output.includes("ready")
115      ) {
116        screen.streamer = { ...streamer, kind: "live" };
117      }
118    }
119  } catch (error) {
120    output += `\n${String(error)}`;
121  }
122  $.ui.log(
123    `commuting-clawders streamer ended (${ending?.signal ?? ending?.code ?? "no start"}):\n${output}`,
124    { to: "debug" },
125  );
126  if (!("child" in screen.streamer) || screen.streamer.child !== streamer.child) {
127    return;
128  }
129  screen.streamer = { kind: "off" };
130  screen.mounted = null;
131  const reason = lastWords(output) || "the streamer stopped";
132  await update($, view, (): View => ({ kind: "failed", reason }));
133};
134
135const startStreamer = async ($: EngineInterface) => {
136  // The streamer answers over a Unix socket, which Windows does not give it.
137  if ((await $.env.get("OS")) === "Windows_NT") {
138    await update($, view, (): View => ({
139      kind: "failed",
140      reason: "Commuting Clawders runs on macOS and Linux",
141    }));
142    return;
143  }
144  screen.streamer = { kind: "launching" };
145  // oxlint-disable-next-line prefer-destructuring -- the engine reads $ from source and refuses any spelling but $.plugin.root
146  const root = $.plugin.root;
147  const [tmpDir, term, termProgram] = await Promise.all([
148    $.env.get("TMPDIR"),
149    $.env.get("TERM"),
150    $.env.get("TERM_PROGRAM"),
151  ]);
152  screen.mode = drawsImages(term, termProgram) ? "image" : "cells";
153  const { imagePath, socketPath } = placementOf(
154    tmpDir,
155    Math.floor(Math.random() * 1e9).toString(36),
156  );
157  const child = $.process.spawn({
158    argv: [
159      "node",
160      `${root}/dist/stream.mjs`,
161      "--world",
162      `${root}/dist/world`,
163      "--socket",
164      socketPath,
165      "--image",
166      imagePath,
167    ],
168    cwd: root,
169  });
170  const streamer = { child, imagePath, kind: "starting", socketPath } as const;
171  screen.streamer = streamer;
172  void watch($, streamer);
173};
174
175// The pane is gone: let Chrome go until it is drawn again.
176const closeScreen = async ($: EngineInterface) => {
177  const { streamer } = screen;
178  screen.streamer = { kind: "off" };
179  screen.mounted = null;
180  screen.latest = null;
181  await stopStreamer(streamer);
182  await update($, view, () => starting);
183};
184
185const sameSize = (a: Size, b: Size) => a.columns === b.columns && a.rows === b.rows;
186
187const blit = ($: EngineInterface, frame: Frame, imagePath: string) =>
188  frame.cells === undefined
189    ? $.ui.blit({
190        key: SCREEN,
191        requestId: PANE,
192        source: {
193          file: imagePath,
194          format: "rgba",
195          generation: frame.frame,
196          height: frame.height,
197          width: frame.width,
198        },
199      })
200    : $.ui.blit({
201        cells: frame.cells,
202        columns: frame.columns,
203        key: SCREEN,
204        requestId: PANE,
205        rows: frame.rows,
206      });
207
208// Puts a frame on screen: blitted onto what is mounted, or, when the pane
209// still shows something else, a redraw that mounts it.
210const show = async ($: EngineInterface, frame: Frame, imagePath: string) => {
211  const { mounted, size } = screen;
212  const isCells = frame.cells !== undefined;
213  if (!size || !sameSize(frame, size) || isCells !== (screen.mode === "cells")) {
214    return;
215  }
216  screen.latest = frame;
217  if (!mounted || mounted.mode !== screen.mode || !sameSize(mounted, frame)) {
218    await update($, view, (): View => ({
219      columns: frame.columns,
220      kind: "live",
221      mode: screen.mode,
222      rows: frame.rows,
223    }));
224    return;
225  }
226  let deny: string | undefined = "the pane is closed";
227  try {
228    ({ deny } = await blit($, frame, imagePath));
229  } catch {
230    // Thrown, like denied: nothing is mounted any more.
231  }
232  if (deny && screen.mode === "image") {
233    // The terminal draws the Image's alt: no pictures here after all.
234    screen.mode = "cells";
235    return;
236  }
237  if (deny) {
238    await closeScreen($);
239    return;
240  }
241  if (frame.frame - screen.talliedAt >= TALLY_MS / FRAME_MS) {
242    screen.talliedAt = frame.frame;
243    await update($, status, () => statusOf(run, frame.coins, postingNote()));
244  }
245};
246
247const pull = async ($: EngineInterface) => {
248  const { size, streamer } = screen;
249  if (screen.isPulling || streamer.kind !== "live" || !size) {
250    return;
251  }
252  screen.isPulling = true;
253  try {
254    const since = screen.latest?.frame ?? -1;
255    const url = frameUrl(since, screen.mode, size.columns, size.rows, {
256      isActive: isActive(run),
257      tokens: run.kind === "idle" ? 0 : run.tokens,
258    });
259    const response = await $.http.fetch(url, { socketPath: streamer.socketPath });
260    const frame = response.status === 200 ? parseFrame(response.headers, response.text) : null;
261    if (frame) {
262      await show($, frame, streamer.imagePath);
263    }
264  } catch {
265    // The streamer is going; its watcher says why.
266  } finally {
267    screen.isPulling = false;
268  }
269};
270
271const setRun = async ($: EngineInterface, next: Run) => {
272  run = next;
273  await update($, status, () => statusOf(next, screen.latest?.coins ?? 0, postingNote()));
274};
275
276// The name runs go up under: the configured one, else git's.
277const playerName = async ($: EngineInterface, configured: string) => {
278  if (configured) {
279    return configured;
280  }
281  try {
282    const { exitCode, stdout } = await $.process.run(["git", "config", "user.name"]);
283    return exitCode === 0 && stdout.trim() ? stdout.trim() : "anonymous";
284  } catch {
285    return "anonymous";
286  }
287};
288
289const post = async ($: EngineInterface, target: Leaderboard, result: Omit<Result, "rank">) => {
290  try {
291    const { ok, text } = await $.http.fetch(new URL("/runs", target.url).href, {
292      body: JSON.stringify({ name: target.name, ...result }),
293      headers: { "content-type": "application/json" },
294      method: "POST",
295    });
296    return ok ? rankOf(text) : null;
297  } catch {
298    return null;
299  }
300};
301
302// Ends the run once its grace has passed with no new prompt; the world starts
303// a fresh game when the next one begins.
304const finish = async ($: EngineInterface) => {
305  const result = resultOf(run, await $.clock.now(), screen.latest?.coins ?? 0);
306  if (!result) {
307    return;
308  }
309  await setRun($, { kind: "idle", last: { ...result, rank: null } });
310  if (!leaderboard) {
311    return;
312  }
313  const rank = await post($, leaderboard, result);
314  if (run.kind === "idle" && rank !== null) {
315    await setRun($, { kind: "idle", last: { ...result, rank } });
316  }
317};
318
319export const register: Register = (on, options) => {
320  const leaderboardUrl = String(options.leaderboardUrl ?? "");
321  const isPosting = options.leaderboard !== false && leaderboardUrl.length > 0;
322
323  on("session.start", async ($, e, next) => {
324    if (isPosting) {
325      leaderboard = {
326        name: await playerName($, String(options.playerName ?? "")),
327        url: leaderboardUrl,
328      };
329    }
330    await update($, status, () => statusOf(run, 0, postingNote()));
331    void $.ui.open({ id: PANE, title: TITLE });
332    // oxlint-disable-next-line unicorn/no-array-method-this-argument -- $.clock.every is a timer, not Array#every
333    $.clock.every(FRAME_MS, () => {
334      void pull($);
335    });
336
337    return next(e);
338  });
339
340  on("turn.start", async ($, e, next) => {
341    await setRun($, startTurn(run, await $.clock.now()));
342    return next(e);
343  });
344
345  // Subagents' steps count: the run is everything Claude spends while it lasts.
346  on("turn.step", async function* countTokens($, e, next) {
347    // oxlint-disable-next-line node/callback-return -- the usage arrives in what the chain returns
348    const result = yield* next(e);
349    if (result.usage) {
350      await setRun($, addTokens(run, result.usage.output_tokens));
351    }
352    return result;
353  });
354
355  // A subagent finishing is not Claude going quiet.
356  on("turn.complete", async ($, e, next) => {
357    if (!e.agentId) {
358      await setRun($, endTurn(run, await $.clock.now()));
359      $.clock.after(GRACE_MS, () => {
360        void finish($);
361      });
362    }
363    return next(e);
364  });
365
366  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
367    const line = await read($, status);
368
369    if (e.surface !== "terminal") {
370      const { Text } = $.ui.resolve(e);
371      return <Text>{line}</Text>;
372    }
373
374    const { Box, Image, Raster, Text } = $.ui.resolve(e);
375    const size = {
376      columns: Math.max(20, Math.min(255, e.props.bodyColumns)),
377      rows: Math.max(8, Math.min(255, (e.viewport?.rows ?? 30) - 8)),
378    };
379    screen.size = size;
380    const before = await read($, view);
381    if (screen.streamer.kind === "off" && before.kind !== "failed") {
382      await startStreamer($);
383    }
384
385    const shown = await read($, view);
386    const { latest, streamer } = screen;
387    const isMountable =
388      shown.kind === "live" &&
389      latest !== null &&
390      sameSize(latest, size) &&
391      streamer.kind === "live";
392    let game = (
393      <Text dimColor>
394        {shown.kind === "failed" ? `✗ ${shown.reason}` : "Starting Commuting Clawders…"}
395      </Text>
396    );
397    screen.mounted = null;
398    if (isMountable && screen.mode === "image" && latest.cells === undefined) {
399      screen.mounted = { ...size, mode: "image" };
400      game = (
401        <Image
402          alt="Commuting Clawders"
403          columns={size.columns}
404          key={SCREEN}
405          rows={size.rows}
406          source={{
407            file: streamer.imagePath,
408            format: "rgba",
409            generation: latest.frame,
410            height: latest.height,
411            width: latest.width,
412          }}
413        />
414      );
415    } else if (isMountable && latest.cells !== undefined) {
416      screen.mounted = { ...size, mode: "cells" };
417      game = <Raster cells={latest.cells} columns={size.columns} key={SCREEN} rows={size.rows} />;
418    }
419
420    return (
421      <Box flexDirection="column">
422        {game}
423        <Text dimColor>{line}</Text>
424      </Box>
425    );
426  });
427};
428
hooks/run.ts 70 lines
1// A run: the stretch Claude keeps working, scored by the output tokens it
2// spends. It survives a quiet gap of GRACE_MS so the next prompt can carry it
3// on; past that it is over and goes to the leaderboard.
4
5export const GRACE_MS = 5000;
6
7export interface Result {
8  tokens: number;
9  coins: number;
10  seconds: number;
11  // Where it placed among players' bests; null when the leaderboard did not answer.
12  rank: number | null;
13}
14
15export type Run =
16  | { kind: "idle"; last: Result | null }
17  | { kind: "live"; startedAt: number; tokens: number }
18  | { kind: "ending"; startedAt: number; tokens: number; endsAt: number };
19
20export const idle: Run = { kind: "idle", last: null };
21
22export const isActive = (run: Run) => run.kind !== "idle";
23
24export const startTurn = (run: Run, now: number): Run =>
25  run.kind === "idle"
26    ? { kind: "live", startedAt: now, tokens: 0 }
27    : { kind: "live", startedAt: run.startedAt, tokens: run.tokens };
28
29export const addTokens = (run: Run, tokens: number): Run =>
30  run.kind === "idle" ? run : { ...run, tokens: run.tokens + tokens };
31
32export const endTurn = (run: Run, now: number): Run =>
33  run.kind === "live" ? { ...run, endsAt: now + GRACE_MS, kind: "ending" } : run;
34
35// The run's result once its grace has passed; null while it can still go on.
36// Its time stops where Claude went quiet, not where the grace ran out.
37export const resultOf = (run: Run, now: number, coins: number): Omit<Result, "rank"> | null =>
38  run.kind === "ending" && now >= run.endsAt
39    ? {
40        coins,
41        seconds: Math.round((run.endsAt - GRACE_MS - run.startedAt) / 1000),
42        tokens: run.tokens,
43      }
44    : null;
45
46const tokensOf = (count: number) => `${count.toLocaleString("en-US")} tokens`;
47
48// `idleNote` follows the idle lines: where finished runs go, so nobody posts
49// to a public leaderboard without seeing it.
50export const statusOf = (run: Run, coins: number, idleNote = "") => {
51  const note = idleNote ? ` · ${idleNote}` : "";
52  if (run.kind === "live") {
53    return `● LIVE · ${tokensOf(run.tokens)} · ● ${coins}`;
54  }
55  if (run.kind === "ending") {
56    return `◐ prompt within ${GRACE_MS / 1000}s to keep the run · ${tokensOf(run.tokens)} · ● ${coins}`;
57  }
58  if (!run.last) {
59    return `○ idle · a run starts with your next prompt${note}`;
60  }
61  const place = run.last.rank === null ? "" : ` · #${run.last.rank} on the leaderboard`;
62  return `○ idle · last run ${tokensOf(run.last.tokens)}${place}${note}`;
63};
64
65// The leaderboard's answer to a posted run: `{ "rank": n }`.
66export const rankOf = (body: string) => {
67  const rank = Number(/"rank":\s*(?<rank>\d+)/u.exec(body)?.groups?.rank);
68  return Number.isInteger(rank) && rank > 0 ? rank : null;
69};
70
hooks/screen.ts 81 lines
1// The hooks' side of the streamer (stream/stream.ts): where it listens and
2// writes, what to ask it for, and what its answers say.
3
4export type Mode = "image" | "cells";
5
6export interface Frame {
7  frame: number;
8  width: number;
9  height: number;
10  columns: number;
11  rows: number;
12  coins: number;
13  cells?: string;
14}
15
16// A Unix socket's path fits in about 100 bytes (104 on macOS, 108 on Linux).
17const SOCKET_PATH_MAX = 100;
18
19// The socket and the frame file, in the user's temporary directory when its
20// path is short enough for a socket, else /tmp.
21export const placementOf = (tmpDir: string | undefined, id: string) => {
22  const base = (tmpDir ?? "").replace(/\/+$/u, "");
23  const name = `commuting-clawders-${id}`;
24  const dir =
25    base.startsWith("/") && `${base}/${name}.sock`.length <= SOCKET_PATH_MAX ? base : "/tmp";
26  return { imagePath: `${dir}/${name}.rgba`, socketPath: `${dir}/${name}.sock` };
27};
28
29// Whether the terminal speaks kitty's graphics protocol; when it turns out
30// not to, a refused Image blit drops back to cells.
31export const drawsImages = (term: string | undefined, termProgram: string | undefined) =>
32  /kitty|ghostty/iu.test(term ?? "") || /ghostty/iu.test(termProgram ?? "");
33
34// What the world shows besides the picture: whether the run goes on, and its
35// tokens for the score.
36export interface Run {
37  isActive: boolean;
38  tokens: number;
39}
40
41export const frameUrl = (
42  since: number,
43  mode: Mode,
44  columns: number,
45  rows: number,
46  { isActive, tokens }: Run,
47) =>
48  `http://clawders/frame?since=${since}&mode=${mode}&columns=${columns}&rows=${rows}&active=${isActive ? 1 : 0}&tokens=${tokens}`;
49
50const count = (headers: Readonly<Record<string, string>>, name: string) => {
51  const value = Number(headers[`x-${name}`] ?? Number.NaN);
52  return Number.isInteger(value) && value >= 0 ? value : Number.NaN;
53};
54
55// The streamer's answer: the frame's numbers in `x-` headers, and in the
56// body its Raster cells, empty when the frame is an Image's file.
57export const parseFrame = (
58  headers: Readonly<Record<string, string>>,
59  body: string,
60): Frame | null => {
61  const numbers = {
62    coins: count(headers, "coins"),
63    columns: count(headers, "columns"),
64    frame: count(headers, "frame"),
65    height: count(headers, "height"),
66    rows: count(headers, "rows"),
67    width: count(headers, "width"),
68  };
69  if (Object.values(numbers).some((value) => Number.isNaN(value))) {
70    return null;
71  }
72  return body ? { ...numbers, cells: body } : numbers;
73};
74
75// The last line a process wrote that says something, for the pane to show.
76export const lastWords = (output: string) =>
77  output
78    .split("\n")
79    .map((line) => line.trim())
80    .findLast((line) => line.length > 0) ?? "";
81
types/index.d.ts 14 lines
1// What the pane shows where the game goes; `live` once a frame of this size
2// and form is in.
3export type View =
4  | { kind: "starting" }
5  | { kind: "failed"; reason: string }
6  | { kind: "live"; mode: "image" | "cells"; columns: number; rows: number };
7
8declare module "claude-code" {
9  interface PluginState {
10    // `status` is the line under the game: the run, its tokens and coins.
11    "commuting-clawders": { view: View; status: string };
12  }
13}
14