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.
- package/LICENSE +21 -0
- package/MANUAL-SETUP.md +53 -0
- package/README.md +144 -0
- package/WHITE_HAT_RESEARCH.md +254 -0
- package/dist/web/app.js +32 -0
- package/dist/web/index.html +127 -0
- package/dist/web/styles.css +400 -0
- package/docs/screenshot.png +0 -0
- package/docs/tui-trophy.png +0 -0
- package/docs/web-trophy-canvas.png +0 -0
- package/mcp-server/dist/mcp-server.mjs +16 -0
- package/mcp-server/package.json +15 -0
- package/mcp-server/src/calibrator.js +138 -0
- package/mcp-server/src/db.js +272 -0
- package/mcp-server/src/environment-richness.js +83 -0
- package/mcp-server/src/fingerprint.js +79 -0
- package/mcp-server/src/harvest.js +496 -0
- package/mcp-server/src/hook-ingest.js +153 -0
- package/mcp-server/src/index.js +181 -0
- package/mcp-server/src/intensity.js +77 -0
- package/mcp-server/src/registration.js +184 -0
- package/mcp-server/src/uploader.js +64 -0
- package/package.json +59 -0
- package/scripts/add-log-safety-check.mjs +43 -0
- package/scripts/build-mcp-launcher.mjs +40 -0
- package/shared/types.js +84 -0
- package/src/aggregator.js +263 -0
- package/src/cli.js +300 -0
- package/src/eco.js +151 -0
- package/src/parse.js +86 -0
- package/src/server.js +162 -0
- package/src/statusline.js +71 -0
- package/src/tui.js +845 -0
- package/src/usage-api.js +250 -0
- package/src/watcher.js +104 -0
package/shared/types.js
ADDED
|
@@ -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
|
+
});
|