Files
daniel 65207a5200 feat(hooks): add plan-mode-game hook component
Adds a browser game (Dino Runner, Snake, Flappy Bird) that launches
automatically when Claude enters Plan Mode. Users can play while
Claude thinks, with a live activity panel showing tool calls in real time.

- EnterPlanMode / /plan → starts Node.js server on port 3456, opens browser
- Read/Bash/Edit/etc → streams tool activity to the activity panel
- Write to ~/.claude/plans/ → also triggers the game (for /plan command)
- AskUserQuestion → pauses the game until user responds
- ExitPlanMode → shows "Claude Termino!" overlay with confetti

Features: 3 games with high score persistence, signed score sharing URLs,
pause overlay when Claude needs input, activity feed with tool icons.
Requires Node.js and curl. macOS only (uses 'open' command).
Install globally in ~/.claude/settings.json to work across all projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:21:04 -04:00

226 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const PORT = 3456;
const PID_FILE = path.join(__dirname, 'server.pid');
const SECRET_FILE = path.join(__dirname, '.secret');
const sseClients = new Set();
function getSecret() {
try {
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
} catch {
const secret = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
return secret;
}
}
const hmacSecret = getSecret();
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
return res.end();
}
// Serve the game
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
const htmlPath = path.join(__dirname, 'index.html');
fs.readFile(htmlPath, (err, data) => {
if (err) { res.writeHead(500); return res.end('Error loading game'); }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(data);
});
return;
}
// SSE endpoint
if (req.method === 'GET' && req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.write('data: connected\n\n');
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
return;
}
// Trigger announcement
if (req.method === 'POST' && req.url === '/done') {
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
req.on('end', () => {
const message = body || 'Claude Termino';
for (const client of sseClients) {
client.write(`event: done\ndata: ${message}\n\n`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, clients: sseClients.size }));
});
return;
}
// Activity feed from hooks
if (req.method === 'POST' && req.url === '/activity') {
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on('end', () => {
for (const client of sseClients) {
client.write(`event: activity\ndata: ${body}\n\n`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// Pause - Claude needs user input
if (req.method === 'POST' && req.url === '/pause') {
for (const client of sseClients) {
client.write(`event: pause\ndata: Claude needs you\n\n`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
// Status - check if plan mode is active
if (req.method === 'GET' && req.url === '/status') {
const planActive = fs.existsSync(path.join(__dirname, '.plan-active'));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ planActive }));
}
// Health check
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ status: 'ok', clients: sseClients.size }));
}
// Share scores - generate signed URL
if (req.method === 'POST' && req.url === '/share') {
let body = '';
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
req.on('end', () => {
try {
const { dino, snake, flappy } = JSON.parse(body);
const payload = { d: dino || 0, s: snake || 0, f: flappy || 0, t: Date.now() };
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
const token = `${payloadB64}.${sig}`;
const url = `http://localhost:${PORT}/v/${token}`;
const text = [
'Plan Mode Game Scores:',
`\u{1F995} Dino Runner: ${payload.d} pts`,
`\u{1F40D} Snake: ${payload.s} pts`,
`\u{1F426} Flappy Bird: ${payload.f} pts`,
'',
`Verify → ${url}`
].join('\n');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url, text }));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Verify scores - render verification page
if (req.method === 'GET' && req.url.startsWith('/v/')) {
const token = req.url.slice(3);
const dotIdx = token.lastIndexOf('.');
let valid = false, payload = null;
if (dotIdx > 0) {
const payloadB64 = token.slice(0, dotIdx);
const sig = token.slice(dotIdx + 1);
const expected = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
valid = sig === expected;
try { payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { valid = false; }
}
const ts = payload && payload.t ? new Date(payload.t).toLocaleString() : 'Unknown';
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>${valid ? 'Verified' : 'Invalid'} - Plan Mode Game</title><style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#1a1a2e;color:#e5e7eb;font-family:'Courier New',monospace;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{text-align:center;max-width:420px;padding:40px;background:#12121f;border:1px solid #2d2d4e;border-radius:16px}
.icon{width:100px;height:100px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 24px;font-size:48px}
.icon.valid{background:linear-gradient(135deg,#059669,#10b981);box-shadow:0 0 40px rgba(16,185,129,0.3)}
.icon.invalid{background:linear-gradient(135deg,#dc2626,#ef4444);box-shadow:0 0 40px rgba(239,68,68,0.3)}
h1{font-size:28px;margin-bottom:8px;letter-spacing:1px}
h1.valid{color:#10b981}
h1.invalid{color:#ef4444}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:28px}
table{width:100%;border-collapse:collapse;margin-bottom:20px}
td{padding:10px 16px;border-bottom:1px solid #1e1e35;font-size:14px}
tr:last-child td{border-bottom:none}
.game{color:#d1d5db;text-align:left}
.pts{color:#a78bfa;font-weight:700;text-align:right;font-variant-numeric:tabular-nums}
.meta{color:#4b5563;font-size:11px;letter-spacing:0.5px}
.play-link{display:inline-block;margin-top:20px;color:#a78bfa;text-decoration:none;font-size:13px;letter-spacing:1px;padding:10px 24px;border:1px solid #7c3aed;border-radius:8px;transition:all 0.2s}
.play-link:hover{background:#7c3aed;color:#fff}
</style></head><body><div class="card">
<div class="icon ${valid ? 'valid' : 'invalid'}">${valid ? '✓' : '✗'}</div>
<h1 class="${valid ? 'valid' : 'invalid'}">${valid ? 'VERIFIED' : 'INVALID'}</h1>
<p class="subtitle">${valid ? 'These scores are authentic' : 'This score link has been tampered with'}</p>
${valid && payload ? `<table>
<tr><td class="game">\u{1F995} Dino Runner</td><td class="pts">${payload.d} pts</td></tr>
<tr><td class="game">\u{1F40D} Snake</td><td class="pts">${payload.s} pts</td></tr>
<tr><td class="game">\u{1F426} Flappy Bird</td><td class="pts">${payload.f} pts</td></tr>
</table>
<p class="meta">Shared on ${ts}</p>` : ''}
<a class="play-link" href="/">Play Plan Mode Game</a>
</div></body></html>`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
return;
}
res.writeHead(404);
res.end('Not found');
});
// Handle port conflicts
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} already in use. Exiting.`);
process.exit(1);
}
throw err;
});
server.listen(PORT, () => {
fs.writeFileSync(PID_FILE, String(process.pid));
console.log(`Plan Mode Game server running on http://localhost:${PORT}`);
});
// SSE heartbeat - keeps connections alive and cleans dead clients
setInterval(() => {
for (const client of sseClients) {
try { client.write(': heartbeat\n\n'); }
catch { sseClients.delete(client); }
}
}, 30000);
// Cleanup on exit
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
function cleanup() {
try { fs.unlinkSync(PID_FILE); } catch {}
server.close();
process.exit(0);
}