zopkit-bug-resolution-mcp 1.7.0 → 1.9.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/README.md CHANGED
@@ -189,3 +189,81 @@ Four more, independent of the fetch/resolve flow above:
189
189
  isocode`) to the company-admin panel — real project names, no full
190
190
  filesystem paths or usernames, even though the local scan reads the full
191
191
  path off the transcript.
192
+
193
+ ---
194
+
195
+ ## Usage reporting (v1.8.0)
196
+
197
+ Three reporters ship alongside the MCP server. They are plain commands
198
+ Claude Code spawns — a status line and two hooks — not MCP tools, because
199
+ what they report has to be captured at moments no tool call happens at.
200
+
201
+ ```bash
202
+ npx zopkit-bug-resolution-mcp --init-usage-reporting --api-key bugres_…
203
+ # then restart Claude Code
204
+ ```
205
+
206
+ That writes `~/.claude/.zopkit-usage.json` (mode 0600 — it holds your API
207
+ key) and adds a `statusLine` plus two hooks to `~/.claude/settings.json`.
208
+ It backs the file up first, never overwrites an existing `statusLine`, and
209
+ is idempotent.
210
+
211
+ ### What each one does
212
+
213
+ | | fires | reports |
214
+ |---|---|---|
215
+ | `bin/zopkit-statusline.js` | every assistant message (300ms debounce), `/compact`, session start/resume, rate-limit reset | per-session context occupancy, the account's 5h/7d limit percentages, session cost |
216
+ | `bin/zopkit-task-marker.js` | `PostToolUse` on `fetch_bug_context` / `submit_bug_resolution` | the session's cumulative tokens at each end of a task window |
217
+ | `bin/zopkit-session-end.js` | `SessionEnd` | that the session is over |
218
+
219
+ The status line is the interesting one. Claude Code hands it a JSON payload
220
+ containing `rate_limits.seven_day.used_percentage` — the number `/usage`
221
+ shows, which nothing server-side can fetch on your behalf. A status-line
222
+ script isn't a third party; it runs inside your own session. So that figure
223
+ no longer has to be typed in by a human (`report_claude_usage`, now legacy).
224
+
225
+ Two rules the status-line script follows, both load-bearing:
226
+
227
+ 1. **It renders before it touches the network.** Claude Code cancels an
228
+ in-flight status-line script when the next update arrives, so a slow
229
+ fetch ahead of the first write would blank the bar.
230
+ 2. **It whitelists fields, never forwards the payload.** The payload
231
+ carries more than this feature needs. `session_name` — your session's
232
+ AI-generated title, i.e. a description of what you're working on — is
233
+ sent **only** with `--share-session-name`, because it lands on a panel
234
+ every staff member with the `claude-usage` section can read.
235
+
236
+ ### Checking your Claude Code is new enough
237
+
238
+ These fields arrived across several releases, so "is my client new enough"
239
+ has a different answer per field:
240
+
241
+ ```bash
242
+ # capture one payload
243
+ cd /tmp && claude --settings '{"statusLine":{"type":"command","command":"tee /tmp/sl.json"}}'
244
+ # …let it render once, exit, then:
245
+ npx zopkit-bug-resolution-mcp --check-statusline < /tmp/sl.json
246
+ ```
247
+
248
+ It prints which fields are present and what goes empty without each.
249
+
250
+ ### Backfilling past bugs
251
+
252
+ The task markers are already in every transcript you've ever written, so
253
+ bugs you fixed before installing the hook can be attributed retroactively:
254
+
255
+ ```bash
256
+ npx zopkit-bug-resolution-mcp --backfill-tasks --dry-run
257
+ npx zopkit-bug-resolution-mcp --backfill-tasks
258
+ ```
259
+
260
+ Reconstruction is weaker than live markers and tags every window it writes
261
+ `backfill`, which the panel renders differently. Known imprecisions: a
262
+ window includes anything else you did inside it; an un-submitted window is
263
+ closed at the first 30-minute gap (without that, one ran 738 minutes and
264
+ swallowed its whole session); and only transcripts on this machine exist.
265
+
266
+ ### Turning it off
267
+
268
+ Delete `~/.claude/.zopkit-usage.json` — every reporter no-ops without it —
269
+ or remove the `statusLine` / `hooks` entries from `~/.claude/settings.json`.
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ // One-shot historical backfill of per-task token attribution.
3
+ //
4
+ // The PostToolUse marker hook only knows about tasks worked AFTER it is
5
+ // installed. But the markers it relies on — fetch_bug_context and
6
+ // submit_bug_resolution tool calls — are already sitting in every
7
+ // transcript ever written, so the same windows can be reconstructed
8
+ // retroactively.
9
+ //
10
+ // Reconstruction is strictly weaker than live markers, and says so: every
11
+ // window it writes is tagged attributionSource 'backfill', which the panel
12
+ // renders differently from a measured one. Three known imprecisions:
13
+ //
14
+ // - A window includes everything the developer did inside it. If they
15
+ // wandered off to an unrelated file, it is still charged to the bug.
16
+ // - An un-submitted window has no natural end, so it is closed at the
17
+ // first gap longer than IDLE_GAP_MS. Without that, one historical
18
+ // window ran 738 minutes and swallowed the rest of the session.
19
+ // - Only transcripts on THIS machine are visible. Work done elsewhere, or
20
+ // through the panel rather than the MCP tool, is simply absent — which
21
+ // is why coverage is reported at the end rather than left to assume.
22
+ //
23
+ // Usage:
24
+ // npx zopkit-bug-resolution-mcp --backfill-tasks [--days 90] [--dry-run]
25
+
26
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
27
+ import { createInterface } from 'node:readline';
28
+ import { join, basename } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+ import { loadConfig, post } from '../lib/zopkit-config.js';
31
+
32
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
33
+ const START_TOOL = 'mcp__bug-resolution__fetch_bug_context';
34
+ const END_TOOL = 'mcp__bug-resolution__submit_bug_resolution';
35
+ const SYNTHETIC_MODEL = '<synthetic>';
36
+
37
+ // An un-submitted window ends at the first gap longer than this. Picked to
38
+ // sit above a long single turn (a big refactor, a slow test run) and below
39
+ // a meal.
40
+ const IDLE_GAP_MS = 30 * 60 * 1000;
41
+
42
+ function emptyTotals() {
43
+ return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
44
+ }
45
+
46
+ function addUsage(byModel, model, usage) {
47
+ const t = (byModel[model] ??= emptyTotals());
48
+ t.inputTokens += usage.input_tokens || 0;
49
+ t.outputTokens += usage.output_tokens || 0;
50
+ t.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
51
+ t.cacheReadTokens += usage.cache_read_input_tokens || 0;
52
+ }
53
+
54
+ async function readEntries(filePath) {
55
+ const out = [];
56
+ let stream;
57
+ try {
58
+ stream = createReadStream(filePath, { encoding: 'utf8' });
59
+ } catch {
60
+ return out;
61
+ }
62
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
63
+ try {
64
+ for await (const line of rl) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ out.push(JSON.parse(line));
68
+ } catch {
69
+ // partial final line
70
+ }
71
+ }
72
+ } finally {
73
+ rl.close();
74
+ stream.destroy();
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function toolUses(entry) {
80
+ const c = entry?.message?.content;
81
+ return Array.isArray(c) ? c.filter((b) => b?.type === 'tool_use') : [];
82
+ }
83
+
84
+ /** Reconstruct every task window in one session transcript. */
85
+ async function windowsForSession(filePath, sinceMs) {
86
+ const entries = await readEntries(filePath);
87
+ if (entries.length === 0) return [];
88
+
89
+ const sessionId = basename(filePath, '.jsonl');
90
+ const windows = [];
91
+ let current = null;
92
+ let seen = new Set();
93
+ let lastTs = null;
94
+
95
+ const close = () => {
96
+ if (current && current.turns > 0) windows.push(current);
97
+ current = null;
98
+ seen = new Set();
99
+ };
100
+
101
+ const sorted = entries
102
+ .filter((e) => e.timestamp)
103
+ .sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
104
+
105
+ for (const entry of sorted) {
106
+ const ts = entry.timestamp;
107
+
108
+ if (entry.type === 'system' && entry.subtype === 'turn_duration' && current) {
109
+ current.activeMs = (current.activeMs ?? 0) + (entry.durationMs || 0);
110
+ continue;
111
+ }
112
+ if (entry.type !== 'assistant' || !entry.message) continue;
113
+
114
+ // An un-submitted window stops at the first long gap, rather than
115
+ // running to the end of the session.
116
+ if (current && !current.closed && lastTs && Date.parse(ts) - Date.parse(lastTs) > IDLE_GAP_MS) {
117
+ close();
118
+ }
119
+ lastTs = ts;
120
+
121
+ // Marker detection runs on every line, duplicates included — setting a
122
+ // boundary twice is harmless, unlike double-counting tokens.
123
+ for (const block of toolUses(entry)) {
124
+ if (block.name === START_TOOL && block.input?.bugReportId) {
125
+ close();
126
+ current = {
127
+ bugReportId: block.input.bugReportId,
128
+ sessionId,
129
+ startedAt: ts,
130
+ endedAt: ts,
131
+ byModel: {},
132
+ turns: 0,
133
+ subagentTurns: 0,
134
+ activeMs: null,
135
+ closed: false,
136
+ };
137
+ } else if (block.name === END_TOOL && current && block.input?.bugReportId === current.bugReportId) {
138
+ current.closed = true;
139
+ }
140
+ }
141
+
142
+ if (!current) continue;
143
+
144
+ const messageId = entry.message.id;
145
+ if (messageId) {
146
+ if (seen.has(messageId)) continue;
147
+ seen.add(messageId);
148
+ }
149
+ const usage = entry.message.usage;
150
+ if (!usage) continue;
151
+ const model = entry.message.model || 'unknown';
152
+ if (model === SYNTHETIC_MODEL) continue;
153
+
154
+ addUsage(current.byModel, model, usage);
155
+ current.turns += 1;
156
+ current.endedAt = ts;
157
+
158
+ if (current.closed) close();
159
+ }
160
+ close();
161
+
162
+ // Fold in subagent spend that falls inside a window. Subagent transcripts
163
+ // carry the parent sessionId, so a timestamp range is enough to attribute
164
+ // them — and omitting them undercounts a window that fanned out.
165
+ const subDir = join(filePath.replace(/\.jsonl$/, ''), 'subagents');
166
+ let subFiles = [];
167
+ try {
168
+ subFiles = readdirSync(subDir, { withFileTypes: true })
169
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
170
+ .map((e) => join(subDir, e.name));
171
+ } catch {
172
+ // none
173
+ }
174
+ for (const sub of subFiles) {
175
+ const subEntries = await readEntries(sub);
176
+ for (const w of windows) {
177
+ const subSeen = new Set();
178
+ for (const entry of subEntries) {
179
+ if (entry.type !== 'assistant' || !entry.message || !entry.timestamp) continue;
180
+ if (entry.timestamp < w.startedAt || entry.timestamp > w.endedAt) continue;
181
+ const id = entry.message.id;
182
+ if (id) {
183
+ if (subSeen.has(id)) continue;
184
+ subSeen.add(id);
185
+ }
186
+ const usage = entry.message.usage;
187
+ if (!usage) continue;
188
+ const model = entry.message.model || 'unknown';
189
+ if (model === SYNTHETIC_MODEL) continue;
190
+ addUsage(w.byModel, model, usage);
191
+ w.subagentTurns += 1;
192
+ }
193
+ }
194
+ }
195
+
196
+ return windows.filter((w) => Date.parse(w.endedAt) >= sinceMs);
197
+ }
198
+
199
+ function listSessionTranscripts() {
200
+ const files = [];
201
+ let dirs;
202
+ try {
203
+ dirs = readdirSync(PROJECTS_DIR, { withFileTypes: true });
204
+ } catch {
205
+ return files;
206
+ }
207
+ for (const d of dirs) {
208
+ if (!d.isDirectory()) continue;
209
+ const dirPath = join(PROJECTS_DIR, d.name);
210
+ let entries;
211
+ try {
212
+ entries = readdirSync(dirPath, { withFileTypes: true });
213
+ } catch {
214
+ continue;
215
+ }
216
+ for (const e of entries) {
217
+ if (e.isFile() && e.name.endsWith('.jsonl')) files.push(join(dirPath, e.name));
218
+ }
219
+ }
220
+ return files;
221
+ }
222
+
223
+ function fmt(n) {
224
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
225
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
226
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
227
+ return String(n);
228
+ }
229
+
230
+ export async function runBackfill({ days = 90, dryRun = false } = {}) {
231
+ const cfg = loadConfig();
232
+ if (!cfg && !dryRun) {
233
+ console.error('No API key — run `npx zopkit-bug-resolution-mcp --init-usage-reporting` first.');
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+
238
+ const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
239
+ const transcripts = listSessionTranscripts();
240
+ console.error(`Scanning ${transcripts.length} transcripts for task markers (last ${days} days)…`);
241
+
242
+ const all = [];
243
+ for (const file of transcripts) {
244
+ try {
245
+ // Cheap pre-filter: most transcripts never touched a bug tool.
246
+ if (statSync(file).size === 0) continue;
247
+ const found = await windowsForSession(file, sinceMs);
248
+ all.push(...found);
249
+ } catch {
250
+ // unreadable transcript — skip, don't abort the run
251
+ }
252
+ }
253
+
254
+ if (all.length === 0) {
255
+ console.error('No task windows found. Nothing to backfill.');
256
+ return;
257
+ }
258
+
259
+ const bugs = new Set(all.map((w) => w.bugReportId));
260
+ console.error(`\n${all.length} windows across ${bugs.size} bugs:\n`);
261
+ for (const w of all.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1)).slice(0, 20)) {
262
+ const newTokens = Object.values(w.byModel).reduce(
263
+ (s, t) => s + t.inputTokens + t.outputTokens + t.cacheCreationTokens,
264
+ 0
265
+ );
266
+ const cacheRead = Object.values(w.byModel).reduce((s, t) => s + t.cacheReadTokens, 0);
267
+ console.error(
268
+ ` ${w.bugReportId.slice(0, 8)} ${fmt(newTokens).padStart(8)} new ${fmt(cacheRead).padStart(8)} cr ` +
269
+ `${String(w.turns).padStart(3)} turns ${w.closed ? 'closed' : 'open '} ` +
270
+ `${w.activeMs === null ? ' —' : `${Math.round(w.activeMs / 60000)}m`}`
271
+ );
272
+ }
273
+ if (all.length > 20) console.error(` … and ${all.length - 20} more`);
274
+
275
+ if (dryRun) {
276
+ console.error('\n--dry-run: nothing posted.');
277
+ return;
278
+ }
279
+
280
+ let ok = 0;
281
+ let failed = 0;
282
+ for (const w of all) {
283
+ const res = await post('/api/admin/bug-reports/task-window', {
284
+ bugReportId: w.bugReportId,
285
+ sessionId: w.sessionId,
286
+ startedAt: w.startedAt,
287
+ // An un-submitted window has no real end; recording the last turn as
288
+ // one would claim the task finished there. It did not.
289
+ endedAt: w.closed ? w.endedAt : null,
290
+ activeMs: w.activeMs,
291
+ turns: w.turns,
292
+ subagentTurns: w.subagentTurns,
293
+ byModel: w.byModel,
294
+ attributionSource: 'backfill',
295
+ });
296
+ if (res?.success) ok += 1;
297
+ else failed += 1;
298
+ }
299
+
300
+ console.error(`\nPosted ${ok} windows${failed ? `, ${failed} failed` : ''}.`);
301
+ // Coverage, stated rather than assumed: a roll-up that silently omits
302
+ // most bugs reads as "those fixes were cheap".
303
+ console.error(
304
+ `Coverage: ${bugs.size} bugs have attribution from this machine. Bugs worked through the ` +
305
+ 'panel, on another machine, or without fetch_bug_context remain unattributed by design.'
306
+ );
307
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // SessionEnd hook — drop a finished session off the Claude Usage panel at
3
+ // once, instead of letting it age out of the live window.
4
+ //
5
+ // Best-effort by nature: a crashed terminal, a killed tmux pane or a closed
6
+ // laptop never fire SessionEnd. That is exactly why the server derives
7
+ // "live" from lastSeenAt rather than storing a flag — this hook makes the
8
+ // common case immediate, it is not what makes it correct.
9
+ //
10
+ // Wire-up (settings.json):
11
+ // "SessionEnd": [{ "hooks": [{ "type": "command", "command": "<this script>" }] }]
12
+
13
+ import { loadConfig, post, readStdinJson } from '../lib/zopkit-config.js';
14
+
15
+ async function main() {
16
+ const payload = await readStdinJson();
17
+ if (!payload?.session_id) return;
18
+ if (!loadConfig()) return;
19
+
20
+ await post(
21
+ '/api/admin/claude-usage/session-end',
22
+ { sessionId: payload.session_id, reason: payload.reason },
23
+ // SessionEnd hooks share a 1.5s budget by default, so this gets a
24
+ // tighter timeout than the rest: better to leave the row to age out
25
+ // than to hold up the editor's shutdown.
26
+ { timeoutMs: 1200 }
27
+ );
28
+ }
29
+
30
+ main().catch(() => {});
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // Status line + real-time usage heartbeat.
3
+ //
4
+ // Claude Code hands a statusLine command a JSON payload on stdin that
5
+ // already contains everything the Claude Usage panel was guessing at:
6
+ //
7
+ // context_window.{total_input_tokens,context_window_size,used_percentage}
8
+ // rate_limits.five_hour.used_percentage <- the /usage number
9
+ // rate_limits.seven_day.used_percentage <- ditto, the weekly one
10
+ // rate_limits.spend_limit.{used_usd,limit_usd}
11
+ // cost.total_cost_usd, model, session_id, workspace.repo, version, …
12
+ //
13
+ // That is the figure report_claude_usage was asking a human to read off
14
+ // `/usage` and type in. It is not retrievable by a third party — but a
15
+ // status-line script runs INSIDE the developer's own session, so it is not
16
+ // a third party. Nothing reported here is self-attested.
17
+ //
18
+ // It re-runs on every new assistant message, on /compact, on session
19
+ // start/resume, when a rate-limit window reaches its resets_at, and on an
20
+ // optional refreshInterval timer, debounced at 300ms. One clock per
21
+ // session, ticking independently — which is what makes concurrent sessions
22
+ // work, where the old single-row-per-developer design could not.
23
+ //
24
+ // Install: npx zopkit-bug-resolution-mcp --init-usage-reporting
25
+ //
26
+ // TWO RULES govern everything below:
27
+ //
28
+ // 1. RENDER BEFORE THE NETWORK. Claude Code cancels an in-flight status
29
+ // line script when the next update arrives. A slow fetch before the
30
+ // first write would blank the bar.
31
+ // 2. WHITELIST, NEVER FORWARD. The payload carries more than this feature
32
+ // needs (session titles, prompt-cache miss causes). Only the named
33
+ // fields below are sent, and the session title only on explicit opt-in.
34
+
35
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { homedir, tmpdir } from 'node:os';
38
+ import { loadConfig, post, readStdinJson, readClaudeAccount } from '../lib/zopkit-config.js';
39
+
40
+ // Per-session throttle. The status line fires on every assistant message; a
41
+ // 40-turn session would otherwise be 40 POSTs. 15s keeps the panel's 10s
42
+ // poll meaningfully fresh without that.
43
+ const THROTTLE_MS = 15_000;
44
+
45
+ const STATE_DIR = join(tmpdir(), 'zopkit-usage');
46
+
47
+ function statePath(sessionId) {
48
+ return join(STATE_DIR, `${String(sessionId).replace(/[^\w.-]/g, '_')}.json`);
49
+ }
50
+
51
+ function readState(sessionId) {
52
+ try {
53
+ return JSON.parse(readFileSync(statePath(sessionId), 'utf8'));
54
+ } catch {
55
+ return { lastSentAt: 0, lastContextTokens: null };
56
+ }
57
+ }
58
+
59
+ function writeState(sessionId, state) {
60
+ try {
61
+ mkdirSync(STATE_DIR, { recursive: true });
62
+ writeFileSync(statePath(sessionId), JSON.stringify(state));
63
+ } catch {
64
+ // A read-only tmpdir just means no throttling — still correct, only
65
+ // chattier. Never a reason to fail the status line.
66
+ }
67
+ }
68
+
69
+ function pct(n) {
70
+ return typeof n === 'number' && Number.isFinite(n) ? Math.floor(n) : null;
71
+ }
72
+
73
+ /** The line the developer actually sees. Kept deliberately plain — anyone
74
+ * who wants branch/git decoration can wrap this script. */
75
+ function render(p) {
76
+ const parts = [];
77
+ if (p.model?.display_name) parts.push(p.model.display_name);
78
+ const ctx = pct(p.context_window?.used_percentage);
79
+ if (ctx !== null) parts.push(`${ctx}% ctx`);
80
+ const week = pct(p.rate_limits?.seven_day?.used_percentage);
81
+ if (week !== null) parts.push(`${week}% wk`);
82
+ const fiveHour = pct(p.rate_limits?.five_hour?.used_percentage);
83
+ // Only worth the space once it is the binding constraint — the 5-hour
84
+ // window is what actually blocks you mid-afternoon.
85
+ if (fiveHour !== null && fiveHour >= 50) parts.push(`${fiveHour}% 5h`);
86
+ if (p.workspace?.git_worktree) parts.push(p.workspace.git_worktree);
87
+ return parts.join(' · ');
88
+ }
89
+
90
+ /** Only the named fields, never the raw payload. */
91
+ function buildHeartbeat(p, cfg, fresh) {
92
+ return {
93
+ sessionId: p.session_id,
94
+ sessionName: cfg.shareSessionName ? (p.session_name ?? null) : null,
95
+ model: p.model?.id ?? null,
96
+ version: p.version ?? null,
97
+
98
+ contextTokens: p.context_window?.total_input_tokens ?? null,
99
+ contextLimit: p.context_window?.context_window_size ?? null,
100
+ contextPercent: p.context_window?.used_percentage ?? null,
101
+ exceeds200k: p.exceeds_200k_tokens ?? null,
102
+
103
+ cwd: p.workspace?.current_dir ?? p.cwd ?? null,
104
+ repo: p.workspace?.repo?.name ?? null,
105
+ gitBranch: p.workspace?.git_worktree ?? null,
106
+
107
+ costUsd: p.cost?.total_cost_usd ?? null,
108
+ linesAdded: p.cost?.total_lines_added ?? null,
109
+ linesRemoved: p.cost?.total_lines_removed ?? null,
110
+ effort: p.effort?.level ?? null,
111
+ fastMode: p.fast_mode ?? null,
112
+
113
+ rateLimits: p.rate_limits ?? null,
114
+
115
+ // Which Anthropic account this session is signed in to. Read from
116
+ // ~/.claude.json rather than the payload, which does not carry it.
117
+ account: readClaudeAccount(),
118
+
119
+ // Whether a NEW API response landed since the last heartbeat for this
120
+ // session, detected by the context token count moving. Only a fresh
121
+ // reading may advance the account-level percentages: an idle session's
122
+ // status line still re-renders on a timer carrying `rate_limits` from
123
+ // its last API response, possibly an hour stale, and would otherwise
124
+ // walk a live session's number backwards.
125
+ fresh,
126
+ };
127
+ }
128
+
129
+ async function main() {
130
+ const payload = await readStdinJson();
131
+ if (!payload) return;
132
+
133
+ // RULE 1: the bar is written before anything can block.
134
+ process.stdout.write(render(payload));
135
+
136
+ const cfg = loadConfig();
137
+ if (!cfg || !payload.session_id) return;
138
+
139
+ const state = readState(payload.session_id);
140
+ const now = Date.now();
141
+ const contextTokens = payload.context_window?.total_input_tokens ?? null;
142
+ const fresh = contextTokens !== null && contextTokens !== state.lastContextTokens;
143
+
144
+ // Throttle, but never let a fresh reading wait: a developer watching
145
+ // their weekly percentage climb should see it move on the panel, not 15
146
+ // seconds later.
147
+ if (!fresh && now - (state.lastSentAt || 0) < THROTTLE_MS) return;
148
+
149
+ writeState(payload.session_id, { lastSentAt: now, lastContextTokens: contextTokens });
150
+ await post('/api/admin/claude-usage/heartbeat', buildHeartbeat(payload, cfg, fresh));
151
+ }
152
+
153
+ // Any unexpected throw must still leave the bar rendered and the editor
154
+ // unbothered.
155
+ main().catch(() => {});
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // Per-task token attribution — the PostToolUse hook.
3
+ //
4
+ // The two bug-resolution MCP tools already bracket a task precisely:
5
+ //
6
+ // fetch_bug_context "calling this IS starting work on it"
7
+ // (open / failed_testing -> in_progress)
8
+ // submit_bug_resolution ends it (-> needs_testing)
9
+ //
10
+ // So a PostToolUse hook matched on those two names knows exactly when a
11
+ // task starts and stops, with the session id, the bug id (in tool_input)
12
+ // and the transcript path all handed to it on stdin. It reads the session's
13
+ // cumulative token totals at each boundary and posts them; the SERVER
14
+ // subtracts. The delta is what the bug actually cost, per model.
15
+ //
16
+ // Why the server does the subtraction rather than this hook: a locally
17
+ // cached baseline is lost on reboot (the end marker would then have nothing
18
+ // to diff against), and keeping it server-side makes an in-flight window
19
+ // reportable — "being worked right now, 120k tokens in" — which a local
20
+ // diff cannot express until the task closes.
21
+ //
22
+ // Install: npx zopkit-bug-resolution-mcp --init-usage-reporting
23
+ //
24
+ // Wire-up (settings.json):
25
+ // "PostToolUse": [{
26
+ // "matcher": "mcp__bug-resolution__(fetch_bug_context|submit_bug_resolution)",
27
+ // "hooks": [{ "type": "command", "command": "<this script>" }]
28
+ // }]
29
+
30
+ import { loadConfig, post, readStdinJson } from '../lib/zopkit-config.js';
31
+ import { scanSessionCumulative, EMPTY_CUMULATIVE } from '../lib/session-scan.js';
32
+
33
+ const START_TOOL = 'fetch_bug_context';
34
+ const END_TOOL = 'submit_bug_resolution';
35
+
36
+ /** Which end of a window this tool call represents, or null if the hook
37
+ * fired for something else (a broader matcher than the documented one). */
38
+ function phaseFor(toolName) {
39
+ if (!toolName) return null;
40
+ if (toolName.endsWith(START_TOOL)) return 'start';
41
+ if (toolName.endsWith(END_TOOL)) return 'end';
42
+ return null;
43
+ }
44
+
45
+ async function main() {
46
+ const payload = await readStdinJson();
47
+ if (!payload) return;
48
+
49
+ const cfg = loadConfig();
50
+ if (!cfg) return;
51
+
52
+ const phase = phaseFor(payload.tool_name);
53
+ if (!phase) return;
54
+
55
+ const bugReportId = payload.tool_input?.bugReportId;
56
+ // fetch_assigned_bugs and create_bug_report carry no bugReportId; if a
57
+ // broader matcher routed one here there is simply no task to bracket.
58
+ if (!bugReportId || !payload.session_id) return;
59
+
60
+ // PostToolUse fires AFTER the tool succeeded, so by now the transcript
61
+ // already contains this turn — the reading includes the work up to and
62
+ // including the marker itself, consistently at both ends, which is what
63
+ // makes the subtraction clean.
64
+ const cumulative = payload.transcript_path
65
+ ? await scanSessionCumulative(payload.transcript_path).catch(() => EMPTY_CUMULATIVE)
66
+ : EMPTY_CUMULATIVE;
67
+
68
+ await post('/api/admin/bug-reports/task-marker', {
69
+ bugReportId,
70
+ sessionId: payload.session_id,
71
+ phase,
72
+ // The client's clock, not the server's: a hook can be retried or
73
+ // queued, and a window's boundaries must match the transcript they
74
+ // were read from.
75
+ observedAt: new Date().toISOString(),
76
+ byModel: cumulative.byModel,
77
+ turns: cumulative.turns,
78
+ subagentTurns: cumulative.subagentTurns,
79
+ activeMs: cumulative.activeMs,
80
+ });
81
+ }
82
+
83
+ // A hook that throws surfaces an error to the developer about telemetry
84
+ // they did not ask to think about. Never that.
85
+ main().catch(() => {});
package/index.js CHANGED
@@ -44,6 +44,45 @@ import { createInterface } from 'node:readline';
44
44
  import { join, basename } from 'node:path';
45
45
  import { homedir } from 'node:os';
46
46
 
47
+ // ── CLI subcommands ───────────────────────────────────────────────────────
48
+ // Handled BEFORE the server is constructed (and before the API-key guard
49
+ // below), since --init-usage-reporting exists precisely to configure a
50
+ // machine that has no key yet. Without a subcommand this falls straight
51
+ // through to the stdio MCP server, which is the normal path.
52
+ const argv = process.argv.slice(2);
53
+ function flag(name) {
54
+ const i = argv.indexOf(`--${name}`);
55
+ return i === -1 ? undefined : (argv[i + 1]?.startsWith('--') ? true : argv[i + 1] ?? true);
56
+ }
57
+
58
+ if (argv.includes('--init-usage-reporting')) {
59
+ const { initUsageReporting } = await import('./lib/init-usage-reporting.js');
60
+ process.exit(
61
+ initUsageReporting({
62
+ apiKey: typeof flag('api-key') === 'string' ? flag('api-key') : undefined,
63
+ baseUrl: typeof flag('base-url') === 'string' ? flag('base-url') : undefined,
64
+ shareSessionName: argv.includes('--share-session-name'),
65
+ })
66
+ );
67
+ }
68
+
69
+ if (argv.includes('--check-statusline')) {
70
+ const { checkStatusline } = await import('./lib/init-usage-reporting.js');
71
+ const chunks = [];
72
+ for await (const chunk of process.stdin) chunks.push(chunk);
73
+ process.exit(await checkStatusline(Buffer.concat(chunks).toString('utf8')));
74
+ }
75
+
76
+ if (argv.includes('--backfill-tasks')) {
77
+ const { runBackfill } = await import('./bin/zopkit-backfill-tasks.js');
78
+ const days = Number(flag('days'));
79
+ await runBackfill({
80
+ days: Number.isFinite(days) && days > 0 ? days : 90,
81
+ dryRun: argv.includes('--dry-run'),
82
+ });
83
+ process.exit(process.exitCode ?? 0);
84
+ }
85
+
47
86
  const API_KEY = process.env.BUG_REPORT_API_KEY;
48
87
  const BASE_URL = (process.env.BUG_REPORT_API_BASE_URL || 'https://api.zopkit.com').replace(/\/$/, '');
49
88
 
@@ -52,7 +91,7 @@ if (!API_KEY) {
52
91
  'Generate one from the company-admin panel (Bug Reports → API Keys) and set it when registering this server.');
53
92
  }
54
93
 
55
- const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.7.0' });
94
+ const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.9.0' });
56
95
 
57
96
  const BUG_SEVERITIES = ['low', 'medium', 'high', 'critical'];
58
97
  // Keep in sync with BUG_STATUSES in backend/src/db/schema/platform/bug-reports.ts.
@@ -100,18 +139,28 @@ const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
100
139
  // weeklySessionsWithWebSearch; missing this half undercounted it before.
101
140
  const WEB_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
102
141
 
103
- // Context-window ceiling per model family — used only to turn
104
- // currentContextTokens into a percentage the panel can render as a bar.
105
- // Heuristic, not queried from Anthropic (there's no local API for "is this
106
- // account's extended-context beta on"): Sonnet ships with a 1M-token beta
107
- // context window in Claude Code; Opus/Haiku are the standard 200k. Falls
108
- // back to 200k for anything unrecognized — an undercount of the true limit
109
- // is the safe failure direction (a 1M-context Opus session would just show
110
- // as "over 100%", visibly wrong and prompting a fix, not silently hidden).
142
+ // Context-window ceiling per model, used only to turn currentContextTokens
143
+ // into a percentage. A GUESS, and unavoidably so: the transcript records no
144
+ // window size (only context_management), so unlike the status-line path —
145
+ // which is handed context_window.context_window_size by Claude Code itself
146
+ // — this has to infer it.
147
+ //
148
+ // The previous rule was "sonnet => 1M, everything else => 200k", which put
149
+ // every Opus 5 session at a fifth of its real ceiling: a session the status
150
+ // line measured at 626k/1.0M (63%) showed here as 626k/200k, pinned red at
151
+ // 100%. The 5-series ships the 1M window; 4.x is 200k.
152
+ //
153
+ // Note the version is parsed positionally, not by substring: the family
154
+ // version is the segment right after the name, so claude-haiku-4-5-20251001
155
+ // is a 4-series despite containing "-5-".
111
156
  function contextLimitForModel(model) {
112
157
  if (!model) return 200_000;
113
- if (model.includes('sonnet')) return 1_000_000;
114
- return 200_000;
158
+ const [, version] = model.replace(/^claude-/, '').split('-');
159
+ const major = Number.parseInt(version, 10);
160
+ // Unrecognised => 200k. Undercounting the ceiling shows a session as
161
+ // fuller than it is, which is visibly wrong and gets reported; guessing
162
+ // high would quietly hide a session that is genuinely about to run out.
163
+ return Number.isFinite(major) && major >= 5 ? 1_000_000 : 200_000;
115
164
  }
116
165
 
117
166
  /**
@@ -0,0 +1,197 @@
1
+ // `--init-usage-reporting` — wire the status line and the two hooks into
2
+ // Claude Code, and write the credentials they need.
3
+ //
4
+ // Done as an explicit opt-in command rather than silently on install: this
5
+ // edits the developer's own ~/.claude/settings.json and turns on reporting
6
+ // to a shared team panel. Both deserve a deliberate action.
7
+ //
8
+ // It is conservative about what it touches:
9
+ // - never overwrites an existing statusLine (prints what to do instead),
10
+ // - appends its hooks rather than replacing the arrays,
11
+ // - is idempotent: running it twice changes nothing the second time,
12
+ // - backs the file up before writing.
13
+
14
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { CONFIG_PATH } from './zopkit-config.js';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ const BIN = join(HERE, '..', 'bin');
22
+
23
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
24
+
25
+ const STATUSLINE_CMD = `node ${join(BIN, 'zopkit-statusline.js')}`;
26
+ const MARKER_CMD = `node ${join(BIN, 'zopkit-task-marker.js')}`;
27
+ const SESSION_END_CMD = `node ${join(BIN, 'zopkit-session-end.js')}`;
28
+
29
+ // The matcher the task marker relies on. Narrow on purpose — a broader one
30
+ // would spawn a node process on every tool call in the session.
31
+ const MARKER_MATCHER = 'mcp__bug-resolution__(fetch_bug_context|submit_bug_resolution)';
32
+
33
+ function readJson(path, fallback) {
34
+ try {
35
+ return JSON.parse(readFileSync(path, 'utf8'));
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ }
40
+
41
+ /** Already present? Compare on the command string, so re-running after an
42
+ * upgrade (same path) is a no-op rather than a duplicate. */
43
+ function hasHook(entries, command) {
44
+ return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
45
+ }
46
+
47
+ export function initUsageReporting({ apiKey, baseUrl, shareSessionName = false } = {}) {
48
+ const log = (...a) => console.error(...a);
49
+
50
+ // ── 1. credentials ──────────────────────────────────────────────────
51
+ const key = apiKey || process.env.BUG_REPORT_API_KEY;
52
+ if (!key) {
53
+ log('No API key.');
54
+ log(' Generate one in the company-admin panel (Bug Reports → API Keys), then:');
55
+ log(' npx zopkit-bug-resolution-mcp --init-usage-reporting --api-key bugres_…');
56
+ log(' …or export BUG_REPORT_API_KEY and re-run.');
57
+ return 1;
58
+ }
59
+
60
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
61
+ const existingCfg = readJson(CONFIG_PATH, {});
62
+ writeFileSync(
63
+ CONFIG_PATH,
64
+ JSON.stringify(
65
+ {
66
+ ...existingCfg,
67
+ apiKey: key,
68
+ baseUrl: baseUrl || existingCfg.baseUrl || 'https://api.zopkit.com',
69
+ // Opt-in: the session title describes what you are working on, and
70
+ // it lands on a panel the whole staff can read.
71
+ shareSessionName: shareSessionName || existingCfg.shareSessionName || false,
72
+ },
73
+ null,
74
+ 2
75
+ ) + '\n',
76
+ // The key is a credential; keep it off other accounts on the machine.
77
+ { mode: 0o600 }
78
+ );
79
+ log(`✓ credentials → ${CONFIG_PATH}`);
80
+
81
+ for (const f of ['zopkit-statusline.js', 'zopkit-task-marker.js', 'zopkit-session-end.js']) {
82
+ try {
83
+ chmodSync(join(BIN, f), 0o755);
84
+ } catch {
85
+ // bundled read-only (npx cache) — `node <path>` still works, and that
86
+ // is the form written into settings.json anyway.
87
+ }
88
+ }
89
+
90
+ // ── 2. settings.json ────────────────────────────────────────────────
91
+ const settings = readJson(SETTINGS_PATH, {});
92
+ if (existsSync(SETTINGS_PATH)) {
93
+ copyFileSync(SETTINGS_PATH, `${SETTINGS_PATH}.zopkit-backup`);
94
+ log(`✓ backed up → ${SETTINGS_PATH}.zopkit-backup`);
95
+ }
96
+
97
+ let changed = false;
98
+
99
+ if (!settings.statusLine) {
100
+ settings.statusLine = { type: 'command', command: STATUSLINE_CMD };
101
+ changed = true;
102
+ log('✓ statusLine configured');
103
+ } else if (settings.statusLine.command === STATUSLINE_CMD) {
104
+ log('· statusLine already configured');
105
+ } else {
106
+ // Never clobber: a custom status line is work the developer did, and
107
+ // theirs is probably nicer than ours.
108
+ log('! statusLine already set to something else — left alone.');
109
+ log(' To keep yours AND report usage, call ours from inside it (it reads stdin and');
110
+ log(' writes one line), or pipe the payload through:');
111
+ log(` ${STATUSLINE_CMD}`);
112
+ }
113
+
114
+ settings.hooks ??= {};
115
+
116
+ settings.hooks.PostToolUse ??= [];
117
+ if (!hasHook(settings.hooks.PostToolUse, MARKER_CMD)) {
118
+ settings.hooks.PostToolUse.push({
119
+ matcher: MARKER_MATCHER,
120
+ hooks: [{ type: 'command', command: MARKER_CMD }],
121
+ });
122
+ changed = true;
123
+ log('✓ PostToolUse task-marker hook added');
124
+ } else {
125
+ log('· task-marker hook already present');
126
+ }
127
+
128
+ settings.hooks.SessionEnd ??= [];
129
+ if (!hasHook(settings.hooks.SessionEnd, SESSION_END_CMD)) {
130
+ settings.hooks.SessionEnd.push({ hooks: [{ type: 'command', command: SESSION_END_CMD }] });
131
+ changed = true;
132
+ log('✓ SessionEnd hook added');
133
+ } else {
134
+ log('· SessionEnd hook already present');
135
+ }
136
+
137
+ if (changed) {
138
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
139
+ log(`✓ wrote ${SETTINGS_PATH}`);
140
+ }
141
+
142
+ log('');
143
+ log('Restart Claude Code for the status line and hooks to take effect.');
144
+ log('');
145
+ log('Then, to attribute bugs you already fixed on this machine:');
146
+ log(' npx zopkit-bug-resolution-mcp --backfill-tasks --dry-run');
147
+ log('');
148
+ log('Note: the account-level percentages come from rate_limits in the status-line');
149
+ log('payload. If your Claude Code predates that field the panel still shows context');
150
+ log('and per-session cost; it just leaves the 5h/7d bars empty. Check with:');
151
+ log(' npx zopkit-bug-resolution-mcp --check-statusline');
152
+ return 0;
153
+ }
154
+
155
+ /**
156
+ * `--check-statusline` — print exactly which of the fields this feature
157
+ * depends on the local Claude Code actually emits.
158
+ *
159
+ * Worth having as a command rather than a docs note: the fields arrived in
160
+ * different releases, so "is my client new enough" has a different answer
161
+ * per field, and guessing wrong looks identical to a broken install.
162
+ */
163
+ export async function checkStatusline(payloadText) {
164
+ let p;
165
+ try {
166
+ p = JSON.parse(payloadText);
167
+ } catch {
168
+ console.error('Pipe a status-line payload in. Easiest way to capture one:');
169
+ console.error('');
170
+ console.error(' cd /tmp && claude --settings \'{"statusLine":{"type":"command",' +
171
+ '"command":"tee /tmp/zopkit-sl.json"}}\'');
172
+ console.error(' # let it render once, exit, then:');
173
+ console.error(' npx zopkit-bug-resolution-mcp --check-statusline < /tmp/zopkit-sl.json');
174
+ return 1;
175
+ }
176
+
177
+ const checks = [
178
+ ['session_id', p.session_id, 'required — identifies the session'],
179
+ ['context_window.used_percentage', p.context_window?.used_percentage, 'the per-session context bar'],
180
+ ['context_window.context_window_size', p.context_window?.context_window_size, 'the bar\'s denominator'],
181
+ ['cost.total_cost_usd', p.cost?.total_cost_usd, 'per-session cost'],
182
+ ['rate_limits.five_hour.used_percentage', p.rate_limits?.five_hour?.used_percentage, 'the 5-hour bar'],
183
+ ['rate_limits.seven_day.used_percentage', p.rate_limits?.seven_day?.used_percentage, 'the weekly % — replaces self-reporting'],
184
+ ['rate_limits.spend_limit.used_usd', p.rate_limits?.spend_limit?.used_usd, 'gateway spend (needs CC >= 2.1.284)'],
185
+ ];
186
+
187
+ console.error(`Claude Code ${p.version ?? '(version not reported)'}\n`);
188
+ let missing = 0;
189
+ for (const [field, value, why] of checks) {
190
+ const present = value !== undefined && value !== null;
191
+ if (!present) missing += 1;
192
+ console.error(` ${present ? '✓' : '✗'} ${field.padEnd(40)} ${present ? '' : `— absent; ${why} will be empty`}`);
193
+ }
194
+ console.error('');
195
+ console.error(missing === 0 ? 'Everything this feature uses is available.' : `${missing} field(s) absent — upgrade Claude Code to fill them in.`);
196
+ return 0;
197
+ }
@@ -0,0 +1,150 @@
1
+ // Cumulative token accounting for ONE Claude Code session.
2
+ //
3
+ // index.js's computeWeeklyTokenBreakdown answers "how much did this
4
+ // developer spend this week", scanning every transcript in a time window.
5
+ // This answers a different question — "how much has THIS session spent so
6
+ // far, in total" — which is what task attribution is built on: the marker
7
+ // hook reads this at each boundary and the server subtracts, so the delta
8
+ // between fetch_bug_context and submit_bug_resolution is the bug's real cost.
9
+ //
10
+ // Deliberately NOT time-windowed. A window boundary inside a session would
11
+ // make the two readings incomparable, and the subtraction is what makes the
12
+ // time filter unnecessary anyway.
13
+
14
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
15
+ import { createInterface } from 'node:readline';
16
+ import { join, dirname, basename } from 'node:path';
17
+
18
+ /** Models Claude Code logs for its own synthetic messages (rate-limit
19
+ * notices, interrupts). They carry a zero usage block and no real spend. */
20
+ const SYNTHETIC_MODEL = '<synthetic>';
21
+
22
+ function emptyTotals() {
23
+ return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
24
+ }
25
+
26
+ function addUsage(byModel, model, usage) {
27
+ const t = (byModel[model] ??= emptyTotals());
28
+ t.inputTokens += usage.input_tokens || 0;
29
+ t.outputTokens += usage.output_tokens || 0;
30
+ t.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
31
+ t.cacheReadTokens += usage.cache_read_input_tokens || 0;
32
+ }
33
+
34
+ /**
35
+ * Subagent transcripts for a session live in a nested directory a flat
36
+ * listing never finds: …/<projectDir>/<sessionId>/subagents/*.jsonl. Their
37
+ * spend is real and belongs to whatever task was running at the time, so a
38
+ * task window that ignored them would undercount — sometimes by a third.
39
+ */
40
+ function subagentFiles(transcriptPath) {
41
+ const dir = join(dirname(transcriptPath), basename(transcriptPath, '.jsonl'), 'subagents');
42
+ try {
43
+ return readdirSync(dir, { withFileTypes: true })
44
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
45
+ .map((e) => join(dir, e.name));
46
+ } catch {
47
+ return []; // no subagents for this session — the common case
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Stream one transcript, folding its assistant turns into `acc`.
53
+ *
54
+ * Streams line-by-line rather than readFileSync+split: a long session's
55
+ * transcript runs to hundreds of MB, and this is called from a hook on the
56
+ * critical path of a tool call.
57
+ */
58
+ async function foldTranscript(filePath, acc, { isSubagent }) {
59
+ let stream;
60
+ try {
61
+ stream = createReadStream(filePath, { encoding: 'utf8' });
62
+ } catch {
63
+ return;
64
+ }
65
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
66
+ try {
67
+ for await (const line of rl) {
68
+ if (!line.trim()) continue;
69
+ let entry;
70
+ try {
71
+ entry = JSON.parse(line);
72
+ } catch {
73
+ continue; // a partially-flushed last line, not a reason to abandon the file
74
+ }
75
+
76
+ // Claude Code's own measure of time actually spent on a turn. Summed
77
+ // instead of using wall-clock, which measures when the developer went
78
+ // to lunch: one historical window read as 738 minutes of "work" that
79
+ // was really 36. Main thread only — a subagent's duration overlaps
80
+ // its parent's and would double-count.
81
+ if (!isSubagent && entry.type === 'system' && entry.subtype === 'turn_duration') {
82
+ acc.activeMs = (acc.activeMs ?? 0) + (entry.durationMs || 0);
83
+ continue;
84
+ }
85
+
86
+ if (entry.type !== 'assistant' || !entry.message) continue;
87
+
88
+ // Streaming writes the same final usage numbers 2-3x per real
89
+ // response (once per content-block flush), so totals would be
90
+ // inflated 2-3x without this.
91
+ const messageId = entry.message.id;
92
+ if (messageId) {
93
+ if (acc._seen.has(messageId)) continue;
94
+ acc._seen.add(messageId);
95
+ }
96
+
97
+ const usage = entry.message.usage;
98
+ if (!usage) continue;
99
+ const model = entry.message.model || 'unknown';
100
+ if (model === SYNTHETIC_MODEL) continue;
101
+
102
+ addUsage(acc.byModel, model, usage);
103
+ if (isSubagent) acc.subagentTurns += 1;
104
+ else acc.turns += 1;
105
+ }
106
+ } finally {
107
+ rl.close();
108
+ stream.destroy();
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Everything this session has spent so far.
114
+ *
115
+ * Subagent spend is folded INTO byModel (so the totals are complete) and
116
+ * also counted separately as subagentTurns, matching the convention the
117
+ * weekly breakdown already uses.
118
+ *
119
+ * activeMs stays null when the transcript has no turn_duration records at
120
+ * all — older Claude Code versions don't write them, and "unknown" must not
121
+ * collapse into "zero minutes of work".
122
+ */
123
+ export async function scanSessionCumulative(transcriptPath) {
124
+ const acc = {
125
+ byModel: {},
126
+ turns: 0,
127
+ subagentTurns: 0,
128
+ activeMs: null,
129
+ _seen: new Set(),
130
+ };
131
+ try {
132
+ statSync(transcriptPath);
133
+ } catch {
134
+ return { byModel: {}, turns: 0, subagentTurns: 0, activeMs: null };
135
+ }
136
+ await foldTranscript(transcriptPath, acc, { isSubagent: false });
137
+ for (const sub of subagentFiles(transcriptPath)) {
138
+ await foldTranscript(sub, acc, { isSubagent: true });
139
+ }
140
+ return {
141
+ byModel: acc.byModel,
142
+ turns: acc.turns,
143
+ subagentTurns: acc.subagentTurns,
144
+ activeMs: acc.activeMs,
145
+ };
146
+ }
147
+
148
+ /** Round-trip-safe empty reading, for a session whose transcript cannot be
149
+ * read at all (the marker still records a window; it just measures zero). */
150
+ export const EMPTY_CUMULATIVE = { byModel: {}, turns: 0, subagentTurns: 0, activeMs: null };
@@ -0,0 +1,133 @@
1
+ // Shared config for the hook/status-line binaries (bin/*.js).
2
+ //
3
+ // The MCP server itself gets BUG_REPORT_API_KEY from the `env` block of the
4
+ // mcpServers entry in ~/.claude.json. Hooks and the status line do NOT —
5
+ // they are plain commands Claude Code spawns, inheriting only the ordinary
6
+ // shell environment, which does not have that key.
7
+ //
8
+ // So they read a small config file instead, and fall back to the env var for
9
+ // anyone who does export it globally. The file lives outside the repo on
10
+ // purpose: an API key in settings.json is one `git add` away from being
11
+ // committed.
12
+ //
13
+ // ~/.claude/.zopkit-usage.json
14
+ // { "apiKey": "bugres_…", "baseUrl": "https://api.zopkit.com" }
15
+ //
16
+ // Create it with: npx zopkit-bug-resolution-mcp --init-usage-reporting
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+
22
+ export const CONFIG_PATH = join(homedir(), '.claude', '.zopkit-usage.json');
23
+
24
+ /**
25
+ * Which Anthropic account this machine is signed in to.
26
+ *
27
+ * Read from ~/.claude.json's oauthAccount block — the same file Claude
28
+ * Code keeps its own settings in. Nothing here is a credential: the uuid
29
+ * is an identifier, and the email/org are what the account calls itself.
30
+ *
31
+ * It matters because RATE LIMITS ARE PER ACCOUNT. The wrapper staff member
32
+ * reading the panel and the Claude account being billed need not be the
33
+ * same identity (on the machine this was written, they are not), and if
34
+ * two developers share one account then "this developer is at 70%" is the
35
+ * wrong sentence — the account is, and the token split says who drove it.
36
+ *
37
+ * Cached for the process: a status line runs on every assistant message
38
+ * and this file is ~MBs, so re-reading it per render would be the most
39
+ * expensive thing the reporter does.
40
+ */
41
+ let accountCache;
42
+ export function readClaudeAccount() {
43
+ if (accountCache !== undefined) return accountCache;
44
+ try {
45
+ const raw = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
46
+ const a = raw?.oauthAccount;
47
+ accountCache = a?.accountUuid
48
+ ? {
49
+ uuid: a.accountUuid,
50
+ email: a.emailAddress ?? null,
51
+ orgName: a.organizationName ?? null,
52
+ // The rate-limit TIER, not the seat: it is what explains why two
53
+ // accounts have different ceilings, so a high percentage on a
54
+ // small plan is not read as the same signal as one on a 20x.
55
+ plan: a.organizationRateLimitTier ?? a.organizationType ?? null,
56
+ }
57
+ : null;
58
+ } catch {
59
+ accountCache = null; // never signed in, or an unreadable file
60
+ }
61
+ return accountCache;
62
+ }
63
+
64
+ let cached;
65
+
66
+ export function loadConfig() {
67
+ if (cached !== undefined) return cached;
68
+ let file = {};
69
+ try {
70
+ file = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
71
+ } catch {
72
+ // Missing or unreadable is the normal case before setup — not an error
73
+ // worth printing, since these binaries run on every status-line render
74
+ // and must never write noise into a developer's terminal.
75
+ }
76
+ const apiKey = file.apiKey || process.env.BUG_REPORT_API_KEY || null;
77
+ const baseUrl = (file.baseUrl || process.env.BUG_REPORT_API_BASE_URL || 'https://api.zopkit.com').replace(/\/$/, '');
78
+ // Opt-in, default off. The session title describes what a developer is
79
+ // working on, which is a step beyond the cwd this feature already
80
+ // collects, and it lands on a panel every staff member with the
81
+ // 'claude-usage' section can read.
82
+ const shareSessionName = file.shareSessionName === true;
83
+ cached = apiKey ? { apiKey, baseUrl, shareSessionName } : null;
84
+ return cached;
85
+ }
86
+
87
+ /**
88
+ * POST, with a hard timeout and every failure swallowed.
89
+ *
90
+ * Nothing these binaries report is worth degrading the editor over: a status
91
+ * line that hangs on a network call blanks the bar, and a hook that throws
92
+ * surfaces an error to the developer about telemetry they did not ask to
93
+ * think about. The next heartbeat is seconds away, so there is nothing to
94
+ * retry either.
95
+ */
96
+ export async function post(path, body, { timeoutMs = 5000 } = {}) {
97
+ const cfg = loadConfig();
98
+ if (!cfg) return null;
99
+ const controller = new AbortController();
100
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
101
+ try {
102
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': cfg.apiKey },
105
+ body: JSON.stringify(body),
106
+ signal: controller.signal,
107
+ });
108
+ if (!res.ok) return null;
109
+ return await res.json().catch(() => null);
110
+ } catch {
111
+ return null;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+
117
+ /** Read all of stdin as text. Claude Code hands hooks and the status line
118
+ * their JSON payload this way. */
119
+ export async function readStdin() {
120
+ const chunks = [];
121
+ for await (const chunk of process.stdin) chunks.push(chunk);
122
+ return Buffer.concat(chunks).toString('utf8');
123
+ }
124
+
125
+ /** Parse the stdin payload, returning null rather than throwing — a
126
+ * malformed payload must not take the status line down with it. */
127
+ export async function readStdinJson() {
128
+ try {
129
+ return JSON.parse(await readStdin());
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "zopkit-bug-resolution-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
- "description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, report your Claude Code usage status, and report a real token/model/context-window breakdown (including subagent spend) from your local transcripts. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
5
+ "description": "MCP server + status-line/hook reporters for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, and report REAL Claude Code usage \u2014 live per-session context and account rate limits via the status line, plus per-bug token attribution via PostToolUse hooks. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) \u2014 see README.md.",
6
6
  "license": "UNLICENSED",
7
7
  "engines": {
8
8
  "node": ">=18"
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "index.js",
16
- "README.md"
16
+ "README.md",
17
+ "bin/",
18
+ "lib/"
17
19
  ],
18
20
  "scripts": {
19
21
  "start": "node index.js"