afk-snake 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jsiwinski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # afk-snake ๐Ÿ
2
+
3
+ A game you play in your terminal while you wait for your AI agents to finish a job.
4
+ No install, one command:
5
+
6
+ ```sh
7
+ npx afk-snake
8
+ ```
9
+
10
+ ## Step 1: Snake
11
+
12
+ - **Steer:** Arrow keys or **WASD**
13
+ - **Pause:** `P` โ€” freezes the game so you can jump back to your Claude terminal (`Enter` returns to Claude; an arrow key resumes)
14
+ - **Restart:** `R` ยท **Quit:** `Q` / `Ctrl-C`
15
+ - Walls and your own tail are deadly. The snake speeds up as you eat.
16
+ - Your best score is saved to `~/.term-game.json`.
17
+
18
+ Beat your high score, screenshot the game-over card, and share it โ€” the
19
+ install command is right there on the card.
20
+
21
+ ## Develop
22
+
23
+ ```sh
24
+ npm start # play from source
25
+ npm test # run the unit tests (node:test, zero deps)
26
+ ```
27
+
28
+ ## Phase 2: Auto-launch while you wait
29
+
30
+ Run your AI agent (Claude Code) **inside tmux**, then once:
31
+
32
+ ```sh
33
+ afk-snake install # adds UserPromptSubmit + Stop hooks to ~/.claude/settings.json
34
+ ```
35
+
36
+ Your game is one continuous session: it freezes when the popup closes and **resumes
37
+ right where you left off** (same score and snake) on the next prompt โ€” press any key to
38
+ un-pause. Dying, quitting (`Q`), or restarting (`R`) starts a fresh game next time.
39
+
40
+ Now every time you send the agent a prompt, Snake pops up over your terminal and
41
+ disappears the moment the agent replies. Remove it any time with:
42
+
43
+ ```sh
44
+ afk-snake uninstall
45
+ ```
46
+
47
+ Not inside tmux? The hooks stay out of your way (the popup is skipped) โ€” just run
48
+ `afk-snake` in a spare terminal instead. A separate-window fallback is future work.
49
+
50
+ MIT ยฉ jsiwinski
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "afk-snake",
3
+ "version": "0.2.0",
4
+ "description": "A terminal game to play while AI agents finish jobs. npx afk-snake",
5
+ "type": "module",
6
+ "bin": { "afk-snake": "src/index.js" },
7
+ "scripts": {
8
+ "start": "node src/index.js",
9
+ "test": "node --test"
10
+ },
11
+ "engines": { "node": ">=18" },
12
+ "license": "MIT",
13
+ "files": ["src", "README.md", "LICENSE"]
14
+ }
package/src/api.js ADDED
@@ -0,0 +1,23 @@
1
+ // src/api.js
2
+ // The ONLY client module that touches the network. `fetch` is injectable for tests.
3
+ // Callers wrap calls in try/catch and degrade to "didn't sync" โ€” gameplay never blocks.
4
+ export function createApi(baseUrl, { fetch = globalThis.fetch } = {}) {
5
+ async function call(path, token, body) {
6
+ const res = await fetch(baseUrl + path, {
7
+ method: body ? 'POST' : 'GET',
8
+ headers: {
9
+ 'content-type': 'application/json',
10
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
11
+ },
12
+ ...(body ? { body: JSON.stringify(body) } : {}),
13
+ });
14
+ if (!res.ok) throw new Error(`http ${res.status}`);
15
+ return res.json();
16
+ }
17
+ return {
18
+ postGame: (token, facts) => call('/games', token, facts),
19
+ postUnlock: (token, itemId) => call('/unlocks', token, { itemId }),
20
+ getMe: (token) => call('/me', token),
21
+ postLogin: (githubToken) => call('/login', null, { githubToken }),
22
+ };
23
+ }
package/src/cli.js ADDED
@@ -0,0 +1,22 @@
1
+ import { run } from './loop.js';
2
+ import { install, uninstall } from './install.js';
3
+ import { startPopup, stopPopup } from './popup.js';
4
+ import { login } from './login.js';
5
+
6
+ const SUBCOMMANDS = new Set(['install', 'uninstall', '_start', '_stop', 'login']);
7
+
8
+ export function parseCommand(argv) {
9
+ const arg = argv[2];
10
+ return SUBCOMMANDS.has(arg) ? arg : 'play';
11
+ }
12
+
13
+ export function main(argv = process.argv, env = process.env) {
14
+ switch (parseCommand(argv)) {
15
+ case 'install': return install(env);
16
+ case 'uninstall': return uninstall(env);
17
+ case '_start': return startPopup(env);
18
+ case '_stop': return stopPopup(env);
19
+ case 'login': return login().then((r) => { if (!r || !r.ok) process.exit(1); });
20
+ default: return run();
21
+ }
22
+ }
@@ -0,0 +1,67 @@
1
+ // src/economyClient.js
2
+ // The loop's single touchpoint for the account + token economy. All network calls
3
+ // degrade silently (gameplay never blocks). `api` and `loadAuth` are injectable.
4
+ import { createApi } from './api.js';
5
+ import { loadAuth as realLoadAuth } from './profile.js';
6
+ import { hasGlow, SKIN_GLOW, GLOW_PRICE } from './skins.js';
7
+
8
+ const DEFAULT_BASE = 'https://term-game-api.jsiwinski2.workers.dev';
9
+ const isUnauthorized = (err) => /\b401\b/.test(String(err && err.message));
10
+
11
+ export function createEconomyClient({ api, loadAuth = realLoadAuth, env = process.env } = {}) {
12
+ const client = api || createApi(env.TERM_GAME_API || DEFAULT_BASE);
13
+ const state = { loggedIn: false, token: null, login: null, balance: 0, best: 0, unlocks: [] };
14
+
15
+ async function load() {
16
+ const auth = loadAuth();
17
+ if (!auth) return;
18
+ state.loggedIn = true;
19
+ state.token = auth.token;
20
+ state.login = auth.login;
21
+ try {
22
+ const me = await client.getMe(auth.token);
23
+ state.balance = me.balance;
24
+ state.best = me.best;
25
+ state.unlocks = me.unlocks || [];
26
+ } catch {
27
+ // logged in but /me unreachable: keep defaults, no glow this session
28
+ }
29
+ }
30
+
31
+ async function submitGame(facts) {
32
+ if (!state.loggedIn) return null;
33
+ try {
34
+ const r = await client.postGame(state.token, facts);
35
+ state.balance = r.balance;
36
+ state.best = r.best;
37
+ return r;
38
+ } catch (err) {
39
+ if (isUnauthorized(err)) state.loggedIn = false;
40
+ return null;
41
+ }
42
+ }
43
+
44
+ async function buyGlow() {
45
+ if (!state.loggedIn) return { ok: false };
46
+ try {
47
+ const r = await client.postUnlock(state.token, SKIN_GLOW);
48
+ state.balance = r.balance;
49
+ state.unlocks = r.unlocks || state.unlocks;
50
+ return { ok: true, balance: r.balance };
51
+ } catch (err) {
52
+ if (isUnauthorized(err)) state.loggedIn = false;
53
+ return { ok: false };
54
+ }
55
+ }
56
+
57
+ return {
58
+ load,
59
+ submitGame,
60
+ buyGlow,
61
+ hasGlow: () => hasGlow(state.unlocks),
62
+ canBuyGlow: () => state.loggedIn && !hasGlow(state.unlocks) && state.balance >= GLOW_PRICE,
63
+ get loggedIn() { return state.loggedIn; },
64
+ get balance() { return state.balance; },
65
+ get best() { return state.best; },
66
+ };
67
+ }
@@ -0,0 +1,8 @@
1
+ // src/economyFooter.js
2
+ // Pure: the one game-over line that sells the loop. Kept โ‰ค 30 chars so it fits
3
+ // the centered game-over card's inner width. No I/O, no network.
4
+ export function economyLine({ loggedIn, awarded = 0, balance = 0, canAfford = false } = {}) {
5
+ if (!loggedIn) return `+${awarded} tokens [A] keep them`;
6
+ if (canAfford) return `+${awarded} bal ${balance} [S] glow skin`;
7
+ return `+${awarded} earned balance ${balance}`;
8
+ }
package/src/engine.js ADDED
@@ -0,0 +1,89 @@
1
+ export const DIRS = {
2
+ up: { x: 0, y: -1 },
3
+ down: { x: 0, y: 1 },
4
+ left: { x: -1, y: 0 },
5
+ right: { x: 1, y: 0 },
6
+ };
7
+
8
+ export const FOOD_COUNT = 3;
9
+
10
+ export function spawnFood(width, height, blocked, rng) {
11
+ const occupied = new Set(blocked.map((c) => `${c.x},${c.y}`));
12
+ const free = [];
13
+ for (let y = 0; y < height; y++) {
14
+ for (let x = 0; x < width; x++) {
15
+ if (!occupied.has(`${x},${y}`)) free.push({ x, y });
16
+ }
17
+ }
18
+ if (free.length === 0) return null;
19
+ return free[Math.floor(rng() * free.length)];
20
+ }
21
+
22
+ // Top a foods array up toward FOOD_COUNT, never placing one on the snake or an
23
+ // existing food. Returns as many as fit if the board lacks free cells.
24
+ export function fillFoods(width, height, snake, foods, rng) {
25
+ const result = [...foods];
26
+ while (result.length < FOOD_COUNT) {
27
+ const food = spawnFood(width, height, [...snake, ...result], rng);
28
+ if (!food) break;
29
+ result.push(food);
30
+ }
31
+ return result;
32
+ }
33
+
34
+ export function createGame({ width, height, rng = Math.random }) {
35
+ const cx = Math.floor(width / 2);
36
+ const cy = Math.floor(height / 2);
37
+ const snake = [
38
+ { x: cx, y: cy },
39
+ { x: cx - 1, y: cy },
40
+ { x: cx - 2, y: cy },
41
+ ];
42
+ const dir = DIRS.right;
43
+ const foods = fillFoods(width, height, snake, [], rng);
44
+ return { width, height, snake, dir, pendingDir: dir, foods, score: 0, status: 'playing', rng };
45
+ }
46
+
47
+ function isOpposite(a, b) {
48
+ return a.x === -b.x && a.y === -b.y;
49
+ }
50
+
51
+ export function turn(state, dirName) {
52
+ const dir = DIRS[dirName];
53
+ if (!dir) return state;
54
+ if (state.snake.length > 1 && isOpposite(dir, state.dir)) return state;
55
+ return { ...state, pendingDir: dir };
56
+ }
57
+
58
+ export function step(state) {
59
+ if (state.status !== 'playing') return state;
60
+ const dir = state.pendingDir;
61
+ const head = state.snake[0];
62
+ const newHead = { x: head.x + dir.x, y: head.y + dir.y };
63
+
64
+ if (
65
+ newHead.x < 0 || newHead.x >= state.width ||
66
+ newHead.y < 0 || newHead.y >= state.height
67
+ ) {
68
+ return { ...state, dir, status: 'over' };
69
+ }
70
+
71
+ const eatenIndex = state.foods.findIndex((f) => f.x === newHead.x && f.y === newHead.y);
72
+ const willEat = eatenIndex !== -1;
73
+ const body = willEat ? state.snake : state.snake.slice(0, -1);
74
+ if (body.some((c) => c.x === newHead.x && c.y === newHead.y)) {
75
+ return { ...state, dir, status: 'over' };
76
+ }
77
+
78
+ const newSnake = [newHead, ...state.snake];
79
+ let score = state.score;
80
+ let foods = state.foods;
81
+ if (willEat) {
82
+ score += 1;
83
+ const remaining = state.foods.filter((_, i) => i !== eatenIndex);
84
+ foods = fillFoods(state.width, state.height, newSnake, remaining, state.rng);
85
+ } else {
86
+ newSnake.pop();
87
+ }
88
+ return { ...state, dir, snake: newSnake, foods, score };
89
+ }
@@ -0,0 +1,27 @@
1
+ // Built programmatically so every line is exactly the same length โ€” required for the
2
+ // centered overlay (overlayCenter). No wide emoji (which would break column alignment).
3
+ export function gameOverCard({ score, best, isNewBest, economyLine }) {
4
+ const W = 30; // inner width (between the side borders)
5
+ const pad = (s, w = W) => {
6
+ const total = Math.max(0, w - s.length);
7
+ const left = Math.floor(total / 2);
8
+ return ' '.repeat(left) + s + ' '.repeat(total - left);
9
+ };
10
+ const leftLine = (s) => (' ' + s).padEnd(W).slice(0, W);
11
+ const row = (s) => 'โ”‚' + s + 'โ”‚';
12
+ const rows = [
13
+ 'โ”Œ' + 'โ”€'.repeat(W) + 'โ”',
14
+ row(pad('G A M E O V E R')),
15
+ row(' '.repeat(W)),
16
+ row(leftLine('Score: ' + score)),
17
+ row(leftLine('Best: ' + best)),
18
+ row(isNewBest ? pad('new best!') : ' '.repeat(W)),
19
+ ];
20
+ if (economyLine) rows.push(row(leftLine(economyLine)));
21
+ rows.push(
22
+ row(leftLine('play: npx afk-snake')),
23
+ 'โ””' + 'โ”€'.repeat(W) + 'โ”˜',
24
+ pad('[R] play again [Q] quit', W + 2), // matches the box outer width (W + 2 borders)
25
+ );
26
+ return rows.join('\n');
27
+ }
package/src/glow.js ADDED
@@ -0,0 +1,15 @@
1
+ // src/glow.js
2
+ // Pure comet-trail coloring: a bright bold head dimming toward the tail, using
3
+ // 256-colour ANSI. Each painted cell is self-contained (colour + char + reset),
4
+ // so colour never bleeds between cells.
5
+ const ESC = '\x1b[';
6
+ export const RESET = `${ESC}0m`;
7
+ const RAMP = [231, 230, 228, 222, 220, 214, 208, 202, 196]; // white -> yellow -> orange -> red
8
+
9
+ export function cometColor(index) {
10
+ return `${ESC}38;5;${RAMP[Math.min(index, RAMP.length - 1)]}m`;
11
+ }
12
+
13
+ export function paintCell(ch, index) {
14
+ return `${cometColor(index)}${ch}${RESET}`;
15
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export const DEFAULT_PATH = join(homedir(), '.term-game.json');
6
+
7
+ export function loadHighScore(path = DEFAULT_PATH) {
8
+ try {
9
+ const data = JSON.parse(readFileSync(path, 'utf8'));
10
+ return typeof data.best === 'number' ? data.best : 0;
11
+ } catch {
12
+ return 0;
13
+ }
14
+ }
15
+
16
+ export function saveHighScore(score, path = DEFAULT_PATH) {
17
+ writeFileSync(path, JSON.stringify({ best: score }), 'utf8');
18
+ }
19
+
20
+ export function updateHighScore(score, path = DEFAULT_PATH) {
21
+ const best = loadHighScore(path);
22
+ if (score > best) {
23
+ saveHighScore(score, path);
24
+ return { best: score, isNewBest: true };
25
+ }
26
+ return { best, isNewBest: false };
27
+ }
28
+
29
+ // Pure, in-memory best tracking โ€” no file I/O. No-account play uses this so the
30
+ // best lives only for the sitting (durable best moves to the account/server).
31
+ export function nextBest(best, score) {
32
+ return score > best ? { best: score, isNewBest: true } : { best, isNewBest: false };
33
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,38 @@
1
+ // A harmless trailing shell comment that tags our hook commands so uninstall
2
+ // can find them regardless of how the launcher was resolved.
3
+ export const MARKER = '# term-game-phase2';
4
+
5
+ const EVENTS = { UserPromptSubmit: '_start', Stop: '_stop' };
6
+
7
+ function entryFor(launcherCmd, sub) {
8
+ return { hooks: [{ type: 'command', command: `${launcherCmd} ${sub} ${MARKER}` }] };
9
+ }
10
+
11
+ function isOurEntry(group) {
12
+ // Match only commands ending in the marker (entryFor always appends it last),
13
+ // so a user hook that merely contains the marker string is never touched.
14
+ return (
15
+ group &&
16
+ Array.isArray(group.hooks) &&
17
+ group.hooks.some((h) => typeof h.command === 'string' && h.command.endsWith(` ${MARKER}`))
18
+ );
19
+ }
20
+
21
+ export function withoutTermGameHooks(settings) {
22
+ if (!settings.hooks) return settings;
23
+ const hooks = {};
24
+ for (const [event, groups] of Object.entries(settings.hooks)) {
25
+ const kept = (groups || []).filter((g) => !isOurEntry(g));
26
+ if (kept.length) hooks[event] = kept; // drop now-empty events
27
+ }
28
+ return { ...settings, hooks };
29
+ }
30
+
31
+ export function withTermGameHooks(settings, launcherCmd) {
32
+ const hooks = { ...(settings.hooks || {}) };
33
+ for (const [event, sub] of Object.entries(EVENTS)) {
34
+ const existing = (hooks[event] || []).filter((g) => !isOurEntry(g)); // drop stale -> idempotent
35
+ hooks[event] = [...existing, entryFor(launcherCmd, sub)];
36
+ }
37
+ return { ...settings, hooks };
38
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ main();
package/src/input.js ADDED
@@ -0,0 +1,20 @@
1
+ export function mapKey(data) {
2
+ const s = data.toString();
3
+ switch (s) {
4
+ case '\x1b[A': return 'up';
5
+ case '\x1b[B': return 'down';
6
+ case '\x1b[C': return 'right';
7
+ case '\x1b[D': return 'left';
8
+ case 'w': case 'W': return 'up';
9
+ case 's': case 'S': return 'down';
10
+ case 'a': case 'A': return 'left';
11
+ case 'd': case 'D': return 'right';
12
+ case 'r': case 'R': return 'restart';
13
+ case 'p': case 'P': return 'pause';
14
+ case 'q': case 'Q': return 'quit';
15
+ case ' ': return 'space';
16
+ case '\r': case '\n': return 'enter';
17
+ case '\x03': return 'quit'; // Ctrl-C
18
+ default: return null;
19
+ }
20
+ }
package/src/install.js ADDED
@@ -0,0 +1,32 @@
1
+ import { settingsPath, readSettings, writeSettings } from './settings.js';
2
+ import { withTermGameHooks, withoutTermGameHooks } from './hooks.js';
3
+ import { launcherCommand } from './launcher.js';
4
+
5
+ function manualSnippet(cmd) {
6
+ const snippet = {
7
+ hooks: {
8
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${cmd} _start` }] }],
9
+ Stop: [{ hooks: [{ type: 'command', command: `${cmd} _stop` }] }],
10
+ },
11
+ };
12
+ return JSON.stringify(snippet, null, 2) + '\n';
13
+ }
14
+
15
+ export function install(env = process.env, out = process.stdout, cmd = launcherCommand()) {
16
+ const path = settingsPath(env);
17
+ writeSettings(path, withTermGameHooks(readSettings(path), cmd));
18
+ out.write(`afk-snake: installed wait-game hooks in ${path}\n`);
19
+ out.write(` UserPromptSubmit -> ${cmd} _start\n`);
20
+ out.write(` Stop -> ${cmd} _stop\n`);
21
+ out.write(`Run your agent inside tmux for the popup. Remove with: afk-snake uninstall\n`);
22
+ out.write(`\nManual equivalent (merge into ${path} yourself if you prefer;\n`);
23
+ out.write(`note: hooks added by hand must also be removed by hand โ€” uninstall only\n`);
24
+ out.write(`removes hooks written by 'afk-snake install'):\n`);
25
+ out.write(manualSnippet(cmd));
26
+ }
27
+
28
+ export function uninstall(env = process.env, out = process.stdout) {
29
+ const path = settingsPath(env);
30
+ writeSettings(path, withoutTermGameHooks(readSettings(path)));
31
+ out.write(`afk-snake: removed wait-game hooks from ${path}\n`);
32
+ }
@@ -0,0 +1,37 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, join } from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+
5
+ // This package's own entry point โ€” always present, so it is the reliable
6
+ // dev/local fallback for launching the game.
7
+ export const LOCAL_ENTRY = join(dirname(fileURLToPath(import.meta.url)), 'index.js');
8
+
9
+ export function isInsideTmux(env = process.env) {
10
+ return Boolean(env.TMUX);
11
+ }
12
+
13
+ // Pure. onPath: did `term-game` resolve on PATH? (Param is named `onPath`, not
14
+ // `termGameOnPath`, to avoid colliding with the probe function of that name.)
15
+ // Tier 1 also covers a global install and `npx afk-snake` after publish.
16
+ export function resolveGameCommand({ onPath, localEntry = LOCAL_ENTRY }) {
17
+ if (onPath) return ['afk-snake'];
18
+ return ['node', localEntry];
19
+ }
20
+
21
+ // Thin (not unit-tested): probe whether `term-game` is runnable on PATH.
22
+ export function termGameOnPath() {
23
+ try {
24
+ execSync('command -v afk-snake', { stdio: 'ignore', shell: '/bin/sh' });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ // Thin: the resolved launch command as a single string, for hook commands.
32
+ // Path segments containing spaces are quoted so a space in the install dir
33
+ // doesn't break shell tokenisation when the string is embedded in a hook.
34
+ export function launcherCommand() {
35
+ const parts = resolveGameCommand({ onPath: termGameOnPath() });
36
+ return parts.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
37
+ }
package/src/login.js ADDED
@@ -0,0 +1,57 @@
1
+ // src/login.js
2
+ // The `afk-snake login` GitHub device flow. Runs in a normal shell (never inside the
3
+ // real-time popup). Everything is injectable so it tests without network or delays.
4
+ import { saveAuth as realSaveAuth } from './profile.js';
5
+ import { createApi } from './api.js';
6
+
7
+ // Public OAuth client id for the term-game device flow (no secret needed).
8
+ export const GITHUB_CLIENT_ID = 'Ov23liVs5WRAqLXaySlG';
9
+ const DEFAULT_BASE = 'https://term-game-api.jsiwinski2.workers.dev';
10
+
11
+ export async function login({
12
+ fetch = globalThis.fetch,
13
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
14
+ out = process.stdout,
15
+ saveAuth = realSaveAuth,
16
+ env = process.env,
17
+ clientId = GITHUB_CLIENT_ID,
18
+ } = {}) {
19
+ const codeRes = await fetch('https://github.com/login/device/code', {
20
+ method: 'POST',
21
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
22
+ body: JSON.stringify({ client_id: clientId, scope: '' }),
23
+ });
24
+ const code = await codeRes.json();
25
+ out.write(`\nGo to ${code.verification_uri} and enter code: ${code.user_code}\n`);
26
+
27
+ let interval = code.interval || 5;
28
+ const deadline = (code.expires_in || 900);
29
+ let elapsed = 0;
30
+ while (elapsed < deadline) {
31
+ await sleep(interval * 1000);
32
+ elapsed += interval;
33
+ const tokRes = await fetch('https://github.com/login/oauth/access_token', {
34
+ method: 'POST',
35
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
36
+ body: JSON.stringify({
37
+ client_id: clientId,
38
+ device_code: code.device_code,
39
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
40
+ }),
41
+ });
42
+ const tok = await tokRes.json();
43
+ if (tok.access_token) {
44
+ const api = createApi(env.TERM_GAME_API || DEFAULT_BASE, { fetch });
45
+ const session = await api.postLogin(tok.access_token);
46
+ saveAuth({ token: session.token, login: session.login });
47
+ out.write(`\nWelcome, @${session.login}\n`);
48
+ return { ok: true, login: session.login };
49
+ }
50
+ if (tok.error === 'authorization_pending') continue;
51
+ if (tok.error === 'slow_down') { interval += 5; continue; }
52
+ out.write(`\nLogin failed: ${tok.error || 'unknown error'}\n`);
53
+ return { ok: false, error: tok.error };
54
+ }
55
+ out.write('\nLogin timed out. Run `afk-snake login` again.\n');
56
+ return { ok: false, error: 'timeout' };
57
+ }
package/src/loop.js ADDED
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createGame, turn, step } from './engine.js';
3
+ import { drawFrame, fitBoard, clearScreen, overlayCenter } from './render.js';
4
+ import { mapKey } from './input.js';
5
+ import { nextBest } from './highscore.js';
6
+ import { gameOverCard } from './gameover.js';
7
+ import { readyCard } from './readycard.js';
8
+ import { loadSession, saveSession, clearSession } from './session.js';
9
+ import { canResume } from './resume.js';
10
+ import { createEconomyClient } from './economyClient.js';
11
+ import { economyLine } from './economyFooter.js';
12
+
13
+ const BASE_TICK_MS = 100;
14
+ const MIN_TICK_MS = 55;
15
+ const HIDE_CURSOR = '\x1b[?25l';
16
+ const SHOW_CURSOR = '\x1b[?25h';
17
+ const PLAY_HINT = 'p pause ยท q quit';
18
+ const PAUSE_HINT = 'paused ยท arrows resume ยท Enter back to Claude ยท q quit';
19
+ const READY_FOOTER = 'โœ“ agent ready ยท Enter to return';
20
+
21
+ export function run(out = process.stdout, inp = process.stdin, economy = createEconomyClient()) {
22
+ const size = fitBoard(out.columns, out.rows);
23
+ if (!size) {
24
+ out.write('afk-snake needs a bigger terminal window. Resize and try again.\n');
25
+ process.exit(1);
26
+ }
27
+
28
+ const saved = loadSession();
29
+ const resuming = canResume(saved, size);
30
+ let state = resuming ? saved : createGame(size);
31
+ let lastScore = state.score;
32
+ let paused = resuming;
33
+ let ready = false;
34
+ let agentReady = false;
35
+ let best = 0;
36
+ let gameId = randomUUID();
37
+ let startTime = Date.now();
38
+ let economyText = null; // the game-over economy line, once known
39
+ let glowPending = false; // guards against a double-buy while /unlocks is in flight
40
+ let timer = null;
41
+
42
+ // Pull account state in the background; when it lands, refresh best + redraw (for glow).
43
+ economy.load().then(() => { best = economy.best; draw(); }).catch(() => {});
44
+
45
+ const tickMs = () => Math.max(MIN_TICK_MS, BASE_TICK_MS - state.score * 6);
46
+
47
+ const cleanup = () => {
48
+ if (timer) { clearInterval(timer); timer = null; }
49
+ if (inp.isTTY) inp.setRawMode(false);
50
+ inp.pause();
51
+ out.write(SHOW_CURSOR + '\n');
52
+ };
53
+ const quit = (code = 0) => { clearSession(); cleanup(); process.exit(code); };
54
+ const saveAndExit = () => {
55
+ if (state.status === 'playing') saveSession(state);
56
+ cleanup();
57
+ process.exit(0);
58
+ };
59
+
60
+ const showReady = () => {
61
+ agentReady = true;
62
+ ready = true;
63
+ if (timer) { clearInterval(timer); timer = null; }
64
+ draw();
65
+ };
66
+
67
+ const board = () => drawFrame(state, { glow: economy.hasGlow() });
68
+
69
+ const drawGameOver = () => {
70
+ const card = gameOverCard({ score: state.score, best, isNewBest: state.score >= best && state.score > 0, economyLine: economyText });
71
+ out.write(clearScreen() + overlayCenter(board(), card.split('\n')).join('\r\n'));
72
+ };
73
+
74
+ const draw = () => {
75
+ if (state.status === 'over') return drawGameOver();
76
+ if (ready) {
77
+ const composed = overlayCenter(board(), readyCard().split('\n'));
78
+ out.write(clearScreen() + composed.join('\r\n'));
79
+ return;
80
+ }
81
+ const lines = board();
82
+ if (paused) lines.push(PAUSE_HINT);
83
+ else if (agentReady) lines.push(READY_FOOTER);
84
+ else if (state.status === 'playing') lines[lines.length - 1] += ' ยท ' + PLAY_HINT;
85
+ out.write(clearScreen() + lines.join('\r\n'));
86
+ };
87
+
88
+ const schedule = () => {
89
+ if (timer) clearInterval(timer);
90
+ timer = setInterval(onTick, tickMs());
91
+ };
92
+
93
+ const newGame = () => {
94
+ clearSession();
95
+ state = createGame(size);
96
+ lastScore = 0;
97
+ paused = false;
98
+ gameId = randomUUID();
99
+ startTime = Date.now();
100
+ economyText = null;
101
+ draw();
102
+ schedule();
103
+ };
104
+
105
+ // On game-over: set best, show the card immediately, then (logged in) post facts and
106
+ // refresh the economy line with the server's truth.
107
+ function onGameOver() {
108
+ clearInterval(timer); timer = null;
109
+ clearSession();
110
+ best = nextBest(best, state.score).best;
111
+ if (economy.loggedIn) {
112
+ economyText = economyLine({ loggedIn: true, awarded: state.score, balance: economy.balance, canAfford: economy.canBuyGlow() });
113
+ drawGameOver();
114
+ economy.submitGame({ gameId, score: state.score, durationMs: Date.now() - startTime, foodsEaten: state.score })
115
+ .then((r) => {
116
+ if (!r) return;
117
+ best = nextBest(best, r.best).best;
118
+ economyText = economyLine({ loggedIn: true, awarded: r.awarded, balance: economy.balance, canAfford: economy.canBuyGlow() });
119
+ drawGameOver();
120
+ })
121
+ .catch(() => {});
122
+ } else {
123
+ economyText = economyLine({ loggedIn: false, awarded: state.score });
124
+ drawGameOver();
125
+ }
126
+ }
127
+
128
+ function onTick() {
129
+ state = step(state);
130
+ if (state.status === 'over') return onGameOver();
131
+ if (state.score !== lastScore) { lastScore = state.score; schedule(); }
132
+ draw();
133
+ }
134
+
135
+ function onKey(data) {
136
+ const intent = mapKey(data);
137
+ if (ready) {
138
+ if (intent === 'enter') return saveAndExit();
139
+ const RESUME = ['space', 'up', 'down', 'left', 'right'];
140
+ if (RESUME.includes(intent)) {
141
+ if (state.status !== 'playing') return;
142
+ ready = false;
143
+ paused = true;
144
+ draw();
145
+ return;
146
+ }
147
+ return;
148
+ }
149
+ // Game-over: [S] buys the glow skin, [A] points to `afk-snake login`. Read the raw
150
+ // key because 's'/'a' map to movement (WASD), which is irrelevant on a dead game.
151
+ if (state.status === 'over') {
152
+ const raw = data.toString().toLowerCase();
153
+ if (raw === 's' && economy.canBuyGlow() && !glowPending) {
154
+ glowPending = true;
155
+ economy.buyGlow().then((res) => {
156
+ glowPending = false;
157
+ economyText = res.ok
158
+ ? 'Glowing trail unlocked'
159
+ : economyLine({ loggedIn: economy.loggedIn, awarded: state.score, balance: economy.balance, canAfford: economy.canBuyGlow() });
160
+ drawGameOver();
161
+ }).catch(() => { glowPending = false; });
162
+ return;
163
+ }
164
+ if (raw === 'a' && !economy.loggedIn) {
165
+ economyText = 'run: afk-snake login';
166
+ drawGameOver();
167
+ return;
168
+ }
169
+ }
170
+ if (paused) {
171
+ if (intent === 'quit') return quit(0);
172
+ if (intent === 'restart') return newGame();
173
+ if (intent === 'enter') return saveAndExit();
174
+ paused = false;
175
+ if (intent && intent !== 'space' && intent !== 'pause') state = turn(state, intent);
176
+ draw();
177
+ schedule();
178
+ return;
179
+ }
180
+ if (!intent) return;
181
+ if (intent === 'quit') return quit(0);
182
+ if (intent === 'restart') return newGame();
183
+ if (intent === 'enter') { if (agentReady) return saveAndExit(); return; }
184
+ if (intent === 'pause') {
185
+ if (state.status !== 'playing') return;
186
+ paused = true;
187
+ if (timer) { clearInterval(timer); timer = null; }
188
+ draw();
189
+ return;
190
+ }
191
+ if (intent === 'space') return;
192
+ if (state.status === 'playing') state = turn(state, intent);
193
+ }
194
+
195
+ if (inp.isTTY) inp.setRawMode(true);
196
+ inp.resume();
197
+ inp.on('data', onKey);
198
+ process.on('SIGINT', () => quit(0));
199
+ process.on('SIGTERM', saveAndExit);
200
+ process.on('SIGUSR2', showReady);
201
+ out.write(HIDE_CURSOR);
202
+ draw();
203
+ if (!paused) schedule();
204
+ }
package/src/popup.js ADDED
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { isInsideTmux, resolveGameCommand, termGameOnPath, LOCAL_ENTRY } from './launcher.js';
6
+
7
+ // The popup runs a wrapper shell that records its OWN pid (which `exec` then
8
+ // hands to the game process) so the Stop hook can SIGTERM the real game. The
9
+ // game's existing SIGTERM handler restores the terminal and exits, the popup
10
+ // command finishes, and tmux closes the popup.
11
+ export function buildPopupCommand(gameArgv, pidfile) {
12
+ // Quote argv parts and the pidfile that contain spaces, so an install path
13
+ // like `~/My Projects/term_game` doesn't word-split inside the wrapper shell.
14
+ const game = gameArgv.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
15
+ return `sh -c 'echo $$ > "${pidfile}"; exec ${game}'`;
16
+ }
17
+
18
+ export function pidfilePath(env = process.env) {
19
+ return join(env.TMPDIR || tmpdir(), 'term-game-popup.pid');
20
+ }
21
+
22
+ function pidAlive(pid) {
23
+ try { process.kill(pid, 0); return true; } catch { return false; }
24
+ }
25
+
26
+ // UserPromptSubmit hook: agent started working.
27
+ export function startPopup(env = process.env, out = process.stderr) {
28
+ if (!isInsideTmux(env)) {
29
+ out.write('afk-snake: run inside tmux to enable the wait-game popup.\n');
30
+ return;
31
+ }
32
+ const pidfile = pidfilePath(env);
33
+ if (existsSync(pidfile)) {
34
+ const pid = Number(readFileSync(pidfile, 'utf8').trim());
35
+ if (pid && pidAlive(pid)) return; // already up -> no stacked popups
36
+ }
37
+ const gameArgv = resolveGameCommand({ onPath: termGameOnPath(), localEntry: LOCAL_ENTRY });
38
+ const popupCmd = buildPopupCommand(gameArgv, pidfile);
39
+ // Detached + unref so the hook returns immediately and never blocks the agent.
40
+ const child = spawn('tmux', ['display-popup', '-E', '-w', '90%', '-h', '90%', popupCmd], {
41
+ stdio: 'ignore',
42
+ detached: true,
43
+ });
44
+ // spawn errors (e.g. tmux not on PATH) arrive async as an 'error' event;
45
+ // swallow them so a hook environment without tmux never crashes the agent.
46
+ child.on('error', () => {});
47
+ child.unref();
48
+ }
49
+
50
+ // Stop hook: agent finished its turn. Signal the running game (SIGUSR2) to show its
51
+ // "Your Agent is Ready" card. Do NOT kill it or close the popup โ€” the game closes itself
52
+ // when the player presses Enter. Leave the pidfile in place (the game is still alive).
53
+ export function stopPopup(env = process.env) {
54
+ const pidfile = pidfilePath(env);
55
+ if (!existsSync(pidfile)) return;
56
+ const pid = Number(readFileSync(pidfile, 'utf8').trim());
57
+ if (pid && pidAlive(pid)) {
58
+ try { process.kill(pid, 'SIGUSR2'); } catch {}
59
+ }
60
+ }
package/src/profile.js ADDED
@@ -0,0 +1,36 @@
1
+ // src/profile.js
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const PROFILE_PATH = join(homedir(), '.term-game.json');
7
+
8
+ export function loadProfile(path = PROFILE_PATH) {
9
+ try {
10
+ const data = JSON.parse(readFileSync(path, 'utf8'));
11
+ return data && typeof data === 'object' ? data : {};
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ export function saveProfile(profile, path = PROFILE_PATH) {
18
+ writeFileSync(path, JSON.stringify(profile), 'utf8');
19
+ }
20
+
21
+ export function loadAuth(path = PROFILE_PATH) {
22
+ const { auth } = loadProfile(path);
23
+ return auth && typeof auth.token === 'string' ? auth : null;
24
+ }
25
+
26
+ export function saveAuth({ token, login }, path = PROFILE_PATH) {
27
+ const profile = loadProfile(path);
28
+ profile.auth = { token, login };
29
+ saveProfile(profile, path);
30
+ }
31
+
32
+ export function clearAuth(path = PROFILE_PATH) {
33
+ const profile = loadProfile(path);
34
+ delete profile.auth;
35
+ saveProfile(profile, path);
36
+ }
@@ -0,0 +1,21 @@
1
+ // Built programmatically so every line is exactly the same length โ€” required for the
2
+ // centered overlay (overlayCenter). No wide emoji (which would break column alignment).
3
+ export function readyCard() {
4
+ const W = 34; // inner width (between the side borders)
5
+ const pad = (s) => {
6
+ const total = Math.max(0, W - s.length);
7
+ const left = Math.floor(total / 2);
8
+ return ' '.repeat(left) + s + ' '.repeat(total - left);
9
+ };
10
+ // label padded to a fixed width so the arrows line up across action rows
11
+ const action = (label, desc) => (' ' + label.padEnd(14) + 'โ†’ ' + desc).padEnd(W).slice(0, W);
12
+ const row = (s) => 'โ”‚' + s + 'โ”‚';
13
+ return [
14
+ 'โ”Œ' + 'โ”€'.repeat(W) + 'โ”',
15
+ row(pad('YOUR AGENT IS READY')),
16
+ row(' '.repeat(W)),
17
+ row(action('Enter', 'back to Claude')),
18
+ row(action('Space/Arrows', 'resume playing')),
19
+ 'โ””' + 'โ”€'.repeat(W) + 'โ”˜',
20
+ ].join('\n');
21
+ }
package/src/render.js ADDED
@@ -0,0 +1,66 @@
1
+ import { paintCell } from './glow.js';
2
+
3
+ const RESET = '\x1b[0m';
4
+
5
+ export const CHARS = {
6
+ head: '@', body: 'o', food: '*', empty: ' ',
7
+ h: 'โ”€', v: 'โ”‚', tl: 'โ”Œ', tr: 'โ”', bl: 'โ””', br: 'โ”˜',
8
+ };
9
+
10
+ export function fitBoard(cols, rows, min = { width: 10, height: 8 }) {
11
+ const width = (cols ?? 0) - 2; // 2 vertical borders
12
+ const height = (rows ?? 0) - 3; // 2 horizontal borders + 1 score line
13
+ if (width < min.width || height < min.height) return null;
14
+ return { width, height };
15
+ }
16
+
17
+ export function drawFrame(state, { glow = false } = {}) {
18
+ const { width, height, snake, foods, score } = state;
19
+ const grid = [];
20
+ for (let y = 0; y < height; y++) grid.push(new Array(width).fill(CHARS.empty));
21
+ for (const f of foods) grid[f.y][f.x] = CHARS.food;
22
+ snake.forEach((c, i) => {
23
+ const ch = i === 0 ? CHARS.head : CHARS.body;
24
+ grid[c.y][c.x] = glow ? paintCell(ch, i) : ch;
25
+ });
26
+
27
+ const lines = [];
28
+ lines.push(CHARS.tl + CHARS.h.repeat(width) + CHARS.tr);
29
+ for (const row of grid) lines.push(CHARS.v + row.join('') + CHARS.v);
30
+ lines.push(CHARS.bl + CHARS.h.repeat(width) + CHARS.br);
31
+ lines.push(`Score: ${score}`);
32
+ return lines;
33
+ }
34
+
35
+ const ESC = '\x1b[';
36
+ export function clearScreen() {
37
+ return `${ESC}2J${ESC}H`;
38
+ }
39
+
40
+ // Split a line into visible cells (1 display column each), where any ANSI escape
41
+ // run attaches to the printable char that follows it. We only emit our own escapes
42
+ // (colour + reset), so this is sufficient.
43
+ function visibleCells(line) {
44
+ return line.match(/(?:\x1b\[[0-9;]*m)*[\s\S]/g) || [];
45
+ }
46
+
47
+ // Write `card` lines centered over `base` lines. For plain lines this is the original
48
+ // byte-for-byte behavior; for lines containing ANSI it splices by VISIBLE column and
49
+ // wraps the inserted card text in resets so ambient colour never bleeds into it.
50
+ export function overlayCenter(base, card) {
51
+ if (card.length === 0) return [...base];
52
+ const rowOffset = Math.max(0, Math.floor((base.length - card.length) / 2));
53
+ return base.map((line, i) => {
54
+ const c = card[i - rowOffset];
55
+ if (c === undefined) return line;
56
+ if (!line.includes('\x1b')) {
57
+ const colOffset = Math.max(0, Math.floor((line.length - c.length) / 2));
58
+ return line.slice(0, colOffset) + c + line.slice(colOffset + c.length);
59
+ }
60
+ const cells = visibleCells(line);
61
+ const colOffset = Math.max(0, Math.floor((cells.length - c.length) / 2));
62
+ const left = cells.slice(0, colOffset).join('');
63
+ const right = cells.slice(colOffset + c.length).join('');
64
+ return left + RESET + c + RESET + right;
65
+ });
66
+ }
package/src/resume.js ADDED
@@ -0,0 +1,11 @@
1
+ // Decide whether a loaded session should resume, or be discarded for a fresh game.
2
+ // Resume only an in-progress game whose board still fits the current popup size.
3
+ export function canResume(saved, size) {
4
+ return Boolean(
5
+ saved &&
6
+ saved.status === 'playing' &&
7
+ size &&
8
+ saved.width <= size.width &&
9
+ saved.height <= size.height,
10
+ );
11
+ }
package/src/session.js ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export function sessionPath(env = process.env) {
6
+ return join(env.TMPDIR || tmpdir(), 'term-game-session.json');
7
+ }
8
+
9
+ export function saveSession(state, env = process.env) {
10
+ const { rng, ...rest } = state; // rng is a function โ€” not JSON-serializable
11
+ writeFileSync(sessionPath(env), JSON.stringify(rest), 'utf8');
12
+ }
13
+
14
+ export function loadSession(env = process.env) {
15
+ try {
16
+ const data = JSON.parse(readFileSync(sessionPath(env), 'utf8'));
17
+ if (!data || !Array.isArray(data.snake) || typeof data.score !== 'number') return null;
18
+ // back-compat: pre-multi-food saves stored a single `food`; normalise to `foods`.
19
+ if (!Array.isArray(data.foods)) data.foods = data.food ? [data.food] : [];
20
+ delete data.food;
21
+ return { ...data, rng: Math.random };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function clearSession(env = process.env) {
28
+ rmSync(sessionPath(env), { force: true }); // force: tolerate a missing file, surface real errors
29
+ }
@@ -0,0 +1,21 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+
5
+ export function settingsPath(env = process.env) {
6
+ const home = env.HOME || homedir();
7
+ return join(home, '.claude', 'settings.json');
8
+ }
9
+
10
+ export function readSettings(path) {
11
+ try {
12
+ return JSON.parse(readFileSync(path, 'utf8'));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ export function writeSettings(path, settings) {
19
+ mkdirSync(dirname(path), { recursive: true });
20
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
21
+ }
package/src/skins.js ADDED
@@ -0,0 +1,11 @@
1
+ // src/skins.js
2
+ // Shared id for the one v1 unlock (mirrors server/src/catalog.js) + ownership check.
3
+ export const SKIN_GLOW = 'skin-glow';
4
+
5
+ export function hasGlow(unlocks = []) {
6
+ return Array.isArray(unlocks) && unlocks.includes(SKIN_GLOW);
7
+ }
8
+
9
+ // Client-side mirror of the server catalog price, used only to decide whether to
10
+ // show the [S] spend prompt. The server remains authoritative on the actual charge.
11
+ export const GLOW_PRICE = 30;