zopkit-bug-resolution-mcp 1.6.0 → 1.8.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 +115 -21
- package/bin/zopkit-backfill-tasks.js +307 -0
- package/bin/zopkit-session-end.js +30 -0
- package/bin/zopkit-statusline.js +151 -0
- package/bin/zopkit-task-marker.js +85 -0
- package/index.js +342 -95
- package/lib/init-usage-reporting.js +197 -0
- package/lib/session-scan.js +150 -0
- package/lib/zopkit-config.js +93 -0
- package/package.json +5 -3
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
(`submit_bug_resolution`, `fetch_bug_context`,
|
|
38
|
-
`create_bug_report`) on success — so a developer
|
|
39
|
-
tool directly still gets a token breakdown
|
|
40
|
-
bug-report work
|
|
41
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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,86 @@ 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.
|
|
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,151 @@
|
|
|
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 } 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
|
+
// Whether a NEW API response landed since the last heartbeat for this
|
|
116
|
+
// session, detected by the context token count moving. Only a fresh
|
|
117
|
+
// reading may advance the account-level percentages: an idle session's
|
|
118
|
+
// status line still re-renders on a timer carrying `rate_limits` from
|
|
119
|
+
// its last API response, possibly an hour stale, and would otherwise
|
|
120
|
+
// walk a live session's number backwards.
|
|
121
|
+
fresh,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function main() {
|
|
126
|
+
const payload = await readStdinJson();
|
|
127
|
+
if (!payload) return;
|
|
128
|
+
|
|
129
|
+
// RULE 1: the bar is written before anything can block.
|
|
130
|
+
process.stdout.write(render(payload));
|
|
131
|
+
|
|
132
|
+
const cfg = loadConfig();
|
|
133
|
+
if (!cfg || !payload.session_id) return;
|
|
134
|
+
|
|
135
|
+
const state = readState(payload.session_id);
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const contextTokens = payload.context_window?.total_input_tokens ?? null;
|
|
138
|
+
const fresh = contextTokens !== null && contextTokens !== state.lastContextTokens;
|
|
139
|
+
|
|
140
|
+
// Throttle, but never let a fresh reading wait: a developer watching
|
|
141
|
+
// their weekly percentage climb should see it move on the panel, not 15
|
|
142
|
+
// seconds later.
|
|
143
|
+
if (!fresh && now - (state.lastSentAt || 0) < THROTTLE_MS) return;
|
|
144
|
+
|
|
145
|
+
writeState(payload.session_id, { lastSentAt: now, lastContextTokens: contextTokens });
|
|
146
|
+
await post('/api/admin/claude-usage/heartbeat', buildHeartbeat(payload, cfg, fresh));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Any unexpected throw must still leave the bar rendered and the editor
|
|
150
|
+
// unbothered.
|
|
151
|
+
main().catch(() => {});
|