sessionboard 0.0.0-stage → 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.
@@ -0,0 +1,1126 @@
1
+ #!/usr/bin/env node
2
+ // sessionboard: an LLM-judged leaderboard of your Claude Code sessions, with redacted sharing and a local dashboard.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import readline from 'node:readline';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const HOME = os.homedir();
12
+ const SELF = fileURLToPath(import.meta.url);
13
+ const ROOT = path.dirname(path.dirname(SELF));
14
+ const CLAUDE_DIR = path.join(HOME, '.claude');
15
+ const CLAUDE_SETTINGS = process.env.SESSIONBOARD_CLAUDE_SETTINGS || path.join(CLAUDE_DIR, 'settings.json');
16
+ const PROJECTS_DIR = process.env.CLAUDE_PROJECTS_DIR || path.join(CLAUDE_DIR, 'projects');
17
+ const DATA_DIR = process.env.SESSIONBOARD_HOME || path.join(HOME, '.sessionboard');
18
+ const INDEX_FILE = path.join(DATA_DIR, 'index.json');
19
+ const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
20
+ const TOP_FILE = path.join(DATA_DIR, 'TOP3.md');
21
+ const EXPORT_DIR = path.join(DATA_DIR, 'exports');
22
+ // The rating hook always runs this copy, so its path survives plugin updates and npm/repo moves.
23
+ const APP_DIR = path.join(DATA_DIR, 'app');
24
+ const APP_FILES = ['bin/sessionboard.mjs', 'lib/dashboard.html', 'package.json'];
25
+ const LOCK_FILE = path.join(DATA_DIR, 'index.lock');
26
+ const CHILD_ENV = 'SESSIONBOARD_CHILD';
27
+ const PARSER_VERSION = 1;
28
+ const KEEP_FOREVER_DAYS = 3650;
29
+
30
+ const DEFAULT_CONFIG = {
31
+ initialized: false,
32
+ topN: 3,
33
+ judge: true,
34
+ judgeModel: 'haiku',
35
+ rejudgeGrowth: 1.25, // re-judge a resumed session once its transcript grows this much
36
+ redactEmails: true,
37
+ redactTerms: [], // your own words to hide in exports: company, product, client, internal hosts
38
+ minPrompts: 2,
39
+ minToolCalls: 5,
40
+ port: 4317,
41
+ };
42
+
43
+ // ---------- utils ----------
44
+
45
+ const clamp = (x, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, x));
46
+ const logScale = (x, cap) => clamp(Math.log1p(x) / Math.log1p(cap));
47
+ const readJSON = (f, fallback) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; } };
48
+ const writeJSON = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(v, null, 2)); fs.renameSync(tmp, f); };
49
+ const loadConfig = () => ({ ...DEFAULT_CONFIG, ...readJSON(CONFIG_FILE, {}) });
50
+ const saveConfig = (patch) => writeJSON(CONFIG_FILE, { ...readJSON(CONFIG_FILE, {}), ...patch });
51
+ const shortId = (id) => id.slice(0, 8);
52
+ const fmtDate = (iso) => iso ? new Date(iso).toISOString().slice(0, 10) : '?';
53
+ const fmtMins = (m) => { m = Math.round(m); return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`; };
54
+ const tildify = (p) => p ? p.split(HOME).join('~') : p;
55
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
56
+
57
+ // ---------- terminal UI ----------
58
+
59
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
60
+ const paint = (code) => (s) => COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s);
61
+ const bold = paint('1'), dim = paint('2'), accent = paint('38;5;173'), green = paint('32'), yellow = paint('33'), red = paint('31');
62
+ const visLen = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
63
+ const padEnd = (s, n) => s + ' '.repeat(Math.max(0, n - visLen(s)));
64
+
65
+ function box(lines, { title = '', width = 66 } = {}) {
66
+ const inner = Math.max(width, ...lines.map((l) => visLen(l) + 2), visLen(title) + 6);
67
+ const top = title ? `${dim('╭─ ')}${bold(title)}${dim(` ${'─'.repeat(inner - visLen(title) - 3)}╮`)}` : dim(`╭${'─'.repeat(inner)}╮`);
68
+ return [top, ...lines.map((l) => `${dim('│')} ${padEnd(l, inner - 2)} ${dim('│')}`), dim(`╰${'─'.repeat(inner)}╯`)].map((l) => ` ${l}`).join('\n');
69
+ }
70
+
71
+ const scoreBar = (score, width = 10) => {
72
+ const filled = Math.round(clamp(score / 10) * width);
73
+ return accent('█'.repeat(filled)) + dim('░'.repeat(width - filled));
74
+ };
75
+ const medal = (rank) => [null, yellow, (s) => bold(s), accent][rank]?.(`#${rank}`) ?? dim(`#${rank}`);
76
+
77
+ // Arrow-key picker. Falls back to `fallback` (or the default) when there is no terminal to ask.
78
+ function select(question, options, { def = 0, fallback } = {}) {
79
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(options[fallback ?? def].value);
80
+ return new Promise((resolve) => {
81
+ let i = def;
82
+ const lines = () => options.map((o, k) => k === i
83
+ ? ` ${accent('❯')} ${bold(o.label)}${o.hint ? ` ${dim(o.hint)}` : ''}`
84
+ : ` ${o.label}${o.hint ? ` ${dim(o.hint)}` : ''}`);
85
+ const draw = (first) => {
86
+ if (!first) process.stdout.write(`\x1b[${options.length}A\x1b[0J`);
87
+ process.stdout.write(`${lines().join('\n')}\n`);
88
+ };
89
+ process.stdout.write(`\n ${accent('?')} ${bold(question)} ${dim('↑↓ to move · enter to pick')}\n`);
90
+ draw(true);
91
+ readline.emitKeypressEvents(process.stdin);
92
+ process.stdin.setRawMode(true);
93
+ process.stdin.resume();
94
+ const onKey = (_, key = {}) => {
95
+ if (key.ctrl && key.name === 'c') { process.stdin.setRawMode(false); process.stdout.write('\n'); process.exit(130); }
96
+ if (key.name === 'up' || key.name === 'k') { i = (i - 1 + options.length) % options.length; draw(); }
97
+ else if (key.name === 'down' || key.name === 'j') { i = (i + 1) % options.length; draw(); }
98
+ else if (key.name === 'return' || key.name === 'enter') {
99
+ process.stdin.off('keypress', onKey);
100
+ process.stdin.setRawMode(false);
101
+ process.stdin.pause();
102
+ process.stdout.write(`\x1b[${options.length + 1}A\x1b[0J ${green('✔')} ${question} ${accent(options[i].label)}\n`);
103
+ resolve(options[i].value);
104
+ }
105
+ };
106
+ process.stdin.on('keypress', onKey);
107
+ });
108
+ }
109
+
110
+ // Free-text prompt. Returns `fallback` when there is no terminal to ask.
111
+ function input(question, { initial = '', hint = '', fallback = initial } = {}) {
112
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(fallback);
113
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
114
+ if (hint) process.stdout.write(`\n ${dim(hint)}`);
115
+ return new Promise((resolve) => {
116
+ rl.question(`\n ${accent('?')} ${bold(question)}\n ${accent('›')} `, (answer) => {
117
+ rl.close();
118
+ process.stdout.write(`\x1b[2A\x1b[0J ${green('✔')} ${question} ${accent(answer.trim() || 'none')}\n`);
119
+ resolve(answer.trim());
120
+ });
121
+ if (initial) rl.write(initial);
122
+ });
123
+ }
124
+
125
+ function spinner(text) {
126
+ if (!process.stdout.isTTY) { process.stdout.write(` ${text}\n`); return { stop: (final) => process.stdout.write(` ${final}\n`) }; }
127
+ const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
128
+ let f = 0;
129
+ const t = setInterval(() => process.stdout.write(`\r\x1b[2K ${accent(frames[f++ % frames.length])} ${text}`), 80);
130
+ return { stop: (final) => { clearInterval(t); process.stdout.write(`\r\x1b[2K ${final}\n`); } };
131
+ }
132
+
133
+ function listSessionFiles() {
134
+ if (!fs.existsSync(PROJECTS_DIR)) return [];
135
+ const out = [];
136
+ for (const proj of fs.readdirSync(PROJECTS_DIR)) {
137
+ let entries;
138
+ try { entries = fs.readdirSync(path.join(PROJECTS_DIR, proj), { withFileTypes: true }); } catch { continue; }
139
+ for (const e of entries) if (e.isFile() && e.name.endsWith('.jsonl')) out.push(path.join(PROJECTS_DIR, proj, e.name));
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function* readLines(file) {
145
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
146
+ if (!line.trim()) continue;
147
+ try { yield JSON.parse(line); } catch { /* partial line from a live session */ }
148
+ }
149
+ }
150
+
151
+ // Text of a real, human-typed prompt, or null for meta/tool-result/command noise.
152
+ function promptText(entry) {
153
+ if (entry.type !== 'user' || entry.isMeta || entry.isSidechain) return null;
154
+ const c = entry.message?.content;
155
+ if (Array.isArray(c) && c.some((b) => b.type === 'tool_result')) return null;
156
+ const text = (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b.type === 'text').map((b) => b.text).join('\n') : '').trim();
157
+ if (!text || /^<(local-command|command-name|command-message|system-reminder|task-notification)/.test(text) || text.startsWith('Caveat:')) return null;
158
+ return text;
159
+ }
160
+
161
+ // ---------- parsing & heuristic score (free, no LLM; used for gating and tie-breaks) ----------
162
+
163
+ const POSITIVE = /\b(thanks|thank you|thx|perfect|awesome|amazing|great job|great work|love it|love this|works now|it works|working now|nice|excellent|brilliant|exactly what|beautiful|wow|lgtm|ship it)\b/i;
164
+ const NEGATIVE = /\b(wrong|not working|doesn'?t work|didn'?t work|still (broken|failing|not)|broken|wtf|ugh|undo|revert|that'?s not|you broke|why did you|useless|terrible)\b/i;
165
+ const TEST_CMD = /\b((npm|pnpm|yarn|bun)( run)? (test|build|lint|typecheck|check)|npx (tsc|jest|vitest|playwright|eslint)|pytest|jest|vitest|playwright test|go (test|build|vet)|cargo (test|build|check|clippy)|tsc( |$)|eslint|ruff|mypy|make (test|build|check)|gradlew? (test|build)|mvn (test|verify|package)|rspec|phpunit|dotnet (test|build))\b/i;
166
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
167
+
168
+ function parseSession(file) {
169
+ const s = {
170
+ id: path.basename(file, '.jsonl'), file, title: null, cwd: null, branch: null, model: null,
171
+ start: null, end: null, activeMinutes: 0,
172
+ prompts: 0, interrupts: 0, positive: 0, negative: 0, praise: null, firstPrompt: null,
173
+ toolCalls: 0, toolErrors: 0, toolResults: 0, tools: {}, filesChanged: [],
174
+ testRuns: 0, commits: 0, prs: 0, outputTokens: 0,
175
+ };
176
+ const files = new Set();
177
+ const seenMsg = new Set();
178
+ let customTitle = false;
179
+ let lastTs = null;
180
+
181
+ for (const e of readLines(file)) {
182
+ if (e.type === 'custom-title' && e.customTitle) { s.title = e.customTitle; customTitle = true; }
183
+ else if (e.type === 'ai-title' && e.aiTitle && !customTitle) s.title = e.aiTitle;
184
+ else if (e.type === 'summary' && e.summary && !s.title) s.title = e.summary;
185
+ if (e.isSidechain) continue;
186
+ if (e.cwd && !s.cwd) s.cwd = e.cwd;
187
+ if (e.gitBranch && e.gitBranch !== 'HEAD') s.branch = e.gitBranch;
188
+
189
+ if (e.timestamp) {
190
+ const t = Date.parse(e.timestamp);
191
+ if (!Number.isNaN(t)) {
192
+ if (!s.start) s.start = e.timestamp;
193
+ s.end = e.timestamp;
194
+ if (lastTs && t > lastTs && t - lastTs < 10 * 60e3) s.activeMinutes += (t - lastTs) / 60e3;
195
+ lastTs = t;
196
+ }
197
+ }
198
+
199
+ if (e.type === 'user') {
200
+ const c = e.message?.content;
201
+ if (Array.isArray(c)) for (const b of c) {
202
+ if (b.type === 'tool_result') { s.toolResults++; if (b.is_error) s.toolErrors++; }
203
+ if (b.type === 'text' && /^\[Request interrupted by user/.test(b.text || '')) s.interrupts++;
204
+ }
205
+ const text = promptText(e);
206
+ if (text && !/^\[Request interrupted by user/.test(text)) {
207
+ s.prompts++;
208
+ if (!s.firstPrompt) s.firstPrompt = text.slice(0, 200);
209
+ if (POSITIVE.test(text)) { s.positive++; if (text.length < 80) s.praise = text; }
210
+ if (NEGATIVE.test(text)) s.negative++;
211
+ }
212
+ }
213
+
214
+ if (e.type === 'assistant' && e.message) {
215
+ const m = e.message;
216
+ if (m.model && !m.model.startsWith('<')) s.model = m.model;
217
+ if (m.id && !seenMsg.has(m.id)) { seenMsg.add(m.id); s.outputTokens += m.usage?.output_tokens || 0; }
218
+ for (const b of m.content || []) {
219
+ if (b.type !== 'tool_use') continue;
220
+ s.toolCalls++;
221
+ const name = b.name?.startsWith('mcp__') ? b.name.split('__').slice(0, 2).join('__') : b.name;
222
+ s.tools[name] = (s.tools[name] || 0) + 1;
223
+ const inp = b.input || {};
224
+ if (EDIT_TOOLS.has(b.name) && (inp.file_path || inp.notebook_path)) files.add(inp.file_path || inp.notebook_path);
225
+ if (b.name === 'Bash' && typeof inp.command === 'string') {
226
+ if (TEST_CMD.test(inp.command)) s.testRuns++;
227
+ if (/\bgit commit\b/.test(inp.command)) s.commits++;
228
+ if (/\bgh pr create\b/.test(inp.command)) s.prs++;
229
+ }
230
+ }
231
+ }
232
+ }
233
+ s.filesChanged = [...files].map(tildify);
234
+ return s;
235
+ }
236
+
237
+ function heuristicScore(s) {
238
+ const parts = {
239
+ depth: [18, logScale(s.toolCalls, 150)],
240
+ output: [18, logScale(s.filesChanged.length, 25)],
241
+ verification: [14, clamp((s.testRuns > 0 ? 0.55 : 0) + (s.commits > 0 ? 0.3 : 0) + (s.prs > 0 ? 0.15 : 0))],
242
+ sentiment: [14, clamp(0.5 + 0.18 * s.positive - 0.15 * s.negative - 0.08 * s.interrupts)],
243
+ engagement: [10, logScale(s.prompts, 30)],
244
+ autonomy: [10, clamp(s.toolCalls / Math.max(1, s.prompts) / 15)],
245
+ breadth: [8, clamp(Object.keys(s.tools).length / 8)],
246
+ reliability: [8, s.toolResults ? 1 - s.toolErrors / s.toolResults : 0.5],
247
+ };
248
+ return Math.round(Object.values(parts).reduce((t, [w, v]) => t + w * v, 0) * 10) / 10;
249
+ }
250
+
251
+ function highlights(s) {
252
+ const h = [`${s.toolCalls} tool calls`];
253
+ if (s.filesChanged.length) h.push(`${s.filesChanged.length} files changed`);
254
+ if (s.testRuns) h.push(`${s.testRuns} test/build runs`);
255
+ if (s.commits) h.push(`${s.commits} commit${s.commits > 1 ? 's' : ''}`);
256
+ if (s.prs) h.push(`${s.prs} PR${s.prs > 1 ? 's' : ''}`);
257
+ h.push(`${s.prompts} prompts`, `${fmtMins(s.activeMinutes)} active`);
258
+ return h.join(' · ');
259
+ }
260
+
261
+ // ---------- LLM judge: one absolute 0-10 score per session ----------
262
+
263
+ const JUDGE_RUBRIC = `You are scoring one Claude Code (AI coding agent) session for a "best agent sessions" portfolio - the kind a developer shares with a hiring company to show how well they work with AI agents.
264
+
265
+ Score it 0-10 against this fixed scale, using one decimal place to separate sessions within a band (use the whole range; most sessions are 4-7):
266
+ 9-10 Hard, real-world task (substantial feature, tricky bug, system design), clearly directed by the human, verified with tests/builds/checks, clearly shipped.
267
+ 7-8 Solid feature or fix, mostly clean execution, verified, goal achieved.
268
+ 4-6 Useful but simple, OR a real task with lots of thrashing/back-and-forth, OR outcome unclear.
269
+ 0-3 Trivial, aimless, chit-chat, mostly setup noise, or the goal failed.
270
+
271
+ Weigh: task difficulty and real-world value; quality of the human's direction (clear goals, decomposition, catching mistakes); the agent's execution (correct, verified, little thrashing); outcome.
272
+
273
+ Reply with ONLY a JSON object, no prose, no code fences:
274
+ {"score": <number 0-10, one decimal>, "summary": "<one sentence: what was accomplished>", "reason": "<one sentence, max 25 words: why this score>"}
275
+
276
+ TRANSCRIPT DIGEST:
277
+ `;
278
+
279
+ function digest(file, maxChars = 14000) {
280
+ const lines = [];
281
+ for (const e of readLines(file)) {
282
+ if (e.isSidechain) continue;
283
+ const text = promptText(e);
284
+ if (text) { lines.push(`USER: ${text.slice(0, 1200)}`); continue; }
285
+ if (e.type === 'assistant') for (const b of e.message?.content || []) {
286
+ if (b.type === 'text' && b.text?.trim()) lines.push(`AGENT: ${b.text.trim().slice(0, 600)}`);
287
+ else if (b.type === 'tool_use') lines.push(` [tool ${b.name}] ${toolOneLiner(b).slice(0, 140)}`);
288
+ }
289
+ if (e.type === 'user' && Array.isArray(e.message?.content))
290
+ for (const b of e.message.content) if (b.type === 'tool_result' && b.is_error) lines.push(' [tool error]');
291
+ }
292
+ let out = lines.join('\n');
293
+ if (out.length > maxChars) out = `${out.slice(0, maxChars * 0.45)}\n\n[... middle omitted ...]\n\n${out.slice(-maxChars * 0.55)}`;
294
+ return redact(out, { emails: true });
295
+ }
296
+
297
+ function judgeSession(file, model) {
298
+ const args = [
299
+ '-p', '--model', model, '--no-session-persistence', '--tools', '', '--output-format', 'text',
300
+ // The judge is itself a Claude Code process; keep it from running the transcript cleanup on startup.
301
+ '--settings', JSON.stringify({ cleanupPeriodDays: KEEP_FOREVER_DAYS }),
302
+ ];
303
+ fs.mkdirSync(DATA_DIR, { recursive: true });
304
+ return new Promise((resolve, reject) => {
305
+ const child = spawn('claude', args, { cwd: DATA_DIR, env: { ...process.env, [CHILD_ENV]: '1' } });
306
+ let out = '', err = '';
307
+ const timer = setTimeout(() => { child.kill(); reject(new Error('judge timed out')); }, 180e3);
308
+ child.stdout.on('data', (d) => { out += d; });
309
+ child.stderr.on('data', (d) => { err += d; });
310
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
311
+ child.on('close', (code) => {
312
+ clearTimeout(timer);
313
+ if (code !== 0) return reject(new Error((err || `exit ${code}`).trim().slice(0, 200)));
314
+ try {
315
+ const j = JSON.parse(out.match(/\{[\s\S]*\}/)?.[0] ?? 'null');
316
+ if (!j) throw new Error('judge returned no JSON');
317
+ resolve({ score: Math.round(clamp(Number(j.score), 0, 10) * 10) / 10, summary: String(j.summary || ''), reason: String(j.reason || '') });
318
+ } catch (e) { reject(e); }
319
+ });
320
+ child.stdin.end(JUDGE_RUBRIC + digest(file));
321
+ });
322
+ }
323
+
324
+ // ---------- index (shared by CLI, hook jobs and the dashboard) ----------
325
+
326
+ function withIndex(fn) {
327
+ fs.mkdirSync(DATA_DIR, { recursive: true });
328
+ const deadline = Date.now() + 60e3;
329
+ for (;;) {
330
+ try { fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' }); break; } catch {
331
+ try { if (Date.now() - fs.statSync(LOCK_FILE).mtimeMs > 120e3) fs.unlinkSync(LOCK_FILE); } catch {}
332
+ if (Date.now() > deadline) throw new Error(`index is locked (${LOCK_FILE})`);
333
+ sleep(100);
334
+ }
335
+ }
336
+ try {
337
+ const index = readJSON(INDEX_FILE, null) || { sessions: {}, pinned: [], hidden: [] };
338
+ if (index.parserVersion !== PARSER_VERSION) {
339
+ for (const s of Object.values(index.sessions)) s.mtimeMs = -1; // force re-parse, keep judge verdicts
340
+ index.parserVersion = PARSER_VERSION;
341
+ }
342
+ const result = fn(index);
343
+ index.updatedAt = new Date().toISOString();
344
+ writeJSON(INDEX_FILE, index);
345
+ writeTopFile(index);
346
+ return result;
347
+ } finally {
348
+ try { fs.unlinkSync(LOCK_FILE); } catch {}
349
+ }
350
+ }
351
+ const readIndex = () => readJSON(INDEX_FILE, null) || { sessions: {}, pinned: [], hidden: [] };
352
+
353
+ // Re-parse changed transcripts. Sessions whose transcript disappeared stay on the board, marked missing.
354
+ function scan() {
355
+ const cfg = loadConfig();
356
+ return withIndex((index) => {
357
+ const live = new Set();
358
+ for (const file of listSessionFiles()) {
359
+ let st;
360
+ try { st = fs.statSync(file); } catch { continue; }
361
+ const id = path.basename(file, '.jsonl');
362
+ live.add(id);
363
+ const prev = index.sessions[id];
364
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size && !prev.missing) continue;
365
+ const s = parseSession(file);
366
+ Object.assign(s, { mtimeMs: st.mtimeMs, size: st.size, heuristic: heuristicScore(s), judge: prev?.judge, missing: false });
367
+ s.eligible = s.prompts >= cfg.minPrompts && s.toolCalls >= cfg.minToolCalls;
368
+ index.sessions[id] = s;
369
+ }
370
+ for (const s of Object.values(index.sessions)) {
371
+ if (!live.has(s.id)) s.missing = true;
372
+ s.eligible = s.prompts >= cfg.minPrompts && s.toolCalls >= cfg.minToolCalls; // thresholds may have changed
373
+ }
374
+ return index;
375
+ });
376
+ }
377
+
378
+ // rejudgeGrowth 0 means "never re-rate a session once it has a score".
379
+ const needsJudge = (s, cfg) => s.eligible && !s.missing && (!s.judge || (cfg.rejudgeGrowth > 0 && s.size > s.judge.atSize * cfg.rejudgeGrowth));
380
+
381
+ // Judge outside the lock (LLM calls are slow), then store the verdict under the lock.
382
+ async function judgeAndStore(id, { force = false, log = () => {} } = {}) {
383
+ const cfg = loadConfig();
384
+ const s = readIndex().sessions[id];
385
+ if (!s || s.missing || (!force && !needsJudge(s, cfg))) return null;
386
+ log(`judging ${shortId(id)} "${s.title || s.firstPrompt?.slice(0, 40) || ''}"... `);
387
+ const verdict = { ...await judgeSession(s.file, cfg.judgeModel), atSize: s.size, model: cfg.judgeModel, at: new Date().toISOString() };
388
+ withIndex((index) => { if (index.sessions[id]) index.sessions[id].judge = verdict; });
389
+ log(`${verdict.score}/10\n`);
390
+ return verdict;
391
+ }
392
+
393
+ async function judgePending({ log } = {}) {
394
+ const cfg = loadConfig();
395
+ const pending = Object.values(readIndex().sessions).filter((s) => needsJudge(s, cfg));
396
+ for (const s of pending) {
397
+ try { await judgeAndStore(s.id, { log }); } catch (err) { log?.(`failed (${err.message})\n`); }
398
+ }
399
+ return pending.length;
400
+ }
401
+
402
+ function ranked(index) {
403
+ const pinned = index.pinned.map((id) => index.sessions[id]).filter(Boolean);
404
+ const rest = Object.values(index.sessions)
405
+ .filter((s) => s.eligible && !index.hidden.includes(s.id) && !index.pinned.includes(s.id))
406
+ .sort((a, b) => ((b.judge?.score ?? -1) - (a.judge?.score ?? -1)) || (b.heuristic - a.heuristic));
407
+ return [...pinned, ...rest];
408
+ }
409
+
410
+ function describe(s, i, index) {
411
+ const tags = [index.pinned.includes(s.id) && '📌 pinned', s.missing && 'transcript deleted'].filter(Boolean).join(', ');
412
+ const lines = [
413
+ `${i + 1}. ${s.title || s.firstPrompt?.slice(0, 60) || '(untitled)'}${tags ? ` [${tags}]` : ''}`,
414
+ ` ${s.judge ? `${s.judge.score}/10` : 'not judged yet'} · ${fmtDate(s.start)} · ${tildify(s.cwd) || '?'} · id ${shortId(s.id)}`,
415
+ ];
416
+ if (s.judge?.summary) lines.push(` ${s.judge.summary}`);
417
+ if (s.judge?.reason) lines.push(` why: ${s.judge.reason}`);
418
+ lines.push(` ${highlights(s)}`);
419
+ return lines.join('\n');
420
+ }
421
+
422
+ function writeTopFile(index) {
423
+ const cfg = loadConfig();
424
+ const body = ranked(index).slice(0, cfg.topN).map((s, i) => describe(s, i, index)).join('\n\n');
425
+ fs.writeFileSync(TOP_FILE, `# My top ${cfg.topN} agent sessions\n\n_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC · share one with \`sessionboard share <rank>\`_\n\n\`\`\`\n${body || 'No eligible sessions yet.'}\n\`\`\`\n`);
426
+ }
427
+
428
+ // ---------- redaction & export ----------
429
+
430
+ const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
431
+
432
+ function redact(text, { emails, terms = loadConfig().redactTerms } = {}) {
433
+ let t = text.split(HOME).join('~');
434
+ t = t.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
435
+ t = t.replace(/\b(sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|AKIA[0-9A-Z]{16}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|[sr]k_(?:live|test)_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, '[REDACTED]');
436
+ t = t.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, '$1[REDACTED]');
437
+ t = t.replace(/\b([A-Za-z0-9_]*(?:api[_-]?key|secret|token|password|passwd|pwd)[A-Za-z0-9_]*["']?\s*[:=]\s*["']?)([^\s"'`,;]{8,})/gi, '$1[REDACTED]');
438
+ // plain language: "password is hunter2", "pass: hunter2", "the pin was 4821"
439
+ t = t.replace(/\b((?:password|passwd|passcode|pass|pwd|pin)\s*(?:is|was|=|:)\s*["']?)([^\s"'`,;]{4,})/gi, '$1[REDACTED]');
440
+ t = t.replace(/(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/]+:[^@\s]+@/gi, '$1://[REDACTED]@');
441
+ // unknown token formats: long hex, or long mixed-case alphanumeric strings that look random
442
+ t = t.replace(/\b[A-Fa-f0-9]{32,}\b/g, '[REDACTED]');
443
+ t = t.replace(/(?<![\w/.-])[A-Za-z0-9_+-]{32,}={0,2}(?![\w/.-])/g, (m) => /\d/.test(m) && /[a-z]/.test(m) && /[A-Z]/.test(m) ? '[REDACTED]' : m);
444
+ if (emails) t = t.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]');
445
+ for (const term of [...(terms || [])].sort((a, b) => b.length - a.length)) if (term.trim()) t = t.replace(new RegExp(escapeRe(term.trim()), 'gi'), '[redacted]');
446
+ return t;
447
+ }
448
+
449
+ function toolOneLiner(b) {
450
+ const i = b.input || {};
451
+ const v = i.command ?? i.file_path ?? i.notebook_path ?? i.pattern ?? i.url ?? i.query ?? i.description ?? i.prompt ?? i.skill ?? '';
452
+ return String(v).replace(/\s+/g, ' ').trim();
453
+ }
454
+
455
+ function buildTranscript(s, { thinking = false, results = false } = {}) {
456
+ const turns = [];
457
+ let cur = null;
458
+ const push = (role) => { if (!cur || cur.role !== role) { cur = { role, parts: [] }; turns.push(cur); } return cur; };
459
+ for (const e of readLines(s.file)) {
460
+ if (e.isSidechain) continue;
461
+ const text = promptText(e);
462
+ if (text) { if (!/^\[Request interrupted/.test(text)) push('user').parts.push({ kind: 'text', text }); continue; }
463
+ if (e.type === 'assistant') for (const b of e.message?.content || []) {
464
+ if (b.type === 'text' && b.text?.trim()) push('agent').parts.push({ kind: 'text', text: b.text.trim() });
465
+ else if (b.type === 'thinking' && thinking && b.thinking?.trim()) push('agent').parts.push({ kind: 'thinking', text: b.thinking.trim() });
466
+ else if (b.type === 'tool_use') push('agent').parts.push({ kind: 'tool', name: b.name, text: toolOneLiner(b) });
467
+ }
468
+ if (e.type === 'user' && Array.isArray(e.message?.content)) for (const b of e.message.content) {
469
+ if (b.type !== 'tool_result' || cur?.role !== 'agent') continue;
470
+ const last = cur.parts.findLast((p) => p.kind === 'tool');
471
+ if (!last) continue;
472
+ if (b.is_error) last.error = true;
473
+ if (results) {
474
+ const c = typeof b.content === 'string' ? b.content : (b.content || []).filter((x) => x.type === 'text').map((x) => x.text).join('\n');
475
+ last.result = c.split('\n').slice(0, 12).join('\n').slice(0, 1200);
476
+ }
477
+ }
478
+ }
479
+ return turns;
480
+ }
481
+
482
+ function exportMarkdown(s, turns) {
483
+ const out = [
484
+ `# ${s.title || 'Claude Code session'}`, '',
485
+ `> Claude Code session · ${fmtDate(s.start)} · ${fmtMins(s.activeMinutes)} active${s.model ? ` · ${s.model}` : ''}${s.branch ? ` · branch \`${s.branch}\`` : ''}`, '',
486
+ `**${highlights(s)}**`, '',
487
+ ];
488
+ if (s.judge?.summary) out.push(`**What got done:** ${s.judge.summary}`, '');
489
+ out.push('---', '');
490
+ for (const t of turns) {
491
+ out.push(t.role === 'user' ? '### 👤 Me' : '### 🤖 Claude', '');
492
+ for (const p of t.parts) {
493
+ if (p.kind === 'text') out.push(p.text, '');
494
+ else if (p.kind === 'thinking') out.push(`<details><summary>thinking</summary>\n\n${p.text}\n\n</details>`, '');
495
+ else {
496
+ out.push(`- 🔧 **${p.name}**${p.error ? ' ⚠️' : ''} \`${p.text.slice(0, 160).replace(/`/g, "'")}\``);
497
+ if (p.result) out.push('', ' ```', ...p.result.split('\n').map((l) => ` ${l}`), ' ```');
498
+ }
499
+ }
500
+ out.push('');
501
+ }
502
+ out.push('---', '_Shared with [sessionboard](https://github.com/kbiam/sessionboard)_');
503
+ return out.join('\n');
504
+ }
505
+
506
+ const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
507
+ const mdLite = (t) => esc(t)
508
+ .replace(/```[\w-]*\n([\s\S]*?)```/g, (_, c) => `<pre><code>${c}</code></pre>`)
509
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
510
+ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
511
+ .replace(/\n/g, '<br>');
512
+
513
+ function exportHTML(s, turns) {
514
+ const body = turns.map((t) => {
515
+ const parts = t.parts.map((p) => {
516
+ if (p.kind === 'text') return `<div class="text">${mdLite(p.text)}</div>`;
517
+ if (p.kind === 'thinking') return `<details class="think"><summary>thinking</summary><div>${mdLite(p.text)}</div></details>`;
518
+ return `<div class="tool${p.error ? ' err' : ''}"><span class="tn">${esc(p.name)}</span><code>${esc(p.text.slice(0, 200))}</code>${p.result ? `<pre>${esc(p.result)}</pre>` : ''}</div>`;
519
+ }).join('');
520
+ return `<section class="turn ${t.role}"><div class="who">${t.role === 'user' ? 'Me' : 'Claude'}</div>${parts}</section>`;
521
+ }).join('\n');
522
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
523
+ <title>${esc(s.title || 'Agent session')}</title>
524
+ <style>
525
+ :root{--bg:#faf9f7;--fg:#1f1e1c;--mute:#6b6862;--card:#fff;--line:#e6e3dd;--accent:#c96442;--user:#f1ede6;--err:#b3261e}
526
+ @media (prefers-color-scheme:dark){:root{--bg:#1a1917;--fg:#ecebe8;--mute:#9b978f;--card:#232220;--line:#35332f;--user:#2b2926;--err:#f2b8b5}}
527
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif}
528
+ main{max-width:820px;margin:0 auto;padding:40px 16px 80px}h1{font-size:28px;line-height:1.25;margin:0 0 8px}
529
+ .meta{color:var(--mute);font-size:14px}.stats{margin:16px 0;padding:12px 16px;border:1px solid var(--line);border-radius:10px;background:var(--card);font-size:14px}
530
+ .turn{margin:18px 0;padding:14px 16px;border-radius:12px;border:1px solid var(--line);background:var(--card)}.turn.user{background:var(--user)}
531
+ .who{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
532
+ .text{margin:6px 0;overflow-wrap:anywhere}.tool{font-size:13px;color:var(--mute);margin:4px 0;overflow-wrap:anywhere}.tool .tn{font-weight:600;margin-right:6px}.tool.err .tn{color:var(--err)}
533
+ code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace}pre{overflow-x:auto;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px;font-size:12.5px}
534
+ details.think{color:var(--mute);font-size:13px}footer{color:var(--mute);font-size:13px;margin-top:40px;text-align:center}
535
+ </style></head><body><main>
536
+ <h1>${esc(s.title || 'Claude Code session')}</h1>
537
+ <div class="meta">Claude Code · ${fmtDate(s.start)} · ${fmtMins(s.activeMinutes)} active${s.model ? ` · ${esc(s.model)}` : ''}</div>
538
+ <div class="stats">${esc(highlights(s))}${s.judge?.summary ? `<br><strong>What got done:</strong> ${esc(s.judge.summary)}` : ''}</div>
539
+ ${body}
540
+ <footer>Shared with sessionboard</footer></main></body></html>`;
541
+ }
542
+
543
+ function renderExport(s, { html = false, thinking = false, results = false, keepEmails = false } = {}) {
544
+ if (s.missing || !fs.existsSync(s.file)) throw new Error('This transcript was deleted from ~/.claude/projects (Claude Code cleanup), so it can no longer be exported.');
545
+ const turns = buildTranscript(s, { thinking, results });
546
+ const raw = html ? exportHTML(s, turns) : exportMarkdown(s, turns);
547
+ return redact(raw, { emails: keepEmails ? false : loadConfig().redactEmails });
548
+ }
549
+ const exportSlug = (s) => (s.title || shortId(s.id)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50) || shortId(s.id);
550
+
551
+ // ---------- Claude Code settings (only touched after the user says yes) ----------
552
+
553
+ function patchClaudeSettings(mutate) {
554
+ const raw = fs.existsSync(CLAUDE_SETTINGS) ? fs.readFileSync(CLAUDE_SETTINGS, 'utf8') : '{}';
555
+ const settings = JSON.parse(raw);
556
+ mutate(settings);
557
+ if (fs.existsSync(CLAUDE_SETTINGS)) fs.copyFileSync(CLAUDE_SETTINGS, `${CLAUDE_SETTINGS}.sessionboard-backup`);
558
+ writeJSON(CLAUDE_SETTINGS, settings);
559
+ }
560
+
561
+ const hasOurHook = (settings) => JSON.stringify(settings.hooks?.SessionEnd || []).includes('sessionboard');
562
+ const hookCommand = () => `node "${path.join(APP_DIR, 'bin', 'sessionboard.mjs')}" hook`;
563
+
564
+ const versionOf = (dir) => readJSON(path.join(dir, 'package.json'), {}).version || '0.0.0';
565
+ const newer = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0); return false; };
566
+
567
+ function installAppCopy() {
568
+ if (path.resolve(ROOT) === path.resolve(APP_DIR)) return;
569
+ for (const rel of APP_FILES) {
570
+ fs.mkdirSync(path.dirname(path.join(APP_DIR, rel)), { recursive: true });
571
+ fs.copyFileSync(path.join(ROOT, rel), path.join(APP_DIR, rel));
572
+ }
573
+ }
574
+
575
+ // Any newer sessionboard (plugin update, npm upgrade) that runs refreshes the copy the hook uses.
576
+ function refreshAppCopy() {
577
+ try { if (fs.existsSync(APP_DIR) && newer(versionOf(ROOT), versionOf(APP_DIR))) installAppCopy(); } catch {}
578
+ }
579
+
580
+ // Adds the rating hook to settings.json unless one is already there (from any install route).
581
+ function ensureHook(st) {
582
+ installAppCopy();
583
+ if (hasOurHook(st)) return false;
584
+ st.hooks ??= {};
585
+ st.hooks.SessionEnd ??= [];
586
+ st.hooks.SessionEnd.push({ hooks: [{ type: 'command', command: hookCommand(), timeout: 10 }] });
587
+ return true;
588
+ }
589
+
590
+ // ---------- dashboard ----------
591
+
592
+ function sessionView(s, index, rank) {
593
+ return {
594
+ id: s.id, rank, title: s.title || s.firstPrompt?.slice(0, 80) || '(untitled)', project: tildify(s.cwd), branch: s.branch, model: s.model,
595
+ date: fmtDate(s.start), start: s.start, active: fmtMins(s.activeMinutes), activeMinutes: s.activeMinutes,
596
+ judge: s.judge ? { score: s.judge.score, summary: s.judge.summary, reason: s.judge.reason } : null,
597
+ heuristic: s.heuristic, eligible: s.eligible, missing: !!s.missing,
598
+ pinned: index.pinned.includes(s.id), hidden: index.hidden.includes(s.id),
599
+ stats: { toolCalls: s.toolCalls, files: s.filesChanged.length, tests: s.testRuns, commits: s.commits, prs: s.prs, prompts: s.prompts },
600
+ };
601
+ }
602
+
603
+ function dashboard(flags) {
604
+ const cfg = loadConfig();
605
+ const page = fs.readFileSync(path.join(ROOT, 'lib', 'dashboard.html'), 'utf8');
606
+ let judging = null; // id currently being judged, so the UI can show progress
607
+
608
+ const json = (res, code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
609
+ const findSession = (id) => readIndex().sessions[id];
610
+
611
+ const server = http.createServer((req, res) => {
612
+ // Only answer to our own origin: blocks DNS-rebinding and cross-site requests from other tabs.
613
+ const host = (req.headers.host || '').replace(/:\d+$/, '');
614
+ if (!['127.0.0.1', 'localhost'].includes(host)) return json(res, 403, { error: 'forbidden host' });
615
+ if (req.method === 'POST' && req.headers['x-sessionboard'] !== '1') return json(res, 403, { error: 'missing header' });
616
+ const url = new URL(req.url, 'http://localhost');
617
+ const m = url.pathname.match(/^\/api\/sessions\/([\w-]+)(?:\/(\w+))?$/);
618
+ try {
619
+ if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page); }
620
+
621
+ if (req.method === 'GET' && url.pathname === '/api/sessions') {
622
+ const index = readIndex();
623
+ const order = ranked(index);
624
+ const rankOf = new Map(order.map((s, i) => [s.id, i + 1]));
625
+ const all = Object.values(index.sessions).map((s) => sessionView(s, index, rankOf.get(s.id) || null));
626
+ return json(res, 200, { topN: loadConfig().topN, updatedAt: index.updatedAt, judging, judgeEnabled: loadConfig().judge, sessions: all });
627
+ }
628
+
629
+ if (req.method === 'POST' && url.pathname === '/api/refresh') {
630
+ scan();
631
+ const cfg = loadConfig();
632
+ if (cfg.judge && cfg.initialized && !judging) {
633
+ // rate in a child so the server stays responsive; the UI polls `judging` to show progress
634
+ judging = 'all';
635
+ const child = spawn(process.execPath, [SELF, 'update', '--quiet'], { stdio: 'ignore', env: { ...process.env, [CHILD_ENV]: '1' } });
636
+ child.on('exit', () => { judging = null; });
637
+ }
638
+ return json(res, 200, { ok: true });
639
+ }
640
+
641
+ if (m && req.method === 'GET' && m[2] === 'transcript') {
642
+ const s = findSession(m[1]);
643
+ if (!s) return json(res, 404, { error: 'not found' });
644
+ if (s.missing) return json(res, 410, { error: 'Transcript was deleted by Claude Code cleanup.' });
645
+ const emails = loadConfig().redactEmails;
646
+ const turns = buildTranscript(s).map((t) => ({ role: t.role, parts: t.parts.map((p) => ({ ...p, text: redact(p.text, { emails }) })) }));
647
+ return json(res, 200, { turns });
648
+ }
649
+
650
+ if (m && req.method === 'GET' && m[2] === 'export') {
651
+ const s = findSession(m[1]);
652
+ if (!s) return json(res, 404, { error: 'not found' });
653
+ const html = url.searchParams.get('format') === 'html';
654
+ const body = renderExport(s, { html, thinking: url.searchParams.has('thinking'), results: url.searchParams.has('results') });
655
+ const name = `${exportSlug(s)}.${html ? 'html' : 'md'}`;
656
+ res.writeHead(200, {
657
+ 'content-type': html ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
658
+ ...(url.searchParams.has('download') ? { 'content-disposition': `attachment; filename="${name}"` } : {}),
659
+ });
660
+ return res.end(body);
661
+ }
662
+
663
+ if (m && req.method === 'POST' && ['pin', 'unpin', 'hide', 'unhide'].includes(m[2])) {
664
+ setFlag(m[1], m[2]);
665
+ return json(res, 200, { ok: true });
666
+ }
667
+
668
+ if (m && req.method === 'POST' && m[2] === 'rejudge') {
669
+ if (judging) return json(res, 409, { error: 'already judging a session' });
670
+ const s = findSession(m[1]);
671
+ if (!s || s.missing) return json(res, 404, { error: 'not found' });
672
+ judging = s.id;
673
+ const child = spawn(process.execPath, [SELF, 'rejudge', s.id, '--quiet'], { stdio: 'ignore', env: { ...process.env, [CHILD_ENV]: '1' } });
674
+ child.on('exit', () => { judging = null; });
675
+ return json(res, 202, { ok: true });
676
+ }
677
+
678
+ json(res, 404, { error: 'not found' });
679
+ } catch (err) {
680
+ json(res, 500, { error: err.message });
681
+ }
682
+ });
683
+
684
+ let port = Number(flags.port) || cfg.port;
685
+ server.on('error', (err) => {
686
+ if (err.code === 'EADDRINUSE' && port < cfg.port + 20) { port++; server.listen(port, '127.0.0.1'); }
687
+ else { console.error(err.message); process.exit(1); }
688
+ });
689
+ server.on('listening', () => {
690
+ const url = `http://localhost:${port}`;
691
+ console.log(`sessionboard dashboard → ${url} (Ctrl+C to stop)`);
692
+ if (!flags['no-open']) spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
693
+ });
694
+ server.listen(port, '127.0.0.1');
695
+ }
696
+
697
+ function setFlag(id, action) {
698
+ return withIndex((index) => {
699
+ const s = index.sessions[id] || Object.values(index.sessions).find((x) => x.id.startsWith(id));
700
+ if (!s) throw new Error('session not found');
701
+ const key = action.endsWith('pin') ? 'pinned' : 'hidden';
702
+ index[key] = index[key].filter((x) => x !== s.id);
703
+ if (!action.startsWith('un')) index[key].push(s.id);
704
+ return s;
705
+ });
706
+ }
707
+
708
+ // ---------- init & interactive config ----------
709
+
710
+ const MODELS = [
711
+ { label: 'Haiku', value: 'haiku', hint: 'fast, cheapest, good enough for ranking (recommended)' },
712
+ { label: 'Sonnet', value: 'sonnet', hint: 'sharper judgement, ~3x the cost of Haiku' },
713
+ { label: 'Opus', value: 'opus', hint: 'best judgement, slowest and most expensive' },
714
+ { label: 'Off', value: false, hint: 'no AI: rank with the free heuristic only' },
715
+ ];
716
+ const REJUDGE = [
717
+ { label: 'When it grows 25%', value: 1.25, hint: 'resumed sessions get re-rated (recommended)' },
718
+ { label: 'When it doubles', value: 2, hint: 'fewer re-rates' },
719
+ { label: 'Never', value: 0, hint: 'first score is final' },
720
+ ];
721
+ const BOARD = [3, 5, 10].map((n) => ({ label: `Top ${n}`, value: n }));
722
+ const GATES = [
723
+ { label: 'Standard', value: [2, 5], hint: '≥2 prompts and ≥5 tool calls (recommended)' },
724
+ { label: 'Strict', value: [5, 20], hint: '≥5 prompts and ≥20 tool calls: only big sessions' },
725
+ { label: 'Everything', value: [1, 1], hint: 'rate every session, even one-liners' },
726
+ ];
727
+ const modelName = (m) => MODELS.find((x) => x.value === m)?.label ?? m;
728
+ const rejudgeName = (g) => REJUDGE.find((x) => x.value === g)?.label ?? `when it grows ${Math.round((g - 1) * 100)}%`;
729
+
730
+ function howItWorks(cfg) {
731
+ const judge = cfg.judge ? `${modelName(cfg.judgeModel)} scores it 0-10 + a summary and the why` : 'AI judge is off: free heuristic score';
732
+ return box([
733
+ '',
734
+ `${accent('1')} You close a Claude Code session`,
735
+ `${dim('│')} ${dim('a hook runs in the background, never slows you down')}`,
736
+ `${dim('▼')}`,
737
+ `${accent('2')} Gate: was it real work? ${dim(`≥${cfg.minPrompts} prompts, ≥${cfg.minToolCalls} tool calls`)}`,
738
+ `${dim('│')} ${dim('trivial sessions stop here, no AI call')}`,
739
+ `${dim('▼')}`,
740
+ `${accent('3')} AI judge: ${judge}`,
741
+ `${dim('│')} ${dim('task difficulty · your direction · verified · shipped')}`,
742
+ `${dim('▼')}`,
743
+ `${accent('4')} Board: your top ${cfg.topN}, always current`,
744
+ `${dim('▼')}`,
745
+ `${accent('5')} Share: redacted HTML / Markdown, or the local dashboard`,
746
+ '',
747
+ ], { title: 'How it works' });
748
+ }
749
+
750
+ // Walks through every setting. Returns { cfg, retention, hook } without applying anything.
751
+ async function configure(flags, { firstRun }) {
752
+ const cur = loadConfig();
753
+ const settings = readJSON(CLAUDE_SETTINGS, {});
754
+ const ask = (q, opts, def, fallback) => select(q, opts, { def, fallback: flags.yes ? def : fallback });
755
+ const idx = (opts, v) => Math.max(0, opts.findIndex((o) => JSON.stringify(o.value) === JSON.stringify(v)));
756
+
757
+ const model = await ask('Which model should judge your sessions?', MODELS, cur.judge ? idx(MODELS, cur.judgeModel) : 3);
758
+ const rejudgeGrowth = model === false ? cur.rejudgeGrowth : await ask('When should a resumed session be re-rated?', REJUDGE, idx(REJUDGE, cur.rejudgeGrowth));
759
+ const topN = await ask('How big is your board?', BOARD, idx(BOARD, cur.topN));
760
+ const [minPrompts, minToolCalls] = await ask('Which sessions count?', GATES, idx(GATES, [cur.minPrompts, cur.minToolCalls]));
761
+ const terms = await input('Words to hide in exports? (comma-separated, enter for none)', {
762
+ initial: (cur.redactTerms || []).join(', '),
763
+ hint: 'Company, product, client or internal host names you don\'t want in a shared transcript, e.g. acme, projectx, internal.acme.com',
764
+ });
765
+ const redactTerms = terms.split(',').map((x) => x.trim()).filter(Boolean);
766
+ const redactEmails = await ask('Redact email addresses in exports?', [
767
+ { label: 'Yes', value: true, hint: 'recommended' }, { label: 'No', value: false },
768
+ ], cur.redactEmails ? 0 : 1);
769
+
770
+ // Claude Code settings: never changed without an explicit answer (no terminal => leave alone).
771
+ let retention = null;
772
+ const days = settings.cleanupPeriodDays;
773
+ if (!(days >= 365) && !flags['no-cleanup-change']) {
774
+ console.log(`\n ${yellow('!')} Claude Code deletes transcripts older than ${days || 30} days. Sessions past that`);
775
+ console.log(` drop off your board and can no longer be shared.`);
776
+ retention = await ask('Keep transcripts longer? (edits ~/.claude/settings.json)', [
777
+ { label: 'Keep for 10 years', value: KEEP_FOREVER_DAYS, hint: 'recommended' },
778
+ { label: 'Keep for 1 year', value: 365 },
779
+ { label: 'Leave as is', value: null, hint: `${days || 30} days` },
780
+ ], 0, 2);
781
+ }
782
+ let hook = false;
783
+ if (!hasOurHook(settings)) {
784
+ hook = await ask('Rate sessions automatically when they end? (adds a hook to ~/.claude/settings.json)', [
785
+ { label: 'Yes', value: true, hint: 'recommended' },
786
+ { label: 'No', value: false, hint: 'run `sessionboard update` yourself' },
787
+ ], 0, 1);
788
+ }
789
+
790
+ const cfg = { judge: model !== false, judgeModel: model || cur.judgeModel, rejudgeGrowth, topN, minPrompts, minToolCalls, redactEmails, redactTerms };
791
+ const hookState = hasOurHook(settings) ? 'on (hook already installed)' : hook ? 'add hook' : 'off (manual updates)';
792
+ const keepState = days >= 365 ? `${days} days` : retention ? `${retention} days ${dim(`(was ${days || 30})`)}` : `${days || 30} days ${dim('(unchanged)')}`;
793
+ const row = (k, v) => `${padEnd(dim(k), 20)}${v}`;
794
+ console.log(`\n${box([
795
+ row('Judge model', cfg.judge ? modelName(cfg.judgeModel) : 'Off (heuristic only)'),
796
+ row('Re-rate', cfg.judge ? rejudgeName(cfg.rejudgeGrowth) : dim('n/a')),
797
+ row('Board size', `Top ${cfg.topN}`),
798
+ row('Counts as real', `≥${cfg.minPrompts} prompts, ≥${cfg.minToolCalls} tool calls`),
799
+ row('Redact emails', cfg.redactEmails ? 'Yes' : 'No'),
800
+ row('Hidden words', cfg.redactTerms.length ? cfg.redactTerms.join(', ').slice(0, 34) : dim('none')),
801
+ row('Keep transcripts', keepState),
802
+ row('Auto-rate', hookState),
803
+ ], { title: 'Your setup', width: 56 })}`);
804
+
805
+ const ok = await select(firstRun ? 'Save and start rating?' : 'Save these settings?', [
806
+ { label: 'Save', value: true }, { label: 'Cancel', value: false, hint: 'nothing is changed' },
807
+ ], { def: 0, fallback: 0 });
808
+ return ok ? { cfg, retention, hook } : null;
809
+ }
810
+
811
+ function applySetup({ cfg, retention, hook }) {
812
+ saveConfig({ ...cfg, initialized: true });
813
+ if (retention || hook) {
814
+ patchClaudeSettings((st) => {
815
+ if (retention) st.cleanupPeriodDays = retention;
816
+ if (hook) ensureHook(st);
817
+ });
818
+ console.log(` ${green('✔')} Updated ${tildify(CLAUDE_SETTINGS)} ${dim(`(backup: ${path.basename(CLAUDE_SETTINGS)}.sessionboard-backup)`)}`);
819
+ }
820
+ console.log(` ${green('✔')} Saved ${dim(tildify(CONFIG_FILE))}`);
821
+ }
822
+
823
+ async function rateWithProgress(sessions) {
824
+ let n = 0;
825
+ for (const s of sessions) {
826
+ n++;
827
+ const title = (s.title || s.firstPrompt || '(untitled)').slice(0, 46);
828
+ const sp = spinner(`${dim(`${n}/${sessions.length}`)} Rating ${title}…`);
829
+ try {
830
+ const v = await judgeAndStore(s.id, { force: true });
831
+ sp.stop(`${green('✔')} ${dim(`${n}/${sessions.length}`)} ${scoreBar(v.score)} ${bold(String(v.score).padStart(4))} ${title}`);
832
+ } catch (err) {
833
+ sp.stop(`${red('✖')} ${dim(`${n}/${sessions.length}`)} ${title} ${dim(err.message.slice(0, 60))}`);
834
+ }
835
+ }
836
+ }
837
+
838
+ async function init(flags) {
839
+ console.log(`\n ${accent('▍')}${bold('sessionboard')} ${dim('your best agent sessions, ranked and ready to share')}\n`);
840
+ console.log(howItWorks(loadConfig()));
841
+ console.log(`\n ${dim('A few choices. Nothing is saved until you confirm at the end.')}`);
842
+
843
+ const setup = await configure(flags, { firstRun: true });
844
+ if (!setup) return console.log(`\n Cancelled. Nothing was changed.\n`);
845
+ applySetup(setup);
846
+
847
+ const index = scan();
848
+ const cfg = loadConfig();
849
+ const eligible = Object.values(index.sessions).filter((s) => s.eligible && !s.missing);
850
+ const pending = eligible.filter((s) => needsJudge(s, cfg)).sort((a, b) => b.heuristic - a.heuristic);
851
+ const total = Object.keys(index.sessions).length;
852
+ console.log(`\n Found ${bold(total)} sessions on disk · ${bold(eligible.length)} count as real work · ${bold(pending.length)} need a score`);
853
+
854
+ if (cfg.judge && pending.length) {
855
+ let todo = pending;
856
+ if (pending.length > 20) {
857
+ todo = await select(`Rate how many now? (~${Math.round(8 * pending.length / 60)} min for all with ${modelName(cfg.judgeModel)})`, [
858
+ { label: 'The 20 most promising', value: pending.slice(0, 20), hint: 'picked by the free heuristic (recommended)' },
859
+ { label: `All ${pending.length}`, value: pending },
860
+ { label: 'None for now', value: [], hint: 'new sessions still get rated as they end' },
861
+ ], { def: 0, fallback: 0 });
862
+ }
863
+ if (todo.length) {
864
+ console.log(`\n ${bold('Rating')} ${dim(`with ${modelName(cfg.judgeModel)}, one call per session`)}\n`);
865
+ await rateWithProgress(todo);
866
+ }
867
+ }
868
+
869
+ console.log(`\n ${bold(`Your top ${cfg.topN}`)}\n`);
870
+ printTop(readIndex(), cfg.topN);
871
+ console.log(`\n${box([
872
+ `${accent('sessionboard dashboard')} open the dashboard in your browser`,
873
+ `${accent('sessionboard share 1 --html')} redacted page for your #1 session`,
874
+ `${accent('sessionboard config')} change any of these settings`,
875
+ ], { title: 'Next', width: 60 })}\n`);
876
+ }
877
+
878
+ // ---------- CLI ----------
879
+
880
+ function resolveSession(index, ref = '1') {
881
+ if (/^\d{1,3}$/.test(ref)) return ranked(index)[Number(ref) - 1];
882
+ return Object.values(index.sessions).find((s) => s.id.startsWith(ref));
883
+ }
884
+
885
+ const wrap = (text, width, indent) => {
886
+ const out = []; let line = '';
887
+ for (const w of text.split(/\s+/)) {
888
+ if (line && line.length + w.length + 1 > width) { out.push(line); line = w; } else line = line ? `${line} ${w}` : w;
889
+ }
890
+ if (line) out.push(line);
891
+ return out.map((l) => indent + l).join('\n');
892
+ };
893
+
894
+ // Terminal version of describe(): colors, score bar, wrapped text.
895
+ function describeTerm(s, i, index) {
896
+ const width = Math.min(process.stdout.columns || 80, 100) - 8;
897
+ const tags = [index.pinned.includes(s.id) && accent('pinned'), s.missing && red('transcript deleted')].filter(Boolean).join(dim(' · '));
898
+ const score = s.judge ? `${scoreBar(s.judge.score)} ${bold(s.judge.score)}${dim('/10')}` : dim('not rated yet');
899
+ const lines = [
900
+ ` ${medal(i + 1)} ${bold(s.title || s.firstPrompt?.slice(0, 60) || '(untitled)')}${tags ? ` ${tags}` : ''}`,
901
+ ` ${score} ${dim(`${fmtDate(s.start)} · ${tildify(s.cwd) || '?'} · ${shortId(s.id)}`)}`,
902
+ ];
903
+ if (s.judge?.summary) lines.push(wrap(s.judge.summary, width, ' '));
904
+ if (s.judge?.reason) lines.push(dim(wrap(`why: ${s.judge.reason}`, width, ' ')));
905
+ lines.push(dim(wrap(highlights(s), width, ' ')));
906
+ return lines.join('\n');
907
+ }
908
+
909
+ function printTop(index, n) {
910
+ const list = ranked(index).slice(0, n);
911
+ if (!list.length) return console.log(' No eligible sessions yet. Do some real work with Claude Code first.');
912
+ console.log(list.map((s, i) => describeTerm(s, i, index)).join('\n\n'));
913
+ }
914
+
915
+ function parseArgs(argv) {
916
+ const flags = {}; const pos = [];
917
+ for (let i = 0; i < argv.length; i++) {
918
+ const a = argv[i];
919
+ if (!a.startsWith('--')) { pos.push(a); continue; }
920
+ const [k, v] = a.slice(2).split('=');
921
+ if (v !== undefined) flags[k] = v;
922
+ else if (['out', 'n', 'port'].includes(k)) flags[k] = argv[++i];
923
+ else flags[k] = true;
924
+ }
925
+ return { flags, pos };
926
+ }
927
+
928
+ const HELP = `sessionboard - an AI-judged leaderboard of your Claude Code sessions
929
+
930
+ sessionboard init one-time setup (asks before changing any settings)
931
+ sessionboard dashboard [--port N] open the local dashboard (http://localhost:4317)
932
+ sessionboard top [--n 5] print your top sessions
933
+ sessionboard share [rank|id] [opts] export a redacted, shareable transcript
934
+ --html self-contained HTML instead of Markdown
935
+ --out <file> output path (default ~/.sessionboard/exports/)
936
+ --copy copy to clipboard
937
+ --gist upload as a SECRET GitHub gist (needs gh) and print the URL
938
+ --thinking include the agent's thinking blocks
939
+ --results include truncated tool output
940
+ --keep-emails don't redact email addresses
941
+ sessionboard update rescan and rate any new sessions
942
+ sessionboard rejudge <rank|id> rate one session again
943
+ sessionboard pin|unpin|hide|unhide <id> curate the board by hand
944
+ sessionboard show <rank|id> all stats for one session
945
+ sessionboard config change settings interactively (or: config key=value)
946
+ sessionboard uninstall [--purge] remove the rating hook (and with --purge, all data)
947
+
948
+ Data lives in ${tildify(DATA_DIR)}; TOP3.md there is always current.`;
949
+
950
+ async function main() {
951
+ const [cmd = 'top', ...rest] = process.argv.slice(2);
952
+ const { flags, pos } = parseArgs(rest);
953
+ const log = flags.quiet ? () => {} : (m) => process.stderr.write(m);
954
+
955
+ if (cmd === 'hook') {
956
+ // SessionEnd hook: return immediately, rate the session that just ended in a detached child.
957
+ if (process.env[CHILD_ENV]) return;
958
+ let input = {};
959
+ try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch {}
960
+ const id = input.session_id || (input.transcript_path && path.basename(input.transcript_path, '.jsonl'));
961
+ fs.mkdirSync(DATA_DIR, { recursive: true });
962
+ const out = fs.openSync(path.join(DATA_DIR, 'hook.log'), 'a');
963
+ spawn(process.execPath, [SELF, 'rate-one', id || '', '--quiet'], { detached: true, stdio: ['ignore', out, out], env: { ...process.env, [CHILD_ENV]: '1' } }).unref();
964
+ return;
965
+ }
966
+
967
+ if (cmd === 'plugin-start') {
968
+ // Plugin SessionStart hook: until setup has run, point the user to /sessionboard:setup (once).
969
+ if (process.env[CHILD_ENV] || loadConfig().initialized) return;
970
+ const marker = path.join(DATA_DIR, '.setup-nudged');
971
+ if (fs.existsSync(marker)) return;
972
+ fs.mkdirSync(DATA_DIR, { recursive: true });
973
+ fs.writeFileSync(marker, new Date().toISOString());
974
+ console.log(JSON.stringify({ systemMessage: 'sessionboard is installed. Run /sessionboard:setup to turn on AI rating of your sessions.' }));
975
+ return;
976
+ }
977
+
978
+ refreshAppCopy();
979
+
980
+ // Non-interactive setup used by /sessionboard:setup: Claude asks the questions, then calls these.
981
+ if (cmd === 'setup-info') {
982
+ const settings = readJSON(CLAUDE_SETTINGS, {});
983
+ const index = scan();
984
+ const sessions = Object.values(index.sessions);
985
+ return console.log(JSON.stringify({
986
+ config: loadConfig(),
987
+ cleanupPeriodDays: settings.cleanupPeriodDays ?? 30,
988
+ hookInstalled: hasOurHook(settings),
989
+ sessions: sessions.length,
990
+ eligible: sessions.filter((x) => x.eligible && !x.missing).length,
991
+ unrated: sessions.filter((x) => x.eligible && !x.missing && !x.judge).length,
992
+ }, null, 2));
993
+ }
994
+
995
+ if (cmd === 'setup-apply') {
996
+ const cur = loadConfig();
997
+ const num = (v, d) => (v === undefined || v === true ? d : Number(v));
998
+ const model = flags.judgeModel ?? cur.judgeModel;
999
+ const cfg = {
1000
+ judge: model !== 'off',
1001
+ judgeModel: model === 'off' ? cur.judgeModel : model,
1002
+ rejudgeGrowth: num(flags.rejudgeGrowth, cur.rejudgeGrowth),
1003
+ topN: num(flags.topN, cur.topN),
1004
+ minPrompts: num(flags.minPrompts, cur.minPrompts),
1005
+ minToolCalls: num(flags.minToolCalls, cur.minToolCalls),
1006
+ redactEmails: flags.redactEmails === undefined ? cur.redactEmails : flags.redactEmails !== 'false',
1007
+ redactTerms: flags.redactTerms === undefined ? cur.redactTerms : String(flags.redactTerms === true ? '' : flags.redactTerms).split(',').map((x) => x.trim()).filter(Boolean),
1008
+ };
1009
+ const retention = flags.retention && flags.retention !== 'keep' ? Number(flags.retention) : null;
1010
+ applySetup({ cfg, retention, hook: flags.hook === 'yes' });
1011
+ scan();
1012
+ return;
1013
+ }
1014
+
1015
+ if (cmd === 'uninstall') {
1016
+ // Removes the rating hook and the app copy; your scores stay in ~/.sessionboard unless --purge.
1017
+ const settings = readJSON(CLAUDE_SETTINGS, null);
1018
+ if (settings && hasOurHook(settings)) {
1019
+ patchClaudeSettings((st) => {
1020
+ const kept = st.hooks.SessionEnd.filter((h) => !JSON.stringify(h).includes('sessionboard'));
1021
+ if (kept.length) st.hooks.SessionEnd = kept; else delete st.hooks.SessionEnd;
1022
+ if (!Object.keys(st.hooks).length) delete st.hooks;
1023
+ });
1024
+ console.log(`Removed the sessionboard hook from ${tildify(CLAUDE_SETTINGS)}.`);
1025
+ }
1026
+ fs.rmSync(flags.purge ? DATA_DIR : APP_DIR, { recursive: true, force: true });
1027
+ return console.log(flags.purge ? `Deleted ${tildify(DATA_DIR)}.` : `Your scores are kept in ${tildify(DATA_DIR)} (use --purge to delete them).`);
1028
+ }
1029
+
1030
+ if (cmd === 'help' || flags.help || flags.h) return console.log(HELP);
1031
+ if (cmd === 'init') return init(flags);
1032
+ if (cmd === 'dashboard' || cmd === 'ui') { scan(); return dashboard(flags); }
1033
+
1034
+ if (cmd === 'config') {
1035
+ if (!pos.length && process.stdin.isTTY && !flags.json) {
1036
+ console.log(`\n ${accent('▍')}${bold('sessionboard')} ${dim('settings')}`);
1037
+ const setup = await configure(flags, { firstRun: false });
1038
+ if (!setup) return console.log('\n Cancelled. Nothing was changed.\n');
1039
+ applySetup(setup);
1040
+ scan(); // re-apply thresholds to the board
1041
+ return console.log(` ${dim('New sessions use these settings. Run `sessionboard update` to rate anything now unrated.')}\n`);
1042
+ }
1043
+ const cur = readJSON(CONFIG_FILE, {});
1044
+ for (const kv of pos) {
1045
+ const [k, v] = kv.split('=');
1046
+ if (!(k in DEFAULT_CONFIG)) { console.error(`unknown key: ${k}`); process.exit(1); }
1047
+ const d = DEFAULT_CONFIG[k];
1048
+ cur[k] = typeof d === 'boolean' ? v === 'true' : typeof d === 'number' ? Number(v) : Array.isArray(d) ? (v || '').split(',').map((x) => x.trim()).filter(Boolean) : v;
1049
+ }
1050
+ if (pos.length) { writeJSON(CONFIG_FILE, cur); scan(); }
1051
+ return console.log(JSON.stringify({ ...DEFAULT_CONFIG, ...cur }, null, 2));
1052
+ }
1053
+
1054
+ const cfg = loadConfig();
1055
+ const index = scan();
1056
+ const canJudge = cfg.judge && cfg.initialized; // never spawn `claude` before init has asked about cleanup
1057
+
1058
+ if (cmd === 'rate-one') {
1059
+ if (!canJudge) return;
1060
+ const id = pos[0] && index.sessions[pos[0]] ? pos[0] : null;
1061
+ try { if (id) await judgeAndStore(id, { log }); } catch (err) { console.error(`${new Date().toISOString()} ${pos[0]}: ${err.message}`); }
1062
+ return;
1063
+ }
1064
+
1065
+ if (cmd === 'update') {
1066
+ if (canJudge) await judgePending({ log });
1067
+ else if (!cfg.initialized) log('Not set up yet; run `sessionboard init` (or /sessionboard:setup in Claude Code) to enable AI rating.\n');
1068
+ if (!flags.quiet) console.log(`Updated. ${Object.keys(readIndex().sessions).length} sessions indexed → ${tildify(TOP_FILE)}`);
1069
+ return;
1070
+ }
1071
+
1072
+ if (cmd === 'top' || cmd === 'ls') {
1073
+ if (!cfg.initialized) console.log(`\n ${yellow('!')} AI rating is off until you run ${accent('sessionboard init')} ${dim('(or /sessionboard:setup in Claude Code)')}`);
1074
+ console.log(`\n ${bold(`Your top ${Number(flags.n) || cfg.topN} agent sessions`)}\n`);
1075
+ printTop(readIndex(), Number(flags.n) || cfg.topN);
1076
+ console.log(`\n ${dim('share one:')} ${accent('sessionboard share 1 --html')} ${dim('dashboard:')} ${accent('sessionboard dashboard')}\n`);
1077
+ return;
1078
+ }
1079
+
1080
+ const s = resolveSession(index, pos[0]);
1081
+ if (['share', 'export', 'show', 'rejudge', 'pin', 'unpin', 'hide', 'unhide'].includes(cmd) && !s) {
1082
+ console.error(`No session "${pos[0] || 1}". Run \`sessionboard top\` to see ranks.`);
1083
+ process.exit(1);
1084
+ }
1085
+
1086
+ if (cmd === 'rejudge') {
1087
+ if (!cfg.initialized) { console.error('Run `sessionboard init` first.'); process.exit(1); }
1088
+ try { await judgeAndStore(s.id, { force: true, log }); } catch (err) { console.error(`failed: ${err.message}`); process.exit(1); }
1089
+ return;
1090
+ }
1091
+
1092
+ if (['pin', 'unpin', 'hide', 'unhide'].includes(cmd)) {
1093
+ setFlag(s.id, cmd);
1094
+ return console.log(`${cmd}: ${shortId(s.id)} "${s.title || ''}"`);
1095
+ }
1096
+
1097
+ if (cmd === 'show') {
1098
+ const { file, ...rest } = s;
1099
+ return console.log(JSON.stringify({ ...rest, file: tildify(file) }, null, 2));
1100
+ }
1101
+
1102
+ if (cmd === 'share' || cmd === 'export') {
1103
+ const html = !!flags.html;
1104
+ let content;
1105
+ try { content = renderExport(s, { html, thinking: !!flags.thinking, results: !!flags.results, keepEmails: !!flags['keep-emails'] }); }
1106
+ catch (err) { console.error(err.message); process.exit(1); }
1107
+ const out = flags.out ? path.resolve(flags.out) : path.join(EXPORT_DIR, `${exportSlug(s)}.${html ? 'html' : 'md'}`);
1108
+ fs.mkdirSync(path.dirname(out), { recursive: true });
1109
+ fs.writeFileSync(out, content);
1110
+ console.log(`Exported "${s.title || s.id}" → ${out}`);
1111
+ console.log(`Redacted: home paths, API keys/tokens/secrets${cfg.redactEmails && !flags['keep-emails'] ? ', emails' : ''}. Skim it before sharing.`);
1112
+ if (flags.copy) {
1113
+ const [bin, args] = process.platform === 'darwin' ? ['pbcopy', []] : process.platform === 'win32' ? ['clip', []] : ['xclip', ['-selection', 'clipboard']];
1114
+ console.log(spawnSync(bin, args, { input: content }).status === 0 ? 'Copied to clipboard.' : `Could not copy (${bin} unavailable).`);
1115
+ }
1116
+ if (flags.gist) {
1117
+ const r = spawnSync('gh', ['gist', 'create', out, '--desc', `Agent session: ${s.title || ''}`], { encoding: 'utf8' });
1118
+ console.log(r.status === 0 ? `Secret gist: ${r.stdout.trim()}` : `gist failed: ${(r.stderr || '').trim()}`);
1119
+ }
1120
+ return;
1121
+ }
1122
+
1123
+ console.log(HELP);
1124
+ }
1125
+
1126
+ main().catch((err) => { console.error(err.message); process.exit(1); });