zopkit-bug-resolution-mcp 1.6.0 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +37 -21
  2. package/index.js +303 -95
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -27,18 +27,24 @@ usage status — instead of hand-rolling curl commands:
27
27
  company-admin panel's "Claude Usage" tab, via `POST /api/admin/claude-usage`.
28
28
  - **`report_claude_token_usage`** — unlike the one above, this one is NOT
29
29
  self-attested: it scans this developer's own local Claude Code transcripts
30
- (`~/.claude/projects/**/*.jsonl`, same machine the MCP server runs on — no
31
- `/usage`, no status line, no asking a human) for the last 7 days, dedupes
32
- streamed duplicates by `message.id`, and reports real input/output/
33
- cache-creation/cache-read token totals, session count, and whether any
34
- sessions used web search or spawned subagents, via
30
+ (`~/.claude/projects/**/*.jsonl` AND their nested `.../subagents/*.jsonl` —
31
+ same machine the MCP server runs on, no `/usage`, no status line, no
32
+ asking a human) for a configurable window (`days`, default 7), streamed
33
+ line-by-line and cached per-file so repeat calls in one session are
34
+ near-instant. Dedupes streamed duplicates by `message.id` and reports real
35
+ input/output/cache-creation/cache-read token totals (subagent spend folded
36
+ in, not just detected), a per-model split, how full the most recently
37
+ active session's context window currently is, session count, and whether
38
+ any sessions used web search or spawned subagents — via
35
39
  `POST /api/admin/claude-usage/token-breakdown`. Also runs automatically,
36
- fire-and-forget, as a side effect of the four bug-report tools above
37
- (`submit_bug_resolution`, `fetch_bug_context`, `fetch_assigned_bugs`,
38
- `create_bug_report`) on success — so a developer who never touches this
39
- tool directly still gets a token breakdown reported just by doing normal
40
- bug-report work. Failures from the auto-report path are swallowed
41
- entirely; they never surface as a tool error.
40
+ genuinely non-blocking (never awaited), as a side effect of the four
41
+ bug-report tools above (`submit_bug_resolution`, `fetch_bug_context`,
42
+ `fetch_assigned_bugs`, `create_bug_report`) on success — so a developer
43
+ who never touches this tool directly still gets a token breakdown
44
+ reported just by doing normal bug-report work, with zero added latency on
45
+ those calls. Failures from the auto-report path are swallowed entirely;
46
+ they never surface as a tool error, and (since it isn't awaited) never
47
+ delay the real response either.
42
48
 
43
49
  All six are thin wrappers — see `backend/src/features/bug-reports/README.md`
44
50
  and `backend/src/features/claude-usage/README.md` for what each endpoint
@@ -150,16 +156,21 @@ Four more, independent of the fetch/resolve flow above:
150
156
  `/usage`, the status line, or the account's billing page — there's no way
151
157
  for the tool to look this up itself) to update the "Claude Usage" tab in
