Compare commits

...

3 Commits

Author SHA1 Message Date
cottongin
195448644a feat: add Jackbox API Logger CLI tool
Standalone tool that connects to a Jackbox room by code, captures all
websocket events, and logs summarized output to console + full JSON to
a JSONL file. Consolidates scattered scripts/ws-probe.js,
ws-lifecycle-test.js, and get-jackbox-player-count.js into one
cohesive debugging tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 00:49:48 -04:00
cottongin
c5ffe23404 fix: detect game start for Pack 7+ titles that don't use state: "Gameplay"
Pack 7 games (Quiplash 3, Blather Round) transition room state from
"Lobby" to "Logo" instead of "Gameplay". Even newer titles (Doominate,
Big Survey) have no room entity at all — the only signals are room/lock
and room/exit opcodes.

- parseRoomEntity: detect game started as any non-Lobby state
- handleMessage: add room/lock and room/exit opcode handling
- handleRoomLock: emit game.started as fallback for room-entity-less games
- handleRoomExit: emit game.ended on explicit room close
- Tests: 6 new tests covering Logo state, room/lock, room/exit

Verified against live rooms: quiplash3, blanky-blank, you-ruined-it,
bigsurvey.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 00:23:32 -04:00
cottongin
2964cee291 chore: README: add AI warning 2026-04-05 06:07:31 -04:00
6 changed files with 647 additions and 2 deletions

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ frontend/public/manifest.json
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
logs/
# OS files # OS files
.DS_Store .DS_Store

View File

@@ -1,3 +1,6 @@
> [!IMPORTANT]
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
# Jackbox Party Pack Game Picker # Jackbox Party Pack Game Picker
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration. A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.

View File

