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 CHANGED
@@ -27,18 +27,24 @@ usage status — instead of hand-rolling curl commands:
27
27
  company-admin panel's "Claude Usage" tab, via `POST /api/admin/claude-usage`.
28
28
  - **`report_claude_token_usage`** — unlike the one above, this one is NOT
29
29
  self-attested: it scans this developer's own local Claude Code transcripts
30
- (`~/.claude/projects/**/*.jsonl`, same machine the MCP server runs on — no
31
- `/usage`, no status line, no asking a human) for the last 7 days, dedupes
32
- streamed duplicates by `message.id`, and reports real input/output/
33
- cache-creation/cache-read token totals, session count, and whether any
34
- sessions used web search or spawned subagents, via
30
+ (`~/.claude/projects/**/*.jsonl` AND their nested `.../subagents/*.jsonl` —
31
+ same machine the MCP server runs on, no `/usage`, no status line, no
32
+ asking a human) for a configurable window (`days`, default 7), streamed
33
+ line-by-line and cached per-file so repeat calls in one session are
34
+ near-instant. Dedupes streamed duplicates by `message.id` and reports real
35
+ input/output/cache-creation/cache-read token totals (subagent spend folded
36
+ in, not just detected), a per-model split, how full the most recently
37
+ active session's context window currently is, session count, and whether
38
+ any sessions used web search or spawned subagents — via
35
39
  `POST /api/admin/claude-usage/token-breakdown`. Also runs automatically,
36
- fire-and-forget, as a side effect of the four bug-report tools above
37
- (`submit_bug_resolution`, `fetch_bug_context`, `fetch_assigned_bugs`,
38
- `create_bug_report`) on success — so a developer who never touches this
39
- tool directly still gets a token breakdown reported just by doing normal
40
- bug-report work. Failures from the auto-report path are swallowed
41
- entirely; they never surface as a tool error.
40
+ genuinely non-blocking (never awaited), as a side effect of the four
41
+ bug-report tools above (`submit_bug_resolution`, `fetch_bug_context`,
42
+ `fetch_assigned_bugs`, `create_bug_report`) on success — so a developer
43
+ who never touches this tool directly still gets a token breakdown
44
+ reported just by doing normal bug-report work, with zero added latency on
45
+ those calls. Failures from the auto-report path are swallowed entirely;
46
+ they never surface as a tool error, and (since it isn't awaited) never
47
+ delay the real response either.
42
48
 
43
49
  All six are thin wrappers — see `backend/src/features/bug-reports/README.md`
44
50
  and `backend/src/features/claude-usage/README.md` for what each endpoint
@@ -150,16 +156,21 @@ Four more, independent of the fetch/resolve flow above:
150
156
  `/usage`, the status line, or the account's billing page — there's no way
151
157
  for the tool to look this up itself) to update the "Claude Usage" tab in
152
158
  the company-admin panel. Pass an optional `note` for context (e.g. "Team
153
- plan, resets Monday").
154
- - Ask it to call `report_claude_token_usage` (no input needed) to push a
155
- real token/session breakdown for the last 7 days up to the same "Claude
156
- Usage" tab — distinct from and independent of `report_claude_usage`
157
- above. You usually don't need to call this directly: it already fires
158
- automatically after every successful `fetch_bug_context`,
159
- `submit_bug_resolution`, `fetch_assigned_bugs`, or `create_bug_report`
160
- call, and a short note gets appended to that tool's own response telling
161
- you it happened (e.g. "Also updated your Claude token usage: 2.1M new
162
- tokens across 3 session(s) this week.").
159
+ plan, resets Monday"), and an optional `resetsAt` (ISO timestamp) whenever
160
+ you can see a concrete reset time (e.g. in the status line's rate-limit
161
+ JSON) — this is what lets the panel show real staleness instead of a
162
+ human having to parse a reset time out of free-text `note`.
163
+ - Ask it to call `report_claude_token_usage` (optionally with `days: N` to
164
+ scan a window other than the default 7) to push a real token/model/context
165
+ breakdown up to the same "Claude Usage" tab — distinct from and
166
+ independent of `report_claude_usage` above. You usually don't need to call
167
+ this directly: it already fires automatically after every successful
168
+ `fetch_bug_context`, `submit_bug_resolution`, `fetch_assigned_bugs`, or
169
+ `create_bug_report` call, genuinely in the background (it's never awaited,
170
+ so it adds no latency and its own response text never shows up in that
171
+ tool's reply) — this standalone call is for an explicit, on-demand check
172
+ with a full summary (tokens by model, current context occupancy, subagent
173
+ share) in the response.
163
174
 
164
175
  ## Notes
165
176
 
@@ -173,3 +184,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(() => {});