Keep Clawd running

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.
packages/claude-code-types pins.CHROME_PATH if it lives somewhere unusual.PATH: it runs the streamer (dist/stream.mjs). No packages to install; the mod ships built.CLAUDE_CODE_NO_FLICKER=1 so the pane docks on the right; otherwise it waits for room.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 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/.
Start Claude Code with the mod loaded and send a prompt. A run starts:
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.
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.
Open /config and find commuting-clawders:
| Setting | Default | Options |
|---|---|---|
| Post runs to the leaderboard | on | off: runs stay local |
| Player name | git user.name | any name, up to 32 characters |
| Leaderboard URL | the shared one | another deployment of the site; empty: no posting |
You can also set them in ~/.claude/settings.json:
{
"pluginConfigs": {
"commuting-clawders": { "options": { "playerName": "kyh" } }
}
}
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.
| Symptom | Cause and fix |
|---|---|
| No pane | The terminal is too narrow (see Requirements). Widen it. |
| Pane says ✗ and a reason | The streamer stopped: usually Chrome not found (set CHROME_PATH) or no node on PATH. |
| Blocky picture | Your terminal can't show pictures; use kitty or Ghostty for the sharp one. |
| Nothing happens at all | Start with claude --debug and look for lines starting with commuting-clawders:. |
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.
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
| File | Role |
|---|---|
hooks/register.tsx | The hooks: pane, runs, posting, starting the streamer and blitting its frames |
hooks/screen.ts | The hooks' side of the streamer: socket, URL, parsing a frame |
hooks/game.ts | Runner simulation and autopilot, shared with the world |
hooks/run.ts | A 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.ts | Serves the world to headless Chrome, takes its frames, answers the hooks |
stream/chrome.ts | Where Chrome is, and how it runs: headless, throwaway profile, tied to the streamer |
stream/files.ts | The built world, served as files |
stream/frame.ts | Frame 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.ts | The $.state contract: what the pane shows, the status line |
.claude-plugin/plugin.json | Manifest and the /config options |
../ | The leaderboard: Next.js app, Turso schema, run schema, standings, page |
hooks/register.tsx 428 lines1import { 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};
428hooks/run.ts 70 lines1// 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};
70hooks/screen.ts 81 lines1// 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) ?? "";
81types/index.d.ts 14 lines1// 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