152
158
  the company-admin panel. Pass an optional `note` for context (e.g. "Team
153
- plan, resets Monday").
154
- - Ask it to call `report_claude_token_usage` (no input needed) to push a
155
- real token/session breakdown for the last 7 days up to the same "Claude
156
- Usage" tab — distinct from and independent of `report_claude_usage`
157
- above. You usually don't need to call this directly: it already fires
158
- automatically after every successful `fetch_bug_context`,
159
- `submit_bug_resolution`, `fetch_assigned_bugs`, or `create_bug_report`
160
- call, and a short note gets appended to that tool's own response telling
161
- you it happened (e.g. "Also updated your Claude token usage: 2.1M new
162
- tokens across 3 session(s) this week.").
159
+ plan, resets Monday"), and an optional `resetsAt` (ISO timestamp) whenever
160
+ you can see a concrete reset time (e.g. in the status line's rate-limit
161
+ JSON) — this is what lets the panel show real staleness instead of a
162
+ human having to parse a reset time out of free-text `note`.
163
+ - Ask it to call `report_claude_token_usage` (optionally with `days: N` to
164
+ scan a window other than the default 7) to push a real token/model/context
165
+ breakdown up to the same "Claude Usage" tab — distinct from and
166
+ independent of `report_claude_usage` above. You usually don't need to call
167
+ this directly: it already fires automatically after every successful
168
+ `fetch_bug_context`, `submit_bug_resolution`, `fetch_assigned_bugs`, or
169
+ `create_bug_report` call, genuinely in the background (it's never awaited,
170
+ so it adds no latency and its own response text never shows up in that
171
+ tool's reply) — this standalone call is for an explicit, on-demand check
172
+ with a full summary (tokens by model, current context occupancy, subagent
173
+ share) in the response.
163
174
 
164
175
  ## Notes
165
176
 
@@ -173,3 +184,8 @@ Four more, independent of the fetch/resolve flow above:
173
184
  - `fetch_bug_context` and `submit_bug_resolution` still need a report id —
174
185
  this server doesn't search or browse. `fetch_assigned_bugs` is the one tool
175
186
  that doesn't require knowing an id up front.
187
+ - `report_claude_token_usage`'s breakdown only ever sends the BASENAME of a
188
+ session's working directory (e.g. `isocode`, not `/Users/you/Downloads/
189
+ isocode`) to the company-admin panel — real project names, no full
190
+ filesystem paths or usernames, even though the local scan reads the full
191
+ path off the transcript.
package/index.js CHANGED
@@ -21,11 +21,16 @@
21
21
  // the company-admin panel's "Claude Usage" tab.
22
22
  // - report_claude_token_usage: POST /api/admin/claude-usage/token-breakdown
23
23
  // — REAL, not self-attested: scans this developer's own local Claude
24
- // Code transcripts (~/.claude/projects/**/*.jsonl — this server runs on
25
- // the same machine) for a 7-day token/web-search/subagent breakdown. The
26
- // other four bug-resolution tools above also trigger this automatically
27
- // as a fire-and-forget side effect (see tryAutoReportTokenUsage) — "every
28
- // task done in the bug reporter" keeps this fresh with zero extra asks.
24
+ // Code transcripts (~/.claude/projects/**/*.jsonl AND their nested
25
+ // .../subagents/*.jsonl — this server runs on the same machine) for a
26
+ // token/model/context breakdown over a configurable window (default 7
27
+ // days): per-model token split, how full the most recently active
28
+ // session's context window is right now, and web-search/subagent usage
29
+ // — subagent token spend is folded into the totals, not just detected.
30
+ // The other four bug-resolution tools above also trigger this
31
+ // automatically as a genuinely non-blocking side effect
32
+ // (tryAutoReportTokenUsage never awaited) — "every task done in the bug
33
+ // reporter" keeps this fresh with zero extra asks and zero added latency.
29
34
  // Thin wrapper: all the actual logic (auth, scoping, the DB writes) lives
30
35
  // server-side; this just forwards the HTTP calls the README documents.
31
36
  //
@@ -34,8 +39,9 @@
34
39
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
35
40
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
36
41
  import { z } from 'zod';
37
- import { readdirSync, readFileSync, statSync } from 'node:fs';
38
- import { join } from 'node:path';
42
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
43
+ import { createInterface } from 'node:readline';
44
+ import { join, basename } from 'node:path';
39
45
  import { homedir } from 'node:os';
40
46
 
41
47
  const API_KEY = process.env.BUG_REPORT_API_KEY;
@@ -46,7 +52,7 @@ if (!API_KEY) {
46
52
  'Generate one from the company-admin panel (Bug Reports → API Keys) and set it when registering this server.');
47
53
  }
48
54
 
49
- const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.6.0' });
55
+ const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.7.0' });
50
56
 
51
57
  const BUG_SEVERITIES = ['low', 'medium', 'high', 'critical'];
52
58
  // Keep in sync with BUG_STATUSES in backend/src/db/schema/platform/bug-reports.ts.
@@ -88,7 +94,35 @@ async function fetchImageContentBlock(url) {
88
94
  // dedupes by message.id, or totals would be inflated 2-3x.
89
95
  const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
90
96
  const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
97
+ // Client-side web tools (not Anthropic's server-executed web_search/
98
+ // web_fetch — see usage.server_tool_use below, a DIFFERENT mechanism) show up
99
+ // as ordinary tool_use blocks instead. Both count as "used web search" for
100
+ // weeklySessionsWithWebSearch; missing this half undercounted it before.
101
+ const WEB_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
102
+
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).
111
+ function contextLimitForModel(model) {
112
+ if (!model) return 200_000;
113
+ if (model.includes('sonnet')) return 1_000_000;
114
+ return 200_000;
115
+ }
91
116
 
117
+ /**
118
+ * Every transcript file to scan in the window: each main session file
119
+ * PLUS its subagent transcripts (…/<sessionId>/subagents/*.jsonl) — a
120
+ * separate, nested location a flat directory listing never finds, which is
121
+ * exactly why subagent token usage was invisible before (an Agent/Task tool
122
+ * call was detected on the main thread, but what THAT subagent itself spent
123
+ * was never read at all). Each entry is tagged so the caller can attribute
124
+ * subagent tokens separately while still rolling them into the session total.
125
+ */
92
126
  function listRecentTranscripts(sinceMs) {
93
127
  const files = [];
94
128
  let projectDirs;
@@ -107,34 +141,75 @@ function listRecentTranscripts(sinceMs) {
107
141
  continue;
108
142
  }
109
143
  for (const entry of entries) {
110
- if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
111
- const filePath = join(dirPath, entry.name);
144
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
145
+ const filePath = join(dirPath, entry.name);
146
+ try {
147
+ if (statSync(filePath).mtimeMs >= sinceMs) {
148
+ files.push({ filePath, isSubagent: false, sessionDir: join(dirPath, entry.name.replace(/\.jsonl$/, '')) });
149
+ }
150
+ } catch {
151
+ // file vanished between readdir and stat — skip
152
+ }
153
+ }
154
+ }
155
+ }
156
+ // Second pass for subagent files — needs the session dirs discovered above.
157
+ for (const main of files.slice()) {
158
+ let subFiles;
159
+ try {
160
+ subFiles = readdirSync(join(main.sessionDir, 'subagents'), { withFileTypes: true });
161
+ } catch {
162
+ continue; // no subagents dir for this session — the common case
163
+ }
164
+ for (const sub of subFiles) {
165
+ if (!sub.isFile() || !sub.name.endsWith('.jsonl')) continue;
166
+ const filePath = join(main.sessionDir, 'subagents', sub.name);
112
167
  try {
113
- if (statSync(filePath).mtimeMs >= sinceMs) files.push(filePath);
168
+ if (statSync(filePath).mtimeMs >= sinceMs) {
169
+ files.push({ filePath, isSubagent: true, parentSessionId: basename(main.filePath, '.jsonl') });
170
+ }
114
171
  } catch {
115
- // file vanished between readdir and stat — skip
172
+ // skip
116
173
  }
117
174
  }
118
175
  }
119
176
  return files;
120
177
  }
121
178
 
122
- /** One session's token totals + what it used — null if the file had nothing
123
- * in range (e.g. mtime was recent but every logged message predates sinceMs). */
124
- function scanTranscript(filePath, sinceMs) {
125
- let raw;
179
+ // Per-process cache keyed by (path, mtime, size) — a transcript already
180
+ // scanned this run, unchanged on disk, is never re-read. Only helps within
181
+ // one long-lived MCP server process (one Claude Code session calling this
182
+ // repeatedly), not across separate sessions — still the common case, since
183
+ // the auto-report path fires on every bug-report tool call.
184
+ const scanCache = new Map();
185
+
186
+ /** One transcript's token totals + what it used — null if nothing in range
187
+ * (e.g. mtime was recent but every logged message predates sinceMs).
188
+ * Streams line-by-line (readline over a read stream) instead of loading the
189
+ * whole file into memory — a long session's transcript can run into the
190
+ * hundreds of MB, and the previous readFileSync+split approach held the
191
+ * entire thing (plus the split array) in memory at once. */
192
+ async function scanTranscript(filePath, sinceMs) {
193
+ let stat;
126
194
  try {
127
- raw = readFileSync(filePath, 'utf8');
195
+ stat = statSync(filePath);
128
196
  } catch {
129
197
  return null;
130
198
  }
199
+ const cacheKey = `${filePath}:${stat.mtimeMs}:${stat.size}`;
200
+ const cached = scanCache.get(cacheKey);
201
+ if (cached) return cached.sinceMs <= sinceMs ? cached.result : null; // a wider window than what's cached still needs a rescan — conservative
131
202
 
132
203
  const seenMessageIds = new Set();
133
204
  let inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0;
134
205
  let usedWebSearch = false, usedSubagents = false;
135
- let startedAt = null, cwd = null;
206
+ let startedAt = null, cwd = null, gitBranch = null, sessionId = null, version = null;
207
+ /** @type {Record<string, {inputTokens:number, outputTokens:number, cacheCreationTokens:number, cacheReadTokens:number}>} */
208
+ const byModel = {};
209
+ let lastModel = null, lastContextTokens = 0, lastTimestamp = null;
136
210
 
137
- for (const line of raw.split('\n')) {
211
+ const rl = createInterface({ input: createReadStream(filePath, 'utf8'), crlfDelay: Infinity });
212
+ for await (const line of rl) {
138
213
  if (!line.trim()) continue;
139
214
  let entry;
140
215
  try {
@@ -143,16 +218,25 @@ function scanTranscript(filePath, sinceMs) {
143
218
  continue; // truncated/partial last line — skip it, not the whole file
144
219
  }
145
220
  if (entry.type !== 'assistant' || !entry.message) continue;
146
- if (entry.timestamp && Date.parse(entry.timestamp) < sinceMs) continue;
147
-
148
- if (cwd === null && entry.cwd) cwd = entry.cwd;
149
- if (entry.timestamp && (!startedAt || entry.timestamp < startedAt)) startedAt = entry.timestamp;
221
+ // A missing timestamp means "can't verify this is in-window" — exclude
222
+ // it rather than risk a pre-window message slipping into the totals (the
223
+ // opposite bug from before: entry.timestamp && ... let a falsy timestamp
224
+ // skip the check entirely, i.e. silently PASS it through unfiltered).
225
+ if (!entry.timestamp || Date.parse(entry.timestamp) < sinceMs) continue;
226
+
227
+ if (cwd === null && entry.cwd) cwd = basename(entry.cwd); // basename only — see upsertTokenBreakdown's privacy comment
228
+ if (sessionId === null && entry.sessionId) sessionId = entry.sessionId;
229
+ if (gitBranch === null && entry.gitBranch) gitBranch = entry.gitBranch;
230
+ if (version === null && entry.version) version = entry.version;
231
+ if (!startedAt || entry.timestamp < startedAt) startedAt = entry.timestamp;
150
232
 
151
233
  // Tool-use detection runs on every line, duplicates included — setting a
152
234
  // boolean true twice is harmless, unlike double-counting tokens below.
153
235
  if (Array.isArray(entry.message.content)) {
154
236
  for (const block of entry.message.content) {
155
- if (block?.type === 'tool_use' && SUBAGENT_TOOL_NAMES.has(block.name)) usedSubagents = true;
237
+ if (block?.type !== 'tool_use') continue;
238
+ if (SUBAGENT_TOOL_NAMES.has(block.name)) usedSubagents = true;
239
+ if (WEB_TOOL_NAMES.has(block.name)) usedWebSearch = true;
156
240
  }
157
241
  }
158
242
 
@@ -164,32 +248,78 @@ function scanTranscript(filePath, sinceMs) {
164
248
 
165
249
  const usage = entry.message.usage;
166
250
  if (!usage) continue;
167
- inputTokens += usage.input_tokens || 0;
168
- outputTokens += usage.output_tokens || 0;
169
- cacheCreationTokens += usage.cache_creation_input_tokens || 0;
170
- cacheReadTokens += usage.cache_read_input_tokens || 0;
251
+ const model = entry.message.model || 'unknown';
252
+ const mi = usage.input_tokens || 0, mo = usage.output_tokens || 0;
253
+ const mc = usage.cache_creation_input_tokens || 0, mr = usage.cache_read_input_tokens || 0;
254
+ inputTokens += mi; outputTokens += mo; cacheCreationTokens += mc; cacheReadTokens += mr;
255
+ // Claude Code emits internally-synthesized assistant entries tagged
256
+ // model: "<synthetic>" (and similar) with zero real usage — real API
257
+ // calls always cost SOMETHING, so skip attributing a model entry when
258
+ // every field is zero, rather than littering byModel with noise rows.
259
+ if (mi + mo + mc + mr > 0) {
260
+ const m = (byModel[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
261
+ m.inputTokens += mi; m.outputTokens += mo; m.cacheCreationTokens += mc; m.cacheReadTokens += mr;
262
+ }
263
+
171
264
  const serverTools = usage.server_tool_use;
172
265
  if (serverTools && ((serverTools.web_search_requests || 0) > 0 || (serverTools.web_fetch_requests || 0) > 0)) {
173
266
  usedWebSearch = true;
174
267
  }
268
+
269
+ // "Current context occupancy" — what the model was actually carrying at
270
+ // THIS turn: the uncached input plus everything served from/written to
271
+ // cache. Tracked off the chronologically LAST REAL message in the file
272
+ // (not the biggest, and not a trailing zero-usage synthetic entry,
273
+ // which would otherwise report "0 tokens / <synthetic>" for a session
274
+ // that actually ended on a real turn).
275
+ if (mi + mo + mc + mr > 0 && (!lastTimestamp || entry.timestamp >= lastTimestamp)) {
276
+ lastTimestamp = entry.timestamp;
277
+ lastModel = model;
278
+ lastContextTokens = mi + mc + mr;
279
+ }
175
280
  }
176
281
 
177
282
  const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
178
- if (totalTokens === 0) return null;
179
- return { totalTokens, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, usedWebSearch, usedSubagents, startedAt, cwd };
283
+ const result = totalTokens === 0 ? null : {
284
+ totalTokens, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens,
285
+ usedWebSearch, usedSubagents, startedAt, lastTimestamp, cwd, gitBranch, sessionId, version, byModel,
286
+ currentContextTokens: lastContextTokens,
287
+ currentContextModel: lastModel,
288
+ };
289
+ scanCache.set(cacheKey, { sinceMs, result });
290
+ return result;
180
291
  }
181
292
 
182
- /** Scans every local Claude Code transcript touched in the last `days` days
183
- * and aggregates it into the shape reportClaudeTokenUsageSchema expects.
184
- * Deliberately NOT scoped to this repo/project — the whole point is "how
185
- * much Claude Code usage this week", same scope /usage itself reports. */
186
- function computeWeeklyTokenBreakdown(days = 7) {
187
- const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
188
- const sessions = listRecentTranscripts(sinceMs)
189
- .map((f) => scanTranscript(f, sinceMs))
190
- .filter((s) => s !== null);
293
+ function mergeByModel(target, source) {
294
+ for (const [model, v] of Object.entries(source)) {
295
+ const m = (target[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
296
+ m.inputTokens += v.inputTokens; m.outputTokens += v.outputTokens;
297
+ m.cacheCreationTokens += v.cacheCreationTokens; m.cacheReadTokens += v.cacheReadTokens;
298
+ }
299
+ }
191
300
 
192
- const totals = sessions.reduce(
301
+ /**
302
+ * Scans every local Claude Code transcript touched in the last `days` days
303
+ * (main sessions AND their subagent transcripts — see listRecentTranscripts)
304
+ * and aggregates it into the shape reportClaudeTokenUsageSchema expects.
305
+ * Deliberately NOT scoped to this repo/project — the whole point is "how
306
+ * much Claude Code usage this week", same scope /usage itself reports.
307
+ */
308
+ async function computeWeeklyTokenBreakdown(days = 7) {
309
+ const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
310
+ const fileRefs = listRecentTranscripts(sinceMs);
311
+ const scanned = await Promise.all(fileRefs.map((f) => scanTranscript(f.filePath, sinceMs)));
312
+
313
+ const mainSessions = [];
314
+ const subagentResults = [];
315
+ fileRefs.forEach((ref, i) => {
316
+ const s = scanned[i];
317
+ if (!s) return;
318
+ if (ref.isSubagent) subagentResults.push(s);
319
+ else mainSessions.push(s);
320
+ });
321
+
322
+ const totals = mainSessions.reduce(
193
323
  (acc, s) => ({
194
324
  inputTokens: acc.inputTokens + s.inputTokens,
195
325
  outputTokens: acc.outputTokens + s.outputTokens,
@@ -199,13 +329,47 @@ function computeWeeklyTokenBreakdown(days = 7) {
199
329
  { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }
200
330
  );
201
331
 
202
- const topSessions = [...sessions]
332
+ // Subagent "new work" (input+output+cache-creation, same exclusion as
333
+ // newTokenCount below) folded into the SAME four columns so the headline
334
+ // total is actually complete — a session that spawned subagents no longer
335
+ // silently undercounts what those subagents spent. Also kept as its own
336
+ // figure (subagentTokens) so the breakdown stays honest about the split.
337
+ const subagentTokens = subagentResults.reduce((sum, s) => sum + s.inputTokens + s.outputTokens + s.cacheCreationTokens, 0);
338
+ totals.inputTokens += subagentResults.reduce((sum, s) => sum + s.inputTokens, 0);
339
+ totals.outputTokens += subagentResults.reduce((sum, s) => sum + s.outputTokens, 0);
340
+ totals.cacheCreationTokens += subagentResults.reduce((sum, s) => sum + s.cacheCreationTokens, 0);
341
+ totals.cacheReadTokens += subagentResults.reduce((sum, s) => sum + s.cacheReadTokens, 0);
342
+
343
+ const byModel = {};
344
+ for (const s of mainSessions) mergeByModel(byModel, s.byModel);
345
+ for (const s of subagentResults) mergeByModel(byModel, s.byModel);
346
+
347
+ // "Right now" context occupancy — pick the MAIN session whose own last
348
+ // message is chronologically most recent (not whichever started most
349
+ // recently — a session can run for hours), and use its last-message
350
+ // context snapshot. null when nothing was scanned at all.
351
+ const mostRecentSession = mainSessions.reduce(
352
+ (latest, s) => (!latest || (s.lastTimestamp ?? '') > (latest.lastTimestamp ?? '') ? s : latest),
353
+ null
354
+ );
355
+ const currentContext = mostRecentSession && {
356
+ tokens: mostRecentSession.currentContextTokens,
357
+ model: mostRecentSession.currentContextModel,
358
+ limit: contextLimitForModel(mostRecentSession.currentContextModel),
359
+ };
360
+
361
+ const topSessions = [...mainSessions]
203
362
  .sort((a, b) => b.totalTokens - a.totalTokens)
204
- .slice(0, 5)
363
+ .slice(0, 8)
205
364
  .map((s) => ({
206
365
  totalTokens: s.totalTokens,
207
366
  startedAt: s.startedAt || new Date(sinceMs).toISOString(),
208
367
  cwd: s.cwd,
368
+ gitBranch: s.gitBranch,
369
+ sessionId: s.sessionId,
370
+ version: s.version,
371
+ model: s.currentContextModel,
372
+ contextTokens: s.currentContextTokens,
209
373
  usedWebSearch: s.usedWebSearch,
210
374
  usedSubagents: s.usedSubagents,
211
375
  }));
@@ -214,9 +378,20 @@ function computeWeeklyTokenBreakdown(days = 7) {
214
378
  periodStart: new Date(sinceMs).toISOString(),
215
379
  periodEnd: new Date().toISOString(),
216
380
  ...totals,
217
- sessionCount: sessions.length,
218
- sessionsWithWebSearch: sessions.filter((s) => s.usedWebSearch).length,
219
- sessionsWithSubagents: sessions.filter((s) => s.usedSubagents).length,
381
+ subagentTokens,
382
+ byModel,
383
+ currentContextTokens: currentContext?.tokens ?? null,
384
+ currentContextModel: currentContext?.model ?? null,
385
+ currentContextLimit: currentContext?.limit ?? null,
386
+ sessionCount: mainSessions.length,
387
+ sessionsWithWebSearch: mainSessions.filter((s) => s.usedWebSearch).length,
388
+ // A session counts as "used subagents" either because the main thread
389
+ // itself spawned one (tool_use detection) OR because subagent transcript
390
+ // files were found for it at all — the two signals can disagree if a
391
+ // subagent was spawned but its own file hasn't flushed yet.
392
+ sessionsWithSubagents: mainSessions.filter(
393
+ (s) => s.usedSubagents || subagentResults.some((sub) => fileRefs.some((r) => r.isSubagent && r.parentSessionId === s.sessionId))
394
+ ).length,
220
395
  topSessions,
221
396
  };
222
397
  }
@@ -225,43 +400,53 @@ function computeWeeklyTokenBreakdown(days = 7) {
225
400
  * reach the hundreds of millions (every turn re-pays for its whole cached
226
401
  * context, so cache-read alone compounds fast), so a plain toLocaleString()
227
402
  * reads as an alarming wall of digits. Still real numbers — just formatted
228
- * for a human skimming a report, not a ledger. */
403
+ * for a human skimming a report, not a ledger. One decimal place
404
+ * everywhere, including k — the panel (frontend's api.ts) uses the same
405
+ * convention, so a number never reads differently in the two places it
406
+ * can show up. */
229
407
  function formatTokenCount(n) {
230
408
  if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
231
409
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
232
- if (n >= 1e3) return `${(n / 1e3).toFixed(0)}k`;
410
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
233
411
  return String(n);
234
412
  }
235
413
 
236
414
  /** input + output + cache-WRITE tokens — genuinely new content processed
237
- * this period. Deliberately excludes cache-READ: that's a heavily-discounted
238
- * re-read of context already paid for once, and in a long session it
239
- * dwarfs everything else by 10-100x, so folding it into one headline
240
- * number would make "new work this week" unreadable. Still reported
241
- * separately in full (see the schema) — just not what ranks/headlines. */
415
+ * this period (subagent spend already folded in — see computeWeeklyTokenBreakdown).
416
+ * Deliberately excludes cache-READ: that's a heavily-discounted re-read of
417
+ * context already paid for once, and in a long session it dwarfs
418
+ * everything else by 10-100x, so folding it into one headline number would
419
+ * make "new work this week" unreadable. Still reported separately in full
420
+ * (see the schema) — just not what ranks/headlines. */
242
421
  function newTokenCount(breakdown) {
243
422
  return breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens;
244
423
  }
245
424
 
246
- /** Fire-and-forget: scan + POST the breakdown, swallowing any failure (a
247
- * missing ~/.claude/projects dir, a network blip, whatever) — this must
248
- * never fail or slow down the tool call it's piggybacking on. Returns a
249
- * short status string for an optional footer, or null if it didn't run. */
250
- async function tryAutoReportTokenUsage() {
251
- if (!API_KEY) return null;
252
- try {
253
- const breakdown = computeWeeklyTokenBreakdown();
254
- if (breakdown.sessionCount === 0) return null;
255
- const res = await fetch(`${BASE_URL}/api/admin/claude-usage/token-breakdown`, {
256
- method: 'POST',
257
- headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
258
- body: JSON.stringify(breakdown),
259
- });
260
- if (!res.ok) return null;
261
- return `${formatTokenCount(newTokenCount(breakdown))} new tokens across ${breakdown.sessionCount} session(s) this week`;
262
- } catch {
263
- return null; // never let this break the real tool call
264
- }
425
+ /**
426
+ * Fire-and-forget: scan + POST the breakdown, swallowing any failure (a
427
+ * missing ~/.claude/projects dir, a network blip, whatever) — this must
428
+ * never fail or slow down the tool call it's piggybacking on. Genuinely
429
+ * non-blocking now (not awaited by its four callers below) — the scan can
430
+ * run into the hundreds of MB and take the better part of a second even
431
+ * streamed, which used to add that same delay to every single bug-report
432
+ * tool call despite the "fire-and-forget" name. Returns nothing: with no
433
+ * await, there's no result left by the time the real tool response goes
434
+ * out, so the old "(Also updated your Claude token usage: ...)" footer is
435
+ * gone too — the next report_claude_token_usage call (or the next bug tool,
436
+ * since this still runs after every one) is what actually surfaces it.
437
+ */
438
+ function tryAutoReportTokenUsage() {
439
+ if (!API_KEY) return;
440
+ computeWeeklyTokenBreakdown()
441
+ .then((breakdown) => {
442
+ if (breakdown.sessionCount === 0) return;
443
+ return fetch(`${BASE_URL}/api/admin/claude-usage/token-breakdown`, {
444
+ method: 'POST',
445
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
446
+ body: JSON.stringify(breakdown),
447
+ });
448
+ })
449
+ .catch(() => {}); // never let this break (or even be waited on by) the real tool call
265
450
  }
266
451
 
267
452
  server.registerTool(
@@ -314,13 +499,13 @@ server.registerTool(
314
499
  }
315
500
 
316
501
  const report = body?.data;
317
- const usageNote = await tryAutoReportTokenUsage();
502
+ tryAutoReportTokenUsage();
318
503
  return {
319
504
  content: [{
320
505
  type: 'text',
321
- text: (report
506
+ text: report
322
507
  ? `Resolution submitted. "${report.title}" is now ${report.status}.`
323
- : 'Resolution submitted.') + (usageNote ? `\n\n(Also updated your Claude token usage: ${usageNote}.)` : ''),
508
+ : 'Resolution submitted.',
324
509
  }],
325
510
  };
326
511
  }
@@ -408,8 +593,7 @@ server.registerTool(
408
593
  }
409
594
  }
410
595
 
411
- const usageNote = await tryAutoReportTokenUsage();
412
- if (usageNote) content.push({ type: 'text', text: `(Also updated your Claude token usage: ${usageNote}.)` });
596
+ tryAutoReportTokenUsage();
413
597
 
414
598
  return { content };
415
599
  }
@@ -484,11 +668,11 @@ server.registerTool(
484
668
  ? `${reports.length} of ${meta.total} assigned report(s) (showing offset ${meta.offset}–${meta.offset + reports.length - 1}; pass a higher offset for more):`
485
669
  : `${reports.length} assigned report(s):`;
486
670
 
487
- const usageNote = await tryAutoReportTokenUsage();
671
+ tryAutoReportTokenUsage();
488
672
  return {
489
673
  content: [{
490
674
  type: 'text',
491
- text: `${header}\n${summary}` + (usageNote ? `\n\n(Also updated your Claude token usage: ${usageNote}.)` : ''),
675
+ text: `${header}\n${summary}`,
492
676
  }],
493
677
  };
494
678
  }
@@ -540,13 +724,13 @@ server.registerTool(
540
724
  }
541
725
 
542
726
  const report = body?.data;
543
- const usageNote = await tryAutoReportTokenUsage();
727
+ tryAutoReportTokenUsage();
544
728
  return {
545
729
  content: [{
546
730
  type: 'text',
547
- text: (report
731
+ text: report
548
732
  ? `Bug report created: "${report.title}" (id: ${report.id}).`
549
- : 'Bug report created.') + (usageNote ? `\n\n(Also updated your Claude token usage: ${usageNote}.)` : ''),
733
+ : 'Bug report created.',
550
734
  }],
551
735
  };
552
736
  }
@@ -567,9 +751,16 @@ server.registerTool(
567
751
  inputSchema: {
568
752
  usagePercent: z.number().min(0).max(100).describe('Current usage, 0-100 (fractional values like 72.5 are fine).'),
569
753
  note: z.string().max(500).optional().describe('Optional context, e.g. "Team plan, resets Monday".'),
754
+ // Structured, not buried in `note` — lets the panel show a real
755
+ // countdown/staleness indicator instead of free text it has to
756
+ // truncate. Pass it whenever you can see a concrete reset time (e.g.
757
+ // the statusLine's rate-limit JSON) — omit it if you can't; a
758
+ // percentage without a known reset time is still worth reporting.
759
+ resetsAt: z.string().datetime({ offset: true }).optional()
760
+ .describe('ISO 8601 timestamp this percentage resets at, if known (e.g. from the status line).'),
570
761
  },
571
762
  },
572
- async ({ usagePercent, note }) => {
763
+ async ({ usagePercent, note, resetsAt }) => {
573
764
  if (!API_KEY) {
574
765
  return {
575
766
  isError: true,
@@ -582,7 +773,7 @@ server.registerTool(
582
773
  res = await fetch(`${BASE_URL}/api/admin/claude-usage`, {
583
774
  method: 'POST',
584
775
  headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
585
- body: JSON.stringify({ usagePercent, note }),
776
+ body: JSON.stringify({ usagePercent, note, resetsAt }),
586
777
  });
587
778
  } catch (err) {
588
779
  return {
@@ -616,16 +807,22 @@ server.registerTool(
616
807
  title: 'Report Claude token usage breakdown',
617
808
  description:
618
809
  "Scans this developer's own local Claude Code transcripts " +
619
- '(~/.claude/projects/**/*.jsonl — this MCP server runs on the same machine, so it can read them ' +
620
- 'directly) and reports a real token-usage breakdown for the last 7 days: total tokens, how many ' +
621
- 'sessions, and how many of those sessions used web search or spawned a subagent. Unlike ' +
810
+ '(~/.claude/projects/**/*.jsonl AND their nested .../subagents/*.jsonl — this MCP server runs on ' +
811
+ 'the same machine, so it can read them directly) and reports a real token/model/context breakdown: ' +
812
+ 'total tokens (including subagent spend, not just the main thread), a per-model split (so it can ' +
813
+ "actually be priced), how full the most recently active session's context window currently is, and " +
814
+ 'how many sessions used web search or spawned a subagent. Unlike ' +
622
815
  "report_claude_usage's percentage, nothing here is self-attested — every number is read off real " +
623
- 'local files, so call this freely with no arguments; it never needs to ask a human for anything. ' +
624
- "The other bug-resolution tools already call this automatically as a side effect, so you usually " +
625
- "don't need to call it directly — this exists for an explicit, on-demand check.",
626
- inputSchema: {},
816
+ 'local files, so call this freely; it never needs to ask a human for anything. ' +
817
+ "The other bug-resolution tools already call this automatically (fire-and-forget) as a side effect, " +
818
+ "so you usually don't need to call it directly — this exists for an explicit, on-demand check, and " +
819
+ 'lets you ask for a window other than the default 7 days.',
820
+ inputSchema: {
821
+ days: z.number().int().min(1).max(90).optional()
822
+ .describe('How many days back to scan (default 7). The reported periodStart/periodEnd always say exactly what was scanned.'),
823
+ },
627
824
  },
628
- async () => {
825
+ async ({ days }) => {
629
826
  if (!API_KEY) {
630
827
  return {
631
828
  isError: true,
@@ -633,10 +830,10 @@ server.registerTool(
633
830
  };
634
831
  }
635
832
 
636
- const breakdown = computeWeeklyTokenBreakdown();
833
+ const breakdown = await computeWeeklyTokenBreakdown(days ?? 7);
637
834
  if (breakdown.sessionCount === 0) {
638
835
  return {
639
- content: [{ type: 'text', text: 'No Claude Code transcripts found in the last 7 days under ~/.claude/projects — nothing to report.' }],
836
+ content: [{ type: 'text', text: `No Claude Code transcripts found in the last ${days ?? 7} day(s) under ~/.claude/projects — nothing to report.` }],
640
837
  };
641
838
  }
642
839
 
@@ -661,13 +858,24 @@ server.registerTool(
661
858
  return { isError: true, content: [{ type: 'text', text: `Failed to report Claude token usage: ${message}` }] };
662
859
  }
663
860
 
861
+ const modelLines = Object.entries(breakdown.byModel)
862
+ .map(([model, t]) => ` - ${model}: ${formatTokenCount(t.inputTokens + t.outputTokens + t.cacheCreationTokens)} new (+${formatTokenCount(t.cacheReadTokens)} cache-read)`)
863
+ .join('\n');
864
+ const contextLine = breakdown.currentContextTokens != null
865
+ ? `Current context (most recently active session, ${breakdown.currentContextModel}): ` +
866
+ `${formatTokenCount(breakdown.currentContextTokens)} / ${formatTokenCount(breakdown.currentContextLimit)} ` +
867
+ `(${Math.round((breakdown.currentContextTokens / breakdown.currentContextLimit) * 100)}%)`
868
+ : 'Current context: unknown (no in-window session found)';
869
+
664
870
  return {
665
871
  content: [{
666
872
  type: 'text',
667
873
  text: `Reported ${formatTokenCount(newTokenCount(breakdown))} new tokens ` +
668
874
  `(plus ${formatTokenCount(breakdown.cacheReadTokens)} discounted cache-read re-reads) ` +
669
- `across ${breakdown.sessionCount} session(s) this week ` +
670
- `(${breakdown.sessionsWithWebSearch} used web search, ${breakdown.sessionsWithSubagents} used subagents).`,
875
+ `across ${breakdown.sessionCount} session(s) over the last ${days ?? 7} day(s) ` +
876
+ `(${breakdown.sessionsWithWebSearch} used web search, ${breakdown.sessionsWithSubagents} used subagents, ` +
877
+ `${formatTokenCount(breakdown.subagentTokens)} of the new-token total came from subagents).\n` +
878
+ `By model:\n${modelLines}\n${contextLine}`,
671
879
  }],
672
880
  };
673
881
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "zopkit-bug-resolution-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.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 weekly token-usage breakdown from your local transcripts. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
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.",
6
6
  "license": "UNLICENSED",
7
7
  "engines": {
8
8
  "node": ">=18"