claude-mission-control 1.5.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.
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ // Full transcript of one session, parsed into displayable turns.
3
+ // Cursor-based: pass back `nextOffset` to read only what was appended since —
4
+ // that's what powers live-follow in the UI.
5
+ const fsp = require('fs/promises');
6
+
7
+ const MAX_EVENTS = 1200; // initial load keeps the newest N turns
8
+ const USER_CAP = 4000;
9
+ const ASSISTANT_CAP = 8000;
10
+ const TOOL_INPUT_CAP = 240;
11
+
12
+ function toolInputSummary(input) {
13
+ if (!input || typeof input !== 'object') return '';
14
+ // The most readable single field wins; else compact JSON.
15
+ for (const key of ['command', 'description', 'file_path', 'prompt', 'query', 'url', 'skill']) {
16
+ if (typeof input[key] === 'string' && input[key].trim()) {
17
+ return input[key].replace(/\s+/g, ' ').slice(0, TOOL_INPUT_CAP);
18
+ }
19
+ }
20
+ try {
21
+ return JSON.stringify(input).slice(0, TOOL_INPUT_CAP);
22
+ } catch {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ function userText(content) {
28
+ if (typeof content === 'string') return content;
29
+ if (!Array.isArray(content)) return null;
30
+ const parts = [];
31
+ for (const p of content) {
32
+ if (p && p.type === 'text' && typeof p.text === 'string') parts.push(p.text);
33
+ }
34
+ return parts.length ? parts.join('\n') : null;
35
+ }
36
+
37
+ function isHarnessNoise(text) {
38
+ const t = text.trimStart();
39
+ return (
40
+ t.startsWith('<system-reminder>') ||
41
+ t.startsWith('<local-command') ||
42
+ t.startsWith('<command-') ||
43
+ t.startsWith('[SYSTEM NOTIFICATION') ||
44
+ t.startsWith('<task-notification>') ||
45
+ t.startsWith('Base directory for this skill')
46
+ );
47
+ }
48
+
49
+ function parseLine(line, events) {
50
+ if (!line.trim()) return;
51
+ let rec;
52
+ try {
53
+ rec = JSON.parse(line);
54
+ } catch {
55
+ return;
56
+ }
57
+ const ts = rec.timestamp ? Date.parse(rec.timestamp) : null;
58
+
59
+ if (rec.type === 'user' && rec.message && !rec.isSidechain) {
60
+ const text = userText(rec.message.content);
61
+ if (text && text.trim() && !isHarnessNoise(text)) {
62
+ events.push({ kind: 'user', text: text.slice(0, USER_CAP), truncated: text.length > USER_CAP, ts });
63
+ }
64
+ return;
65
+ }
66
+
67
+ if (rec.type === 'assistant' && rec.message && Array.isArray(rec.message.content) && !rec.isSidechain) {
68
+ for (const part of rec.message.content) {
69
+ if (!part) continue;
70
+ if (part.type === 'text' && part.text && part.text.trim()) {
71
+ events.push({ kind: 'assistant', text: part.text.slice(0, ASSISTANT_CAP), truncated: part.text.length > ASSISTANT_CAP, ts });
72
+ } else if (part.type === 'tool_use') {
73
+ events.push({ kind: 'tool', name: part.name || 'tool', input: toolInputSummary(part.input), ts });
74
+ }
75
+ }
76
+ return;
77
+ }
78
+
79
+ if (rec.type === 'system' && rec.subtype === 'away_summary' && rec.content) {
80
+ events.push({ kind: 'note', text: String(rec.content).slice(0, ASSISTANT_CAP), ts });
81
+ }
82
+ }
83
+
84
+ // Read complete lines from `fromOffset` to EOF; a trailing partial line is
85
+ // left for the next call.
86
+ async function readNewLines(file, fromOffset) {
87
+ const st = await fsp.stat(file);
88
+ if (st.size <= fromOffset) return { lines: [], nextOffset: fromOffset };
89
+ const fh = await fsp.open(file, 'r');
90
+ try {
91
+ const len = st.size - fromOffset;
92
+ const buf = Buffer.alloc(len);
93
+ await fh.read(buf, 0, len, fromOffset);
94
+ const lastNl = buf.lastIndexOf(0x0a);
95
+ if (lastNl === -1) return { lines: [], nextOffset: fromOffset };
96
+ return {
97
+ lines: buf.subarray(0, lastNl + 1).toString('utf8').split('\n'),
98
+ nextOffset: fromOffset + lastNl + 1,
99
+ };
100
+ } finally {
101
+ await fh.close();
102
+ }
103
+ }
104
+
105
+ async function sessionTranscript(file, fromOffset = 0) {
106
+ const { lines, nextOffset } = await readNewLines(file, fromOffset);
107
+ const events = [];
108
+ for (const line of lines) parseLine(line, events);
109
+ let truncatedTurns = 0;
110
+ if (fromOffset === 0 && events.length > MAX_EVENTS) {
111
+ truncatedTurns = events.length - MAX_EVENTS;
112
+ events.splice(0, truncatedTurns);
113
+ }
114
+ return { events, truncatedTurns, nextOffset };
115
+ }
116
+
117
+ module.exports = { sessionTranscript };
@@ -0,0 +1,390 @@
1
+ 'use strict';
2
+ // Session transcript metadata from ~/.claude/projects/<enc>/<sessionId>.jsonl.
3
+ // Files reach 14MB; never full-parse. Read head (cwd/branch/first prompt),
4
+ // tail (last-prompt / away_summary), and one streaming substring pass for the
5
+ // last ai-title. Cached by (mtime, size).
6
+ const fsp = require('fs/promises');
7
+ const path = require('path');
8
+ const { CLAUDE_DIR, worktreeRoot } = require('./paths');
9
+ const { estimateCost, totalTokens } = require('./pricing');
10
+
11
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
12
+ const HEAD_BYTES = 64 * 1024;
13
+ const TAIL_BYTES = 16 * 1024;
14
+
15
+ // absPath -> { mtimeMs, size, meta }
16
+ const cache = new Map();
17
+
18
+ async function scanAllTranscripts() {
19
+ let dirs;
20
+ try {
21
+ dirs = await fsp.readdir(PROJECTS_DIR);
22
+ } catch {
23
+ return [];
24
+ }
25
+ const sessions = [];
26
+ for (const dir of dirs) {
27
+ const dirPath = path.join(PROJECTS_DIR, dir);
28
+ let files;
29
+ try {
30
+ files = await fsp.readdir(dirPath);
31
+ } catch {
32
+ continue;
33
+ }
34
+ for (const f of files) {
35
+ if (!f.endsWith('.jsonl')) continue;
36
+ const abs = path.join(dirPath, f);
37
+ const sessionId = f.replace(/\.jsonl$/, '');
38
+ try {
39
+ const meta = await scanFile(abs, sessionId);
40
+ if (meta) {
41
+ const sub = await scanSubagents(path.join(dirPath, sessionId, 'subagents'));
42
+ meta.subagentUsage = sub && sub.usage;
43
+ meta.subagentDays = sub && sub.days;
44
+ meta.subagentCount = sub ? sub.count : 0;
45
+ sessions.push(meta);
46
+ }
47
+ } catch {
48
+ /* unreadable file: skip */
49
+ }
50
+ }
51
+ }
52
+ return sessions;
53
+ }
54
+
55
+ async function scanFile(abs, sessionIdFromName) {
56
+ const st = await fsp.stat(abs);
57
+ const hit = cache.get(abs);
58
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.meta;
59
+ // Incremental scan state survives across rescans of a growing file.
60
+ const scan = hit && hit.scan && st.size >= hit.scan.offset
61
+ ? hit.scan
62
+ : { offset: 0, aiTitle: null, usage: {}, days: {}, lastMsgId: null, lastModel: null };
63
+
64
+ const fh = await fsp.open(abs, 'r');
65
+ let head, tail;
66
+ try {
67
+ const headLen = Math.min(HEAD_BYTES, st.size);
68
+ const headBuf = Buffer.alloc(headLen);
69
+ await fh.read(headBuf, 0, headLen, 0);
70
+ head = headBuf.toString('utf8');
71
+
72
+ const tailLen = Math.min(TAIL_BYTES, st.size);
73
+ const tailBuf = Buffer.alloc(tailLen);
74
+ await fh.read(tailBuf, 0, tailLen, st.size - tailLen);
75
+ tail = tailBuf.toString('utf8');
76
+ } finally {
77
+ await fh.close();
78
+ }
79
+
80
+ const meta = {
81
+ sessionId: sessionIdFromName,
82
+ file: abs,
83
+ cwd: null,
84
+ gitBranch: null,
85
+ startedAt: null,
86
+ lastActivityAt: st.mtimeMs,
87
+ firstUserPrompt: null,
88
+ lastPrompt: null,
89
+ awaySummary: null,
90
+ awaySummaryAt: null,
91
+ aiTitle: null,
92
+ };
93
+
94
+ // Head: first ~40 lines; the first line can be queue-operation without cwd.
95
+ const headLines = head.split('\n').slice(0, 40);
96
+ for (const line of headLines) {
97
+ if (!line.trim()) continue;
98
+ let rec;
99
+ try {
100
+ rec = JSON.parse(line);
101
+ } catch {
102
+ continue; // possibly truncated final head line
103
+ }
104
+ if (meta.cwd === null && typeof rec.cwd === 'string') {
105
+ meta.cwd = rec.cwd;
106
+ meta.gitBranch = rec.gitBranch || null;
107
+ }
108
+ if (meta.startedAt === null && rec.timestamp) {
109
+ const t = Date.parse(rec.timestamp);
110
+ if (!Number.isNaN(t)) meta.startedAt = t;
111
+ }
112
+ if (meta.firstUserPrompt === null) {
113
+ const text = extractUserText(rec);
114
+ if (text) meta.firstUserPrompt = text.slice(0, 120);
115
+ }
116
+ if (meta.cwd && meta.startedAt && meta.firstUserPrompt) break;
117
+ }
118
+
119
+ // Tail: last complete lines carry last-prompt / away_summary.
120
+ const tailLines = tail.split('\n').filter((l) => l.trim());
121
+ for (let i = tailLines.length - 1; i >= 0; i--) {
122
+ let rec;
123
+ try {
124
+ rec = JSON.parse(tailLines[i]);
125
+ } catch {
126
+ continue; // first tail line is usually a partial record
127
+ }
128
+ if (meta.lastPrompt === null && rec.type === 'last-prompt' && rec.lastPrompt) {
129
+ meta.lastPrompt = String(rec.lastPrompt).slice(0, 200);
130
+ }
131
+ if (meta.awaySummary === null && rec.type === 'system' && rec.subtype === 'away_summary' && rec.content) {
132
+ meta.awaySummary = String(rec.content).slice(0, 800);
133
+ const t = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
134
+ meta.awaySummaryAt = Number.isNaN(t) ? null : t;
135
+ }
136
+ }
137
+
138
+ await incrementalScan(abs, st.size, scan);
139
+ meta.aiTitle = scan.aiTitle;
140
+ meta.usage = scan.usage;
141
+ meta.days = scan.days;
142
+ meta.waits = scan.waits || null;
143
+ meta.model = scan.lastModel || null;
144
+
145
+ cache.set(abs, { mtimeMs: st.mtimeMs, size: st.size, meta, scan });
146
+ return meta;
147
+ }
148
+
149
+ // Reads only the bytes appended since the last scan, up to the last complete
150
+ // line. Accumulates the latest ai-title and per-model token usage. Usage is
151
+ // deduped by message id — one API response spans several assistant records
152
+ // that all carry the same usage object.
153
+ async function incrementalScan(abs, size, scan) {
154
+ if (size <= scan.offset) return;
155
+ const fh = await fsp.open(abs, 'r');
156
+ try {
157
+ const len = size - scan.offset;
158
+ const buf = Buffer.alloc(len);
159
+ await fh.read(buf, 0, len, scan.offset);
160
+ const lastNl = buf.lastIndexOf(0x0a);
161
+ if (lastNl === -1) return; // no complete new line yet
162
+ scan.offset += lastNl + 1;
163
+ for (const line of buf.subarray(0, lastNl + 1).toString('utf8').split('\n')) {
164
+ scanLine(line, scan);
165
+ }
166
+ } finally {
167
+ await fh.close();
168
+ }
169
+ }
170
+
171
+ function scanLine(line, scan) {
172
+ if (line.includes('"type":"ai-title"')) {
173
+ try {
174
+ const rec = JSON.parse(line);
175
+ if (rec.aiTitle) scan.aiTitle = rec.aiTitle;
176
+ } catch {
177
+ /* ignore */
178
+ }
179
+ } else if (line.includes('"type":"assistant"') && line.includes('"usage"')) {
180
+ try {
181
+ const rec = JSON.parse(line);
182
+ const m = rec.message;
183
+ if (!m || !m.usage) return;
184
+ if ((m.model || '').startsWith('<')) return; // '<synthetic>' harness records
185
+ if (m.id && m.id === scan.lastMsgId) return;
186
+ scan.lastMsgId = m.id || null;
187
+ const model = m.model || 'unknown';
188
+ scan.lastModel = model; // most recent real model = the session's model
189
+ addUsage(scan.usage, model, m.usage);
190
+ const t = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
191
+ if (!Number.isNaN(t)) {
192
+ scan.lastAssistantTs = t;
193
+ const days = scan.days || (scan.days = {});
194
+ const day = days[dayKey(t)] || (days[dayKey(t)] = {});
195
+ addUsage(day, model, m.usage);
196
+ }
197
+ } catch {
198
+ /* ignore */
199
+ }
200
+ } else if (scan.lastAssistantTs && line.includes('"type":"user"') && !line.includes('"tool_use_id"')) {
201
+ // Your next real prompt after assistant output: bucket the gap.
202
+ // <5s is automation, >30min you were away — neither says anything
203
+ // about response time, but both consume the pending assistant mark.
204
+ try {
205
+ const rec = JSON.parse(line);
206
+ if (!extractUserText(rec)) return;
207
+ const t = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
208
+ if (Number.isNaN(t)) return;
209
+ const gap = t - scan.lastAssistantTs;
210
+ scan.lastAssistantTs = null;
211
+ if (gap < 5000 || gap > 30 * 60000) return;
212
+ const waits = scan.waits || (scan.waits = [0, 0, 0, 0]);
213
+ waits[gap < 30000 ? 0 : gap < 120000 ? 1 : gap < 600000 ? 2 : 3]++;
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ }
218
+ }
219
+
220
+ function addUsage(byModel, model, usage) {
221
+ const u = byModel[model] || (byModel[model] = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 });
222
+ u.input += usage.input_tokens || 0;
223
+ u.output += usage.output_tokens || 0;
224
+ u.cacheRead += usage.cache_read_input_tokens || 0;
225
+ u.cacheCreation += usage.cache_creation_input_tokens || 0;
226
+ }
227
+
228
+ // Local-time day bucket key, e.g. '2026-08-10'.
229
+ function dayKey(ms) {
230
+ const d = new Date(ms);
231
+ const p = (n) => String(n).padStart(2, '0');
232
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
233
+ }
234
+
235
+ function extractUserText(rec) {
236
+ if (rec.type !== 'user' || !rec.message) return null;
237
+ const c = rec.message.content;
238
+ if (typeof c === 'string') return cleanPrompt(c);
239
+ if (Array.isArray(c)) {
240
+ for (const part of c) {
241
+ if (part && part.type === 'text' && part.text) return cleanPrompt(part.text);
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
247
+ const NOISE_PREFIXES = [
248
+ '[SYSTEM NOTIFICATION',
249
+ 'Base directory for this skill',
250
+ 'Caveat: The messages below',
251
+ 'This session is being continued from',
252
+ ];
253
+
254
+ function cleanPrompt(s) {
255
+ const t = s.trim();
256
+ // Anything tag-shaped is harness plumbing (<system-reminder>, <command-*>,
257
+ // <local-command-caveat>, <task-notification>, ...), not a human prompt.
258
+ if (!t || t.startsWith('<') || NOISE_PREFIXES.some((p) => t.startsWith(p))) return null;
259
+ return t.replace(/\s+/g, ' ');
260
+ }
261
+
262
+ // Subagent transcripts live beside the main file; their tokens are real
263
+ // spend too. Same incremental scan, usage only.
264
+ const subCache = new Map(); // absPath -> { mtimeMs, size, scan }
265
+
266
+ async function scanSubagents(dir) {
267
+ let files;
268
+ try {
269
+ files = await fsp.readdir(dir);
270
+ } catch {
271
+ return null;
272
+ }
273
+ const total = {};
274
+ const totalDays = {};
275
+ let count = 0;
276
+ for (const f of files) {
277
+ if (!f.endsWith('.jsonl')) continue;
278
+ count++;
279
+ const abs = path.join(dir, f);
280
+ try {
281
+ const st = await fsp.stat(abs);
282
+ let entry = subCache.get(abs);
283
+ if (!entry || entry.mtimeMs !== st.mtimeMs || entry.size !== st.size) {
284
+ const scan = entry && entry.scan && st.size >= entry.scan.offset
285
+ ? entry.scan
286
+ : { offset: 0, aiTitle: null, usage: {}, days: {}, lastMsgId: null };
287
+ await incrementalScan(abs, st.size, scan);
288
+ entry = { mtimeMs: st.mtimeMs, size: st.size, scan };
289
+ subCache.set(abs, entry);
290
+ }
291
+ mergeUsage(total, entry.scan.usage);
292
+ mergeDays(totalDays, entry.scan.days);
293
+ } catch {
294
+ /* skip */
295
+ }
296
+ }
297
+ return count ? { usage: total, days: totalDays, count } : null;
298
+ }
299
+
300
+ // Compact live-board summary; tokens rounded to 0.1M so the state
301
+ // fingerprint stays stable between scans.
302
+ function subagentSummary(meta) {
303
+ if (!meta.subagentCount) return null;
304
+ return {
305
+ count: meta.subagentCount,
306
+ mtok: Math.round(totalTokens(meta.subagentUsage) / 100000) / 10,
307
+ };
308
+ }
309
+
310
+ function mergeUsage(target, src) {
311
+ for (const [model, u] of Object.entries(src || {})) {
312
+ const t = target[model] || (target[model] = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 });
313
+ t.input += u.input || 0;
314
+ t.output += u.output || 0;
315
+ t.cacheRead += u.cacheRead || 0;
316
+ t.cacheCreation += u.cacheCreation || 0;
317
+ }
318
+ return target;
319
+ }
320
+
321
+ // Main-loop usage plus subagent usage, merged.
322
+ function combinedUsage(meta) {
323
+ if (!meta.subagentUsage) return meta.usage || {};
324
+ return mergeUsage(mergeUsage({}, meta.usage), meta.subagentUsage);
325
+ }
326
+
327
+ function mergeDays(target, src) {
328
+ for (const [day, byModel] of Object.entries(src || {})) {
329
+ target[day] = mergeUsage(target[day] || {}, byModel);
330
+ }
331
+ return target;
332
+ }
333
+
334
+ // Main-loop day buckets plus subagent day buckets, merged.
335
+ function combinedDays(meta) {
336
+ if (!meta.subagentDays) return meta.days || {};
337
+ return mergeDays(mergeDays({}, meta.days), meta.subagentDays);
338
+ }
339
+
340
+ // Sum per-session day buckets into a chartable series: one entry per local
341
+ // day for the trailing numDays window ending today, zero-filled.
342
+ function dailyCostSeries(daysList, numDays, today = Date.now()) {
343
+ const merged = {};
344
+ for (const d of daysList) mergeDays(merged, d);
345
+ const now = new Date(today);
346
+ const series = [];
347
+ for (let i = numDays - 1; i >= 0; i--) {
348
+ const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
349
+ const usage = merged[dayKey(d.getTime())] || {};
350
+ series.push({
351
+ t: d.getTime(),
352
+ cost: Math.round(estimateCost(usage) * 100) / 100,
353
+ tokens: totalTokens(usage),
354
+ });
355
+ }
356
+ return series;
357
+ }
358
+
359
+ // Best display title for a session, with its provenance.
360
+ function sessionTitle(meta) {
361
+ if (meta.aiTitle) return { title: meta.aiTitle, source: 'ai-title' };
362
+ if (meta.firstUserPrompt) return { title: meta.firstUserPrompt, source: 'first-prompt' };
363
+ if (meta.lastPrompt) return { title: meta.lastPrompt, source: 'last-prompt' };
364
+ return { title: meta.sessionId.slice(0, 8), source: 'session-id' };
365
+ }
366
+
367
+ // Group scanned sessions by canonical project root (worktrees fold in).
368
+ function groupByProject(sessions) {
369
+ const byProject = new Map(); // lowercase root -> { path, sessions: [] }
370
+ for (const meta of sessions) {
371
+ if (!meta.cwd) continue; // can't place it without a real path
372
+ const { root, worktree } = worktreeRoot(meta.cwd);
373
+ const key = root.toLowerCase();
374
+ let e = byProject.get(key);
375
+ if (!e) {
376
+ e = { path: root, sessions: [] };
377
+ byProject.set(key, e);
378
+ }
379
+ e.sessions.push({ ...meta, worktree, projectPath: root });
380
+ }
381
+ for (const e of byProject.values()) {
382
+ e.sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
383
+ }
384
+ return byProject;
385
+ }
386
+
387
+ module.exports = {
388
+ scanAllTranscripts, groupByProject, sessionTitle, combinedUsage, mergeUsage,
389
+ scanLine, mergeDays, combinedDays, dailyCostSeries, subagentSummary, dayKey,
390
+ };
package/lib/usage.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ // Usage meters via Anthropic's OAuth usage endpoint, authenticated with the
3
+ // same Keychain credentials Claude Code itself uses. The token lives in
4
+ // memory only — never written, never logged. macOS only (Keychain); other
5
+ // platforms fall back to the statusline cache in quota.js.
6
+ //
7
+ // This is the dashboard's one automatic network call, to Anthropic only,
8
+ // for the account's own usage numbers. Toggle: config.usageApi.
9
+ const { execFile } = require('child_process');
10
+ const { readConfig } = require('./config');
11
+
12
+ const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
13
+ const FETCH_TTL_MS = 5 * 60 * 1000; // be polite: at most one call per 5 min
14
+
15
+ let tokenCache = { token: null, expiresAt: 0 };
16
+ let usageCache = { at: 0, quota: null };
17
+
18
+ function readKeychainToken() {
19
+ return new Promise((resolve) => {
20
+ execFile(
21
+ 'security',
22
+ ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
23
+ { timeout: 5000 },
24
+ (err, stdout) => {
25
+ if (err) return resolve(null);
26
+ try {
27
+ const oauth = JSON.parse(stdout).claudeAiOauth;
28
+ if (oauth && oauth.accessToken) {
29
+ resolve({ token: oauth.accessToken, expiresAt: oauth.expiresAt || 0 });
30
+ return;
31
+ }
32
+ } catch { /* fall through */ }
33
+ resolve(null);
34
+ }
35
+ );
36
+ });
37
+ }
38
+
39
+ async function getToken(forceReread) {
40
+ const now = Date.now();
41
+ // Claude Code refreshes the Keychain item as it runs; re-read when ours
42
+ // is missing, near expiry, or a request just got a 401.
43
+ if (forceReread || !tokenCache.token || now > tokenCache.expiresAt - 60_000) {
44
+ const fresh = await readKeychainToken();
45
+ if (fresh) tokenCache = fresh;
46
+ else if (forceReread) tokenCache = { token: null, expiresAt: 0 };
47
+ }
48
+ return tokenCache.token;
49
+ }
50
+
51
+ function normalize(body) {
52
+ const five = body.five_hour || {};
53
+ const week = body.seven_day || {};
54
+ const extra = body.extra_usage || {};
55
+ const scale = Math.pow(10, extra.decimal_places ?? 2);
56
+ return {
57
+ utilization: typeof five.utilization === 'number' ? five.utilization : null,
58
+ weeklyUtilization: typeof week.utilization === 'number' ? week.utilization : null,
59
+ resetsAt: five.resets_at || null,
60
+ weeklyResetsAt: week.resets_at || null,
61
+ costUsed: typeof extra.used_credits === 'number' ? extra.used_credits / scale : null,
62
+ costLimit: typeof extra.monthly_limit === 'number' ? extra.monthly_limit / scale : null,
63
+ currency: extra.currency || 'USD',
64
+ extraUsageEnabled: extra.is_enabled === true,
65
+ stale: false,
66
+ source: 'api',
67
+ };
68
+ }
69
+
70
+ async function requestUsage(token) {
71
+ const r = await fetch(ENDPOINT, {
72
+ headers: {
73
+ Authorization: `Bearer ${token}`,
74
+ 'anthropic-beta': 'oauth-2025-04-20',
75
+ 'User-Agent': 'claude-dashboard',
76
+ },
77
+ });
78
+ if (!r.ok) {
79
+ const e = new Error(`usage endpoint ${r.status}`);
80
+ e.status = r.status;
81
+ throw e;
82
+ }
83
+ return normalize(await r.json());
84
+ }
85
+
86
+ // Returns a quota object, or null (caller falls back to the file cache).
87
+ async function fetchOauthUsage() {
88
+ if (process.platform !== 'darwin') return null;
89
+ if (readConfig().usageApi === false) return null;
90
+ const now = Date.now();
91
+ if (usageCache.quota && now - usageCache.at < FETCH_TTL_MS) return usageCache.quota;
92
+
93
+ try {
94
+ let token = await getToken(false);
95
+ if (!token) return null;
96
+ let quota;
97
+ try {
98
+ quota = await requestUsage(token);
99
+ } catch (e) {
100
+ if (e.status !== 401) throw e;
101
+ token = await getToken(true); // stale token — re-read Keychain once
102
+ if (!token) return null;
103
+ quota = await requestUsage(token);
104
+ }
105
+ usageCache = { at: now, quota };
106
+ return quota;
107
+ } catch {
108
+ // Keep serving the last good numbers briefly; mark stale past the TTL.
109
+ if (usageCache.quota && now - usageCache.at < 3 * FETCH_TTL_MS) {
110
+ return { ...usageCache.quota, stale: true };
111
+ }
112
+ return null;
113
+ }
114
+ }
115
+
116
+ module.exports = { fetchOauthUsage };
@@ -0,0 +1,82 @@
1
+ #!/bin/bash
2
+ # SwiftBar plugin for the Claude Dashboard. Refreshes every 15s (filename).
3
+ # <swiftbar.title>Claude Dashboard</swiftbar.title>
4
+ # <swiftbar.hideAbout>true</swiftbar.hideAbout>
5
+ # <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>
6
+ # <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>
7
+
8
+ PORT="${CLAUDE_DASH_PORT:-4517}"
9
+ STATE=$(curl -sf --max-time 3 "http://127.0.0.1:$PORT/api/state" 2>/dev/null)
10
+
11
+ if [ -z "$STATE" ]; then
12
+ echo "❯ · | color=gray"
13
+ echo "---"
14
+ echo "Dashboard server offline"
15
+ echo "Open dashboard | href=http://127.0.0.1:$PORT"
16
+ exit 0
17
+ fi
18
+
19
+ echo "$STATE" | /usr/bin/python3 -c '
20
+ import json, sys, time
21
+
22
+ s = json.load(sys.stdin)
23
+ live = s.get("liveSessions", [])
24
+ projects = s.get("projects", [])
25
+ waiting = [l for l in live if l.get("status") == "waiting"]
26
+ quiet = [l for l in live if l.get("quietMin")]
27
+
28
+ # Menu bar title: attention states change the glyph so it reads at a glance.
29
+ if waiting:
30
+ print(f"❯ {len(waiting)}⚠ | color=#e0a63a")
31
+ elif quiet:
32
+ print(f"❯ {len(live)}?")
33
+ elif live:
34
+ print(f"❯ {len(live)}")
35
+ else:
36
+ print("❯ | color=gray")
37
+
38
+ print("---")
39
+
40
+ def elapsed(ms):
41
+ if not ms: return ""
42
+ m = int((time.time() * 1000 - ms) / 60000)
43
+ return f"{m//60}h {m%60}m" if m >= 60 else f"{m}m"
44
+
45
+ if live:
46
+ for l in live:
47
+ name = l.get("projectName", "?")
48
+ if l.get("status") == "waiting":
49
+ what = l.get("waitingFor") or "waiting"
50
+ print(f"{name} — {what} | color=#e0a63a")
51
+ elif l.get("quietMin"):
52
+ qm = l.get("quietMin")
53
+ print(f"{name} — busy, quiet {qm}m | color=#e0a63a")
54
+ else:
55
+ task = (l.get("currentTask") or {}).get("activeForm") or "busy"
56
+ age = elapsed(l.get("startedAt"))
57
+ print(f"{name} — {task} ({age})")
58
+ else:
59
+ print("No sessions running | color=gray")
60
+
61
+ attn = []
62
+ for p in projects:
63
+ g = p.get("git") or {}
64
+ if not g.get("isRepo"): continue
65
+ changes = (g.get("dirty") or 0) + (g.get("untracked") or 0)
66
+ ahead = g.get("ahead") or 0
67
+ if changes or ahead:
68
+ bits = (f"●{changes}" if changes else "") + (f" ↑{ahead}" if ahead else "")
69
+ pname = p.get("name")
70
+ attn.append(f"{pname} {bits.strip()}")
71
+ if attn:
72
+ print("---")
73
+ print(f"Unpushed work — {len(attn)} repos | color=gray")
74
+ for a in attn[:8]:
75
+ print(a)
76
+
77
+ spend = sum(p.get("spend7d") or 0 for p in projects)
78
+ print("---")
79
+ if spend >= 0.005:
80
+ print(f"7d est. value ≈${spend:.0f} | color=gray")
81
+ print("Open dashboard | href=http://127.0.0.1:'"$PORT"'")
82
+ '