@@ -36,7 +36,7 @@ class EcastShardClient {
lobbyState: roomVal.lobbyState ?? null, lobbyState: roomVal.lobbyState ?? null,
gameCanStart: !!roomVal.gameCanStart, gameCanStart: !!roomVal.gameCanStart,
gameIsStarting: !!roomVal.gameIsStarting, gameIsStarting: !!roomVal.gameIsStarting,
gameStarted: roomVal.state === 'Gameplay', gameStarted: roomVal.state != null && roomVal.state !== 'Lobby',
gameFinished: !!roomVal.gameFinished, gameFinished: !!roomVal.gameFinished,
}; };
} }
@@ -213,6 +213,12 @@ class EcastShardClient {
break; break;
case 'client/disconnected': case 'client/disconnected':
break; break;
case 'room/lock':
this.handleRoomLock();
break;
case 'room/exit':
this.handleRoomExit(message.result);
break;
case 'error': case 'error':
this.handleError(message.result); this.handleError(message.result);
break; break;
@@ -363,6 +369,44 @@ class EcastShardClient {
} }
} }
handleRoomLock() {
if (!this.gameStarted) {
console.log(`[Shard Monitor] Room ${this.roomCode} locked (game starting)`);
this.gameStarted = true;
this.gameState = this.gameState || 'Gameplay';
this.onEvent('game.started', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
}
handleRoomExit() {
if (this.gameFinished) return;
console.log(`[Shard Monitor] Room ${this.roomCode} exited`);
this.gameFinished = true;
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
this.disconnect();
}
handleError(result) { handleError(result) {
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`); console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
if (result?.code === 2027) { if (result?.code === 2027) {

View File

@@ -2,7 +2,7 @@ export const branding = {
app: { app: {
name: 'HSO Jackbox Game Picker', name: 'HSO Jackbox Game Picker',
shortName: 'Jackbox Game Picker', shortName: 'Jackbox Game Picker',
version: '0.6.5 - Fish Tank Edition', version: '0.7.0 - Fixed For Real Edition',
description: 'Spicing up Hyper Spaceout game nights!', description: 'Spicing up Hyper Spaceout game nights!',
}, },
meta: { meta: {

View File

@@ -50,6 +50,15 @@ describe('EcastShardClient', () => {
expect(result.gameStarted).toBe(true); expect(result.gameStarted).toBe(true);
}); });
test('detects game started from non-Lobby state (Pack 7 Logo)', () => {
const roomVal = { state: 'Logo', locale: 'en', platformId: 'PS4' };
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameStarted).toBe(true);
expect(result.gameState).toBe('Logo');
expect(result.lobbyState).toBeNull();
expect(result.gameFinished).toBe(false);
});
test('detects game finished', () => { test('detects game finished', () => {
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true }; const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
const result = EcastShardClient.parseRoomEntity(roomVal); const result = EcastShardClient.parseRoomEntity(roomVal);
@@ -272,6 +281,26 @@ describe('EcastShardClient', () => {
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']); expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
}); });
test('broadcasts game.started on state transition to Logo (Pack 7)', () => {
client.lobbyState = 'Countdown';
client.gameState = 'Lobby';
client.gameStarted = false;
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleEntityUpdate({
key: 'room',
val: { state: 'Logo', locale: 'en', platformId: 'PS4' },
version: 14, from: 1,
});
const startEvents = events.filter(e => e.type === 'game.started');
expect(startEvents).toHaveLength(1);
expect(startEvents[0].data.playerCount).toBe(4);
expect(client.gameState).toBe('Logo');
expect(client.gameStarted).toBe(true);
});
test('does not broadcast game.started if already started', () => { test('does not broadcast game.started if already started', () => {
client.gameStarted = true; client.gameStarted = true;
client.gameState = 'Gameplay'; client.gameState = 'Gameplay';
@@ -573,4 +602,80 @@ describe('EcastShardClient', () => {
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true); expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
}); });
}); });
describe('handleRoomLock', () => {
test('emits game.started when game has not yet started', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.gameStarted = false;
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleRoomLock();
expect(client.gameStarted).toBe(true);
const startEvents = events.filter(e => e.type === 'game.started');
expect(startEvents).toHaveLength(1);
expect(startEvents[0].data.playerCount).toBe(4);
});
test('does not emit game.started if game already started', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.gameStarted = true;
client.handleRoomLock();
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
});
});
describe('handleRoomExit', () => {
test('emits game.ended and room.disconnected on room exit', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.playerCount = 3;
client.playerNames = ['X', 'Y', 'Z'];
client.handleRoomExit();
expect(client.gameFinished).toBe(true);
expect(events.some(e => e.type === 'game.ended')).toBe(true);
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
});
test('does not emit events if game already finished', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.gameFinished = true;
client.handleRoomExit();
expect(events).toHaveLength(0);
});
});
}); });

492
tools/jackbox-logger.js Normal file
View File

@@ -0,0 +1,492 @@
#!/usr/bin/env node
const https = require('https');
const fs = require('fs');
const path = require('path');
let WebSocket;
try {
WebSocket = require('ws');
} catch (_) {
try {
WebSocket = require('../backend/node_modules/ws');
} catch (_2) {
console.error('Error: WebSocket library (ws) not found.');
console.error('Run: cd backend && npm install');
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// ANSI helpers
// ---------------------------------------------------------------------------
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
};
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
console.log(`
${C.bold}Jackbox API Logger${C.reset}
Connects to a Jackbox game room and logs all WebSocket events.
${C.bold}Usage:${C.reset}
node tools/jackbox-logger.js <ROOM_CODE> [options]
${C.bold}Options:${C.reset}
--role <shard|audience|player> Connection role (default: shard)
--name <name> Display name (default: JBLogger)
--no-file Skip writing to log file
--verbose Print full JSON to console
--help, -h Show this help
${C.bold}Examples:${C.reset}
node tools/jackbox-logger.js ABCD
node tools/jackbox-logger.js ABCD --role audience
node tools/jackbox-logger.js ABCD --verbose --no-file
`);
process.exit(0);
}
const opts = {
roomCode: null,
role: 'shard',
name: 'JBLogger',
noFile: false,
verbose: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--role') {
opts.role = args[++i];
} else if (arg === '--name') {
opts.name = args[++i];
} else if (arg === '--no-file') {
opts.noFile = true;
} else if (arg === '--verbose') {
opts.verbose = true;
} else if (!arg.startsWith('-') && !opts.roomCode) {
opts.roomCode = arg.toUpperCase();
}
}
if (!opts.roomCode) {
console.error(`${C.red}Error: room code is required${C.reset}`);
process.exit(1);
}
if (!['shard', 'audience', 'player'].includes(opts.role)) {
console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`);
process.exit(1);
}
return opts;
}
// ---------------------------------------------------------------------------
// REST: fetch room info
// ---------------------------------------------------------------------------
function getRoomInfo(roomCode) {
return new Promise((resolve, reject) => {
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.ok) resolve(json.body);
else reject(new Error(json.error || 'Room not found'));
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
// ---------------------------------------------------------------------------
// Timestamp
// ---------------------------------------------------------------------------
function ts() {
return new Date().toISOString().slice(11, 23);
}
// ---------------------------------------------------------------------------
// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js
// ---------------------------------------------------------------------------
function summarize(msg) {
const r = msg.result || {};
switch (msg.opcode) {
case 'client/welcome': {
const hereEntries = r.here ? Object.entries(r.here) : [];
const players = hereEntries
.filter(([, v]) => v.roles?.player)
.map(([id, v]) => `${v.roles.player.name}(${id})`);
const entityKeys = r.entities ? Object.keys(r.entities) : [];
return (
`id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` +
` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` +
` entities: [${entityKeys.join(', ')}]`
);
}
case 'object': {
if (r.key === 'room' || r.key === 'bc:room') {
const v = r.val || {};
return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`;
}
if (r.key === 'textDescriptions') {
const latest = r.val?.latestDescriptions;
if (Array.isArray(latest) && latest.length > 0) {
const last = latest[latest.length - 1];
return `TEXT "${last.text}" (${last.category}) v${r.version}`;
}
return `textDescriptions v${r.version}`;
}
if (r.key?.startsWith('player:')) {
const v = r.val || {};
return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`;
}
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
}
case 'client/connected': {
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
const playerName = r.roles?.player?.name || r.name || '';
return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`;
}
case 'client/disconnected': {
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
return `id=${r.id} role=${roleName}`;
}
case 'client/kicked':
return JSON.stringify(r).slice(0, 200);
case 'room/lock':
return 'room locked (game starting)';
case 'room/exit':
return 'room closed';
case 'room/get-audience':
return `audience connections=${r.connections}`;
case 'error':
return `code=${r.code}: ${r.msg}`;
case 'ok':
return `seq response`;
default:
return JSON.stringify(r).slice(0, 200);
}
}
function opcodeColor(opcode) {
if (opcode === 'client/welcome') return C.green;
if (opcode === 'error') return C.red;
if (opcode === 'client/connected') return C.cyan;
if (opcode === 'client/disconnected') return C.yellow;
if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta;
if (opcode === 'object') return C.white;
return C.dim;
}
// ---------------------------------------------------------------------------
// File logger
// ---------------------------------------------------------------------------
class FileLogger {
constructor(roomCode) {
const logsDir = path.join(__dirname, '..', 'logs');
fs.mkdirSync(logsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`);
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
}
write(entry) {
this.stream.write(JSON.stringify(entry) + '\n');
}
close() {
this.stream.end();
}
}
// ---------------------------------------------------------------------------
// Main logger
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const { roomCode, role, name, noFile, verbose } = opts;
console.log(`${C.bold}Jackbox API Logger${C.reset}`);
console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`);
console.log();
// Fetch room info
console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`);
let roomInfo;
try {
roomInfo = await getRoomInfo(roomCode);
} catch (e) {
console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`);
process.exit(1);
}
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`);
console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`);
console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`);
console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`);
console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`);
console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`);
console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`);
console.log();
// File logger
let fileLogger = null;
if (!noFile) {
fileLogger = new FileLogger(roomCode);
console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`);
fileLogger.write({
ts: new Date().toISOString(),
direction: 'meta',
type: 'session_start',
roomCode,
role,
name,
host: roomInfo.host,
appTag: roomInfo.appTag,
maxPlayers: roomInfo.maxPlayers,
locked: roomInfo.locked,
full: roomInfo.full,
audienceEnabled: roomInfo.audienceEnabled,
});
}
// State for reconnection
let shardId = null;
let secret = null;
let msgCount = 0;
let reconnecting = false;
let manuallyStopped = false;
const startTime = Date.now();
function buildWsUrl(reconnect) {
if (role === 'audience') {
return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`;
}
const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`;
if (reconnect && secret && shardId) {
return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`;
}
return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`;
}
function connect(isReconnect) {
const url = buildWsUrl(isReconnect);
console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`);
const ws = new WebSocket(url, ['ecast-v0'], {
headers: {
Origin: 'https://jackbox.tv',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
handshakeTimeout: 10000,
});
ws.on('open', () => {
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`);
console.log();
});
ws.on('message', (raw) => {
msgCount++;
try {
const msg = JSON.parse(raw.toString());
// Capture credentials from welcome for reconnection
if (msg.opcode === 'client/welcome' && msg.result) {
shardId = msg.result.id;
secret = msg.result.secret;
}
// Console output
const color = opcodeColor(msg.opcode);
const summary = summarize(msg);
console.log(
`${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}`
);
if (verbose) {
console.log(JSON.stringify(msg, null, 2));
}
// File output
if (fileLogger) {
fileLogger.write({
ts: new Date().toISOString(),
direction: 'recv',
msgNum: msgCount,
pc: msg.pc,
opcode: msg.opcode,
raw: msg,
});
}
} catch (e) {
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`);
if (fileLogger) {
fileLogger.write({
ts: new Date().toISOString(),
direction: 'recv',
msgNum: msgCount,
parseError: true,
raw: raw.toString().slice(0, 2000),
});
}
}
});
ws.on('close', (code, reason) => {
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`);
if (fileLogger) {
fileLogger.write({
ts: new Date().toISOString(),
direction: 'meta',
type: 'disconnected',
code,
reason: reason?.toString(),
});
}
if (!manuallyStopped && secret != null) {
reconnectWithBackoff(ws);
}
});
ws.on('error', (err) => {
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`);
if (fileLogger) {
fileLogger.write({
ts: new Date().toISOString(),
direction: 'meta',
type: 'error',
message: err.message,
});
}
});
// SIGINT handler
const onSigint = () => {
manuallyStopped = true;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log();
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
if (fileLogger) {
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
fileLogger.write({
ts: new Date().toISOString(),
direction: 'meta',
type: 'session_end',
msgCount,
durationMs: Date.now() - startTime,
});
fileLogger.close();
}
console.log();
try {
ws.close(1000, 'Logger stopped');
} catch (_) {}
setTimeout(() => process.exit(0), 500);
};
process.removeAllListeners('SIGINT');
process.on('SIGINT', onSigint);
return ws;
}
async function reconnectWithBackoff() {
if (reconnecting || manuallyStopped) return;
reconnecting = true;
const delays = [2000, 4000, 8000];
for (let i = 0; i < delays.length; i++) {
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`);
await new Promise((r) => setTimeout(r, delays[i]));
if (manuallyStopped) {
reconnecting = false;
return;
}
try {
const freshRoom = await getRoomInfo(roomCode);
if (!freshRoom) {
console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`);
reconnecting = false;
shutdown();
return;
}
roomInfo.host = freshRoom.host;
connect(true);
reconnecting = false;
return;
} catch (e) {
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`);
}
}
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`);
reconnecting = false;
shutdown();
}
function shutdown() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log();
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
if (fileLogger) {
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
fileLogger.write({
ts: new Date().toISOString(),
direction: 'meta',
type: 'session_end',
msgCount,
durationMs: Date.now() - startTime,
});
fileLogger.close();
}
console.log();
process.exit(1);
}
connect(false);
}
main().catch((e) => {
console.error(`${C.red}Fatal: ${e.message}${C.reset}`);
process.exit(1);
});