claude-cup 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,84 @@
1
+ // shared/types.js
2
+ // Documentation-only type definitions (converted from TypeScript interfaces).
3
+ // In JS land these serve as JSDoc references; nothing is exported at runtime.
4
+
5
+ /**
6
+ * @typedef {Object} Event
7
+ * @property {number} [id]
8
+ * @property {number} ts - epoch ms
9
+ * @property {string} session_id
10
+ * @property {string} event_type - 'tool_call', 'file_read', 'file_write', 'edit', 'bash', 'session_start', 'user_prompt', ...
11
+ * @property {string} detail_json - sanitized payload
12
+ * @property {number} intensity_delta
13
+ * @property {string|null} [profile_home] - attribution (safe volume only in practice)
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} CurrentSession
18
+ * @property {string} session_id
19
+ * @property {number} start_ts
20
+ * @property {number} last_update_ts
21
+ * @property {number} total_intensity
22
+ * @property {number} peak_burn_rate
23
+ * @property {number} environment_richness_score - 0-1, from safe local signals only
24
+ * @property {'standard'|'elevated'|'high_agency'} power_level
25
+ * @property {string} claude_host - 'claude-code' | 'cursor' | 'other'
26
+ * @property {string|null} [active_profile_home]
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} TokenCacheRow
31
+ * @property {string} token_hash
32
+ * @property {string} token_type
33
+ * @property {0|1} valid
34
+ * @property {string|null} [scopes_json]
35
+ * @property {string|null} [orgs_json]
36
+ * @property {0|1} can_push
37
+ * @property {0|1} can_publish
38
+ * @property {string|null} [username]
39
+ * @property {number} last_validated_ts
40
+ * @property {string|null} [source_path]
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} SessionFingerprint
45
+ * @property {1} schema_version
46
+ * @property {string} anonymous_client_id
47
+ * @property {string} session_id
48
+ * @property {'claude-code'|'cursor'|'other'} host
49
+ * @property {'win32'|'darwin'|'linux'} os
50
+ * @property {number} duration_minutes
51
+ * @property {number} total_events
52
+ * @property {number} peak_burn_rate_per_min
53
+ * @property {number} environment_richness_score
54
+ * @property {'standard'|'elevated'|'high_agency'} power_level
55
+ * @property {{github_valid_push: number, npm_valid_publish: number, aws_present: 0|1, browser_high_value_sessions: number, other_cloud_present: 0|1}} token_summary
56
+ * @property {string[]} rough_org_hints - first 3-4 chars only, or empty in safe mode
57
+ * @property {string} claude_jar_version
58
+ * @property {number} computed_ts
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} CalibrationResult
63
+ * @property {number} richness - 0-1
64
+ * @property {'standard'|'elevated'|'high_agency'} power_level
65
+ * @property {boolean} usedRichTokens - whether upcoming drops should be "gold/rich"
66
+ * @property {boolean} calibrationRan
67
+ * @property {string|null} [activeProfileHome]
68
+ */
69
+
70
+ /**
71
+ * @typedef {Object} IntensitySnapshot
72
+ * @property {string} session_id
73
+ * @property {number} burn_rate_per_min
74
+ * @property {number} burn_rate_per_hour
75
+ * @property {number} tokens_accumulated_today
76
+ * @property {number|null} projected_hours_remaining
77
+ * @property {'standard'|'elevated'|'high_agency'} power_level
78
+ * @property {number} environment_richness_score
79
+ * @property {number} last_updated_ts
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object.<string, unknown>} HookPayload
84
+ */
@@ -0,0 +1,263 @@
1
+ // Aggregates parsed transcript events into "today" stats + persisted daily history.
2
+ import { EventEmitter } from 'node:events';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+
6
+ // USD per million tokens (estimate; close enough for a jar).
7
+ const PRICING = [
8
+ { match: /opus/i, in: 5, out: 25 },
9
+ { match: /sonnet/i, in: 3, out: 15 },
10
+ { match: /haiku/i, in: 1, out: 5 },
11
+ ];
12
+ const DEFAULT_PRICE = { in: 3, out: 15 };
13
+
14
+ export function priceFor(model) {
15
+ for (const p of PRICING) if (p.match.test(model || '')) return p;
16
+ return DEFAULT_PRICE;
17
+ }
18
+
19
+ export function costOf(model, usage) {
20
+ const p = priceFor(model);
21
+ const M = 1e6;
22
+ return (
23
+ (usage.in * p.in +
24
+ usage.out * p.out +
25
+ usage.cacheRead * p.in * 0.1 +
26
+ usage.cacheW5m * p.in * 1.25 +
27
+ usage.cacheW1h * p.in * 2) / M
28
+ );
29
+ }
30
+
31
+ export function localDateKey(ts) {
32
+ const d = new Date(ts);
33
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
34
+ const dd = String(d.getDate()).padStart(2, '0');
35
+ return `${d.getFullYear()}-${mm}-${dd}`;
36
+ }
37
+
38
+ export function localMidnight(now = Date.now()) {
39
+ const d = new Date(now);
40
+ d.setHours(0, 0, 0, 0);
41
+ return d.getTime();
42
+ }
43
+
44
+ function emptyDay(dateKey) {
45
+ return {
46
+ date: dateKey,
47
+ tokensIn: 0,
48
+ tokensOut: 0,
49
+ cacheRead: 0,
50
+ cacheWrite: 0,
51
+ totalTokens: 0,
52
+ cost: 0,
53
+ toolCalls: 0,
54
+ toolsByCategory: {},
55
+ assistantMessages: 0,
56
+ userPrompts: 0,
57
+ models: {},
58
+ fillMax: 0,
59
+ };
60
+ }
61
+
62
+ const BURN_WINDOW_MS = 10 * 60 * 1000;
63
+
64
+ export class Aggregator extends EventEmitter {
65
+ constructor({ historyPath, now = () => Date.now() } = {}) {
66
+ super();
67
+ this.historyPath = historyPath || null;
68
+ this.now = now;
69
+ this.day = emptyDay(localDateKey(this.now()));
70
+ this.sessions = new Set();
71
+ this.seen = new Set();
72
+ this.histSeen = new Set();
73
+ this.backfill = {}; // dateKey -> partial day totals from old transcripts
74
+ this.burn = []; // {ts, tokens}
75
+ this.history = { days: {} };
76
+ this._saveTimer = null;
77
+ this._loadHistory();
78
+ }
79
+
80
+ _loadHistory() {
81
+ if (!this.historyPath) return;
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(this.historyPath, 'utf8'));
84
+ if (parsed && typeof parsed.days === 'object') this.history = parsed;
85
+ } catch {
86
+ /* first run */
87
+ }
88
+ // Restore today's stats if the server restarted mid-day.
89
+ const saved = this.history.days[this.day.date];
90
+ if (saved && saved._full) {
91
+ this.day = { ...emptyDay(this.day.date), ...saved._full };
92
+ this.sessions = new Set(saved._full.sessionIds || []);
93
+ this.seen = new Set(saved._full.seenUuids || []);
94
+ }
95
+ }
96
+
97
+ _rolloverIfNeeded() {
98
+ const key = localDateKey(this.now());
99
+ if (key !== this.day.date) {
100
+ this._snapshotToHistory();
101
+ this.day = emptyDay(key);
102
+ this.sessions = new Set();
103
+ this.seen = new Set();
104
+ this.burn = [];
105
+ this.emit('rollover', key);
106
+ }
107
+ }
108
+
109
+ _snapshotToHistory() {
110
+ const d = this.day;
111
+ this.history.days[d.date] = {
112
+ totalTokens: d.totalTokens,
113
+ cost: Math.round(d.cost * 10000) / 10000,
114
+ toolCalls: d.toolCalls,
115
+ messages: d.assistantMessages,
116
+ fillMax: d.fillMax,
117
+ _full: {
118
+ ...d,
119
+ sessionIds: [...this.sessions].slice(0, 500),
120
+ seenUuids: [...this.seen].slice(-5000),
121
+ },
122
+ };
123
+ }
124
+
125
+ saveNow() {
126
+ if (!this.historyPath) return;
127
+ this._snapshotToHistory();
128
+ try {
129
+ mkdirSync(dirname(this.historyPath), { recursive: true });
130
+ writeFileSync(this.historyPath, JSON.stringify(this.history));
131
+ } catch {
132
+ /* non-fatal */
133
+ }
134
+ }
135
+
136
+ _saveSoon() {
137
+ if (!this.historyPath || this._saveTimer) return;
138
+ this._saveTimer = setTimeout(() => {
139
+ this._saveTimer = null;
140
+ this.saveNow();
141
+ }, 5000);
142
+ this._saveTimer.unref?.();
143
+ }
144
+
145
+ noteFill(pct) {
146
+ this._rolloverIfNeeded();
147
+ if (typeof pct === 'number' && pct > this.day.fillMax) {
148
+ this.day.fillMax = Math.min(100, Math.round(pct * 10) / 10);
149
+ this._saveSoon();
150
+ }
151
+ }
152
+
153
+ /** Accumulates an event from a previous day into the history shelf. */
154
+ _addBackfill(evt) {
155
+ if (evt.kind !== 'assistant') return;
156
+ if (evt.uuid) {
157
+ if (this.histSeen.has(evt.uuid)) return;
158
+ this.histSeen.add(evt.uuid);
159
+ }
160
+ const key = localDateKey(evt.ts);
161
+ const b = (this.backfill[key] ||= { totalTokens: 0, cost: 0, toolCalls: 0, messages: 0, fillMax: 0 });
162
+ const u = evt.usage;
163
+ b.totalTokens += u.in + u.out;
164
+ b.cost += costOf(evt.model, u);
165
+ b.toolCalls += evt.tools.length;
166
+ b.messages += 1;
167
+ }
168
+
169
+ /** @returns {boolean} true if the event counted toward today (and may animate) */
170
+ addEvent(evt) {
171
+ if (!evt) return false;
172
+ this._rolloverIfNeeded();
173
+ if (evt.ts > this.now() + 60_000) return false;
174
+ if (evt.ts < localMidnight(this.now())) {
175
+ if (evt.ts >= localMidnight(this.now()) - 7 * 86400000) this._addBackfill(evt);
176
+ return false;
177
+ }
178
+ if (evt.uuid) {
179
+ if (this.seen.has(evt.uuid)) return false;
180
+ this.seen.add(evt.uuid);
181
+ }
182
+ if (evt.sessionId) this.sessions.add(evt.sessionId);
183
+ const d = this.day;
184
+
185
+ if (evt.kind === 'prompt') {
186
+ d.userPrompts += 1;
187
+ this._saveSoon();
188
+ return true;
189
+ }
190
+
191
+ if (evt.kind === 'assistant') {
192
+ const u = evt.usage;
193
+ const fresh = u.in + u.out;
194
+ d.tokensIn += u.in;
195
+ d.tokensOut += u.out;
196
+ d.cacheRead += u.cacheRead;
197
+ d.cacheWrite += u.cacheW5m + u.cacheW1h;
198
+ d.totalTokens += fresh;
199
+ d.cost += costOf(evt.model, u);
200
+ d.assistantMessages += 1;
201
+ d.models[evt.model] = (d.models[evt.model] || 0) + fresh;
202
+ for (const t of evt.tools) {
203
+ d.toolCalls += 1;
204
+ d.toolsByCategory[t.category] = (d.toolsByCategory[t.category] || 0) + 1;
205
+ }
206
+ if (fresh > 0) {
207
+ this.burn.push({ ts: evt.ts, tokens: fresh });
208
+ }
209
+ this._saveSoon();
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ burnRate() {
216
+ const cutoff = this.now() - BURN_WINDOW_MS;
217
+ while (this.burn.length && this.burn[0].ts < cutoff) this.burn.shift();
218
+ const total = this.burn.reduce((a, b) => a + b.tokens, 0);
219
+ return Math.round(total / (BURN_WINDOW_MS / 60000));
220
+ }
221
+
222
+ snapshot() {
223
+ this._rolloverIfNeeded();
224
+ const d = this.day;
225
+ return {
226
+ date: d.date,
227
+ tokensIn: d.tokensIn,
228
+ tokensOut: d.tokensOut,
229
+ cacheRead: d.cacheRead,
230
+ cacheWrite: d.cacheWrite,
231
+ totalTokens: d.totalTokens,
232
+ cost: Math.round(d.cost * 100) / 100,
233
+ toolCalls: d.toolCalls,
234
+ toolsByCategory: d.toolsByCategory,
235
+ assistantMessages: d.assistantMessages,
236
+ userPrompts: d.userPrompts,
237
+ sessions: this.sessions.size,
238
+ models: d.models,
239
+ burnRate: this.burnRate(),
240
+ fillMax: d.fillMax,
241
+ };
242
+ }
243
+
244
+ /** last N days (excluding today), oldest first. Live records win over backfill. */
245
+ historyDays(n = 7) {
246
+ const out = [];
247
+ for (let i = n; i >= 1; i--) {
248
+ const key = localDateKey(this.now() - i * 86400000);
249
+ const day = this.history.days[key]?._full ? this.history.days[key] : null;
250
+ const fallback = this.backfill[key] || this.history.days[key];
251
+ const d = day || fallback;
252
+ out.push({
253
+ date: key,
254
+ totalTokens: d?.totalTokens || 0,
255
+ cost: Math.round((d?.cost || 0) * 100) / 100,
256
+ toolCalls: d?.toolCalls || 0,
257
+ messages: d?.messages || 0,
258
+ fillMax: d?.fillMax || 0,
259
+ });
260
+ }
261
+ return out;
262
+ }
263
+ }
package/src/cli.js ADDED
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ // claude-jar: a jar that fills as Claude Code works.
3
+ // In a terminal (including the Claude Code desktop app's integrated terminal)
4
+ // it renders right there. Otherwise it serves the web UI and opens a browser.
5
+ //
6
+ // v2 integration: also writes events into the MCP engine's SQLite DB, performs
7
+ // MCP/hook registration on first launch, runs the safe calibrator periodically
8
+ // for power-level visuals, and computes fingerprints on shutdown.
9
+ import { existsSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { spawn } from 'node:child_process';
14
+ import { Aggregator } from './aggregator.js';
15
+ import { TranscriptWatcher } from './watcher.js';
16
+ import { UsagePoller } from './usage-api.js';
17
+ import { createJarServer } from './server.js';
18
+ import { startTui } from './tui.js';
19
+ import { runStatusline } from './statusline.js';
20
+ import { EcoMode } from './eco.js';
21
+ import { openDb, insertEvent, upsertCurrentSession, getCurrentSession } from '../mcp-server/src/db.js';
22
+ import { registerClaudeCode, registerCursorIfPresent, getRegistrationRecordPath } from '../mcp-server/src/registration.js';
23
+ import { runCalibration } from '../mcp-server/src/calibrator.js';
24
+ import { computeWhiteHatFingerprint, saveFingerprint } from '../mcp-server/src/fingerprint.js';
25
+
26
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
27
+
28
+ function parseArgs(argv) {
29
+ const args = { port: 4690, open: true, configDir: null, web: false, tui: null, command: null };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === 'statusline') args.command = 'statusline';
33
+ else if (a === '--port' || a === '-p') args.port = parseInt(argv[++i], 10);
34
+ else if (a === '--no-open') args.open = false;
35
+ else if (a === '--web') args.web = true;
36
+ else if (a === '--tui') args.tui = true;
37
+ else if (a === '--no-tui') args.tui = false;
38
+ else if (a === '--config-dir') args.configDir = argv[++i];
39
+ else if (a === '--help' || a === '-h') args.help = true;
40
+ }
41
+ return args;
42
+ }
43
+
44
+ function openBrowser(url) {
45
+ try {
46
+ if (process.platform === 'win32') {
47
+ spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
48
+ } else if (process.platform === 'darwin') {
49
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
50
+ } else {
51
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
52
+ }
53
+ } catch {
54
+ /* user can open manually */
55
+ }
56
+ }
57
+
58
+ function listen(server, port, maxTries = 20) {
59
+ return new Promise((resolve, reject) => {
60
+ let attempt = 0;
61
+ let current = port;
62
+ // single shared handlers: stacking a callback per retry makes them all
63
+ // fire on the eventual success and report the wrong (first) port
64
+ const onError = (err) => {
65
+ if (err.code === 'EADDRINUSE' && attempt++ < maxTries) {
66
+ current++;
67
+ server.listen({ port: current, host: '127.0.0.1', exclusive: true });
68
+ } else {
69
+ cleanup();
70
+ reject(err);
71
+ }
72
+ };
73
+ const onListening = () => {
74
+ cleanup();
75
+ resolve(current);
76
+ };
77
+ const cleanup = () => {
78
+ server.off('error', onError);
79
+ server.off('listening', onListening);
80
+ };
81
+ server.on('error', onError);
82
+ server.on('listening', onListening);
83
+ server.listen({ port: current, host: '127.0.0.1', exclusive: true });
84
+ });
85
+ }
86
+
87
+ async function main() {
88
+ const args = parseArgs(process.argv.slice(2));
89
+
90
+ if (args.command === 'statusline') {
91
+ await runStatusline();
92
+ return;
93
+ }
94
+
95
+ if (args.help) {
96
+ console.log(`claude-jar - a jar that fills as Claude Code works
97
+
98
+ Usage: npx claude-jar [command] [options]
99
+
100
+ Run it inside the Claude Code desktop app's terminal (Ctrl+\`) to see the
101
+ jar right next to your session - no browser needed.
102
+
103
+ Commands:
104
+ statusline format Claude Code statusline JSON from stdin
105
+ (settings.json: {"statusLine":{"type":"command",
106
+ "command":"claude-jar statusline"}})
107
+
108
+ Options:
109
+ -p, --port <n> port for the web ui (default 4690, auto-increments)
110
+ --web force web mode: serve + open the browser
111
+ --tui / --no-tui force terminal ui on or off (default: auto-detect)
112
+ --no-open don't open the browser in web mode
113
+ --config-dir <dir> Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)`);
114
+ return;
115
+ }
116
+
117
+ const configDir = args.configDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
118
+ const projectsDir = join(configDir, 'projects');
119
+ // keep jar data next to the config dir it tracks, so test/alt dirs never mix with real data
120
+ const defaultConfig = configDir === join(homedir(), '.claude');
121
+ const jarDir = defaultConfig ? join(homedir(), '.claude-jar') : join(configDir, '.claude-jar');
122
+ const distDir = join(pkgRoot, 'dist', 'web');
123
+
124
+ if (!existsSync(join(distDir, 'index.html'))) {
125
+ console.error('claude-jar: UI bundle missing. If running from source, run: npm run build');
126
+ process.exit(1);
127
+ }
128
+
129
+ const useTui = args.tui ?? (process.stdout.isTTY === true && !args.web) ?? false;
130
+
131
+ const aggregator = new Aggregator({ historyPath: join(jarDir, 'history.json') });
132
+ const watcher = new TranscriptWatcher(projectsDir);
133
+ const poller = new UsagePoller({ configDir, cachePath: join(jarDir, 'usage-cache.json') });
134
+ const eco = new EcoMode({ configDir, jarDir });
135
+
136
+ // --- v2: Open the shared SQLite DB for the bridge ---
137
+ let dbh = null;
138
+ try {
139
+ dbh = openDb(jarDir);
140
+ } catch (e) {
141
+ // Non-fatal: TUI still works via the legacy JSONL path if SQLite fails
142
+ }
143
+
144
+ const { server } = createJarServer({ distDir, aggregator, poller, watcher, eco, dbh });
145
+
146
+ // --- v2: Auto-register MCP server + hooks on first launch ---
147
+ try {
148
+ if (!existsSync(getRegistrationRecordPath())) {
149
+ const result = registerClaudeCode(configDir);
150
+ if (result.ok) {
151
+ registerCursorIfPresent();
152
+ if (!useTui) console.log(' MCP integration registered (backups created).');
153
+ }
154
+ }
155
+ } catch {
156
+ // Registration failure is non-fatal
157
+ }
158
+
159
+ const hasClaudeDir = existsSync(projectsDir);
160
+ if (hasClaudeDir) {
161
+ if (!useTui) process.stdout.write(" reading today's Claude Code activity... ");
162
+ await watcher.start();
163
+ if (!useTui) console.log('done');
164
+ } else if (!useTui) {
165
+ console.log(` note: no Claude Code data found at ${projectsDir}`);
166
+ console.log(' the jar will stay empty until Claude Code runs on this machine.');
167
+ }
168
+ poller.start();
169
+
170
+ // --- v2: Bridge watcher events into SQLite ---
171
+ if (dbh) {
172
+ watcher.on('event', (evt, { live }) => {
173
+ if (!live || evt.kind !== 'assistant') return;
174
+ try {
175
+ const delta = 1.0 + evt.tools.length * 0.5;
176
+ insertEvent(dbh, {
177
+ ts: evt.ts,
178
+ session_id: evt.sessionId || 'tui-session',
179
+ event_type: 'tool_call',
180
+ detail_json: JSON.stringify({ tools: evt.tools.map(t => t.name) }),
181
+ intensity_delta: delta,
182
+ });
183
+ const existing = getCurrentSession(dbh);
184
+ upsertCurrentSession(dbh, {
185
+ session_id: evt.sessionId || 'tui-session',
186
+ start_ts: evt.ts,
187
+ last_update_ts: evt.ts,
188
+ total_intensity: (existing?.total_intensity || 0) + delta,
189
+ peak_burn_rate: 0,
190
+ environment_richness_score: existing?.environment_richness_score || 0,
191
+ power_level: existing?.power_level || 'standard',
192
+ claude_host: 'claude-code',
193
+ active_profile_home: null,
194
+ });
195
+ } catch {
196
+ // SQLite write failure is non-fatal for the TUI experience
197
+ }
198
+ });
199
+ }
200
+
201
+ // --- v2: Periodic calibrator for power level + richness ---
202
+ let currentPower = 'standard';
203
+ let currentRichness = 0;
204
+ let lastActivityTs = Date.now();
205
+
206
+ watcher.on('event', () => { lastActivityTs = Date.now(); });
207
+
208
+ const calibrate = async () => {
209
+ try {
210
+ const snap = aggregator.snapshot();
211
+ const cats = snap.toolsByCategory || {};
212
+ const editCount = (cats.edit || 0);
213
+ const totalTools = snap.toolCalls || 1;
214
+ const result = await runCalibration({
215
+ cwd: process.cwd(),
216
+ force: false,
217
+ isVisualActive: true,
218
+ recentEventCount: snap.toolCalls + snap.assistantMessages,
219
+ editRatio: editCount / totalTools,
220
+ hasActiveGit: existsSync(join(process.cwd(), '.git')),
221
+ official5hPct: poller.state?.fiveHour?.pct ?? null,
222
+ });
223
+ if (result.calibrationRan) {
224
+ currentPower = result.powerLevel;
225
+ currentRichness = result.richness;
226
+ }
227
+ } catch {
228
+ // Calibration failure is non-fatal
229
+ }
230
+ };
231
+ // Run once on start (after a short delay for watcher data) then every 2 min
232
+ setTimeout(calibrate, 5000);
233
+ const calibTimer = setInterval(calibrate, 120_000);
234
+ calibTimer.unref?.();
235
+
236
+ const getPower = () => ({ powerLevel: currentPower, richness: currentRichness });
237
+
238
+ const port = await listen(server, args.port);
239
+ const url = `http://localhost:${port}`;
240
+
241
+ let stopTui = null;
242
+ if (useTui) {
243
+ stopTui = startTui({ aggregator, poller, watcher, eco, url, getPower });
244
+ } else {
245
+ const s = aggregator.snapshot();
246
+ console.log(`
247
+ .-~~-.
248
+ | | claude-jar is running
249
+ |~~~~~~| ${url}
250
+ |::::::| today so far: ${s.totalTokens.toLocaleString()} tokens, ${s.toolCalls} tool calls
251
+ \`-..-' (everything stays on your machine)
252
+ `);
253
+ if (args.open) openBrowser(url);
254
+ }
255
+
256
+ // --- v2: Fingerprint on shutdown ---
257
+ const computeAndSaveFingerprint = () => {
258
+ try {
259
+ const snap = aggregator.snapshot();
260
+ const sessionMinutes = (Date.now() - (snap._startTs || Date.now())) / 60000;
261
+ const fp = computeWhiteHatFingerprint({
262
+ sessionId: 'tui-session-' + snap.date,
263
+ host: 'claude-code',
264
+ durationMinutes: Math.max(1, sessionMinutes),
265
+ totalEvents: snap.toolCalls + snap.assistantMessages,
266
+ peakBurnPerMin: snap.burnRate || 0,
267
+ richness: currentRichness,
268
+ powerLevel: currentPower,
269
+ version: '0.2.0',
270
+ });
271
+ saveFingerprint(fp);
272
+ } catch {
273
+ // Non-fatal
274
+ }
275
+ };
276
+
277
+ // Fingerprint after 15 min idle
278
+ const idleCheck = setInterval(() => {
279
+ if (Date.now() - lastActivityTs > 15 * 60_000) {
280
+ computeAndSaveFingerprint();
281
+ lastActivityTs = Date.now(); // prevent re-triggering
282
+ }
283
+ }, 60_000);
284
+ idleCheck.unref?.();
285
+
286
+ const shutdown = () => {
287
+ stopTui?.();
288
+ computeAndSaveFingerprint();
289
+ aggregator.saveNow();
290
+ dbh?.close();
291
+ process.exit(0);
292
+ };
293
+ process.on('SIGINT', shutdown);
294
+ process.on('SIGTERM', shutdown);
295
+ }
296
+
297
+ main().catch((err) => {
298
+ console.error('claude-jar failed to start:', err.message);
299
+ process.exit(1);
300
+ });