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 +78 -0
- package/bin/zopkit-backfill-tasks.js +307 -0
- package/bin/zopkit-session-end.js +30 -0
- package/bin/zopkit-statusline.js +155 -0
- package/bin/zopkit-task-marker.js +85 -0
- package/index.js +60 -11
- package/lib/init-usage-reporting.js +197 -0
- package/lib/session-scan.js +150 -0
- package/lib/zopkit-config.js +133 -0
- package/package.json +5 -3
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.
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
|
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"
|