claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { basename, dirname, extname } from 'node:path';
|
|
2
|
+
import { dayKey, weekKey, DATE_SUFFIX_RE, cleanProjectName } from './scanner.js';
|
|
3
|
+
import { fmtCost } from './pricing.js';
|
|
4
|
+
import { languageOf } from './languages.js';
|
|
5
|
+
import { detectGitBranch, detectGitRepo } from './git.js';
|
|
6
|
+
|
|
7
|
+
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
8
|
+
|
|
9
|
+
function fmtLinesNet(n) {
|
|
10
|
+
if (!n) return '0';
|
|
11
|
+
const sign = n > 0 ? '+' : '−';
|
|
12
|
+
return `${sign}${fmtNum(Math.abs(n))}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function topEntry(map) {
|
|
16
|
+
if (!map) return null;
|
|
17
|
+
let best = null;
|
|
18
|
+
for (const [k, v] of Object.entries(map)) {
|
|
19
|
+
if (!best || v > best.v) best = { k, v };
|
|
20
|
+
}
|
|
21
|
+
return best;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function topN(map, n = 3) {
|
|
25
|
+
if (!map) return [];
|
|
26
|
+
return Object.entries(map).sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fmtHourLocal(ms) {
|
|
30
|
+
if (!ms) return '';
|
|
31
|
+
const d = new Date(ms);
|
|
32
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fmtNum(n) {
|
|
36
|
+
if (!n) return '0';
|
|
37
|
+
if (n < 1000) return `${n}`;
|
|
38
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
39
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
40
|
+
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fmtDuration(ms) {
|
|
44
|
+
if (!ms || ms < 0) return '0s';
|
|
45
|
+
const s = Math.floor(ms / 1000);
|
|
46
|
+
const h = Math.floor(s / 3600);
|
|
47
|
+
const m = Math.floor((s % 3600) / 60);
|
|
48
|
+
const sec = s % 60;
|
|
49
|
+
if (h) return `${h}h ${m}m`;
|
|
50
|
+
if (m) return `${m}m ${sec}s`;
|
|
51
|
+
return `${sec}s`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtHours(ms) {
|
|
55
|
+
if (!ms || ms < 0) return '0h';
|
|
56
|
+
const hours = ms / 3_600_000;
|
|
57
|
+
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
|
58
|
+
if (hours < 10) return `${hours.toFixed(1)}h`;
|
|
59
|
+
return `${Math.round(hours)}h`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function plural(n, sing, plur) {
|
|
63
|
+
const word = n === 1 ? sing : (plur || `${sing}s`);
|
|
64
|
+
return `${fmtNum(n)} ${word}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// claude-opus-4-7 → Opus 4.7 · claude-sonnet-4-6-20250514 → Sonnet 4.6
|
|
68
|
+
function humanModel(id) {
|
|
69
|
+
if (!id || typeof id !== 'string') return 'Claude';
|
|
70
|
+
const m = id.match(/(opus|sonnet|haiku)[^\d]*(\d+)[-.](\d+)/i);
|
|
71
|
+
if (m) return `${m[1][0].toUpperCase()}${m[1].slice(1).toLowerCase()} ${m[2]}.${m[3]}`;
|
|
72
|
+
if (/opus/i.test(id)) return 'Opus';
|
|
73
|
+
if (/sonnet/i.test(id)) return 'Sonnet';
|
|
74
|
+
if (/haiku/i.test(id)) return 'Haiku';
|
|
75
|
+
return 'Claude';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// mcp__claude_ai_Vercel__deploy_to_vercel → Vercel:deploy · mcp__github__list → github:list
|
|
79
|
+
function humanTool(name) {
|
|
80
|
+
if (!name) return '';
|
|
81
|
+
if (name.startsWith('mcp__')) {
|
|
82
|
+
const parts = name.split('__').filter(Boolean);
|
|
83
|
+
if (parts.length >= 3) {
|
|
84
|
+
const server = parts[parts.length - 2].replace(/^claude[_ ]?ai[_ ]?/i, '');
|
|
85
|
+
const action = parts[parts.length - 1];
|
|
86
|
+
return `${server}:${action}`;
|
|
87
|
+
}
|
|
88
|
+
return parts.slice(1).join(':');
|
|
89
|
+
}
|
|
90
|
+
return name;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// "C--Users-simmo-Downloads-CLAUDE" → "CLAUDE"
|
|
94
|
+
// "-home-alice-projects-my-app" → "my-app"
|
|
95
|
+
// "archive-2026-04-25T185311Z" → "archive"
|
|
96
|
+
function humanProject(slugOrPath) {
|
|
97
|
+
if (!slugOrPath) return '';
|
|
98
|
+
const raw = String(slugOrPath);
|
|
99
|
+
let name;
|
|
100
|
+
if (raw.includes('/') || raw.includes('\\')) {
|
|
101
|
+
name = basename(raw);
|
|
102
|
+
} else if (/^[A-Za-z]--/.test(raw)
|
|
103
|
+
|| raw.startsWith('-home-')
|
|
104
|
+
|| raw.startsWith('-Users-')
|
|
105
|
+
|| raw.startsWith('-tmp-')
|
|
106
|
+
|| raw.startsWith('-var-')
|
|
107
|
+
|| raw.startsWith('-opt-')) {
|
|
108
|
+
// Path-style slug — take the last segment.
|
|
109
|
+
const parts = raw.split('-').filter((p) => p && p !== 'C');
|
|
110
|
+
name = parts[parts.length - 1] || raw;
|
|
111
|
+
} else {
|
|
112
|
+
name = raw;
|
|
113
|
+
}
|
|
114
|
+
return cleanProjectName(name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function statusVerbose(status, currentToolPretty, idleMs) {
|
|
118
|
+
switch (status) {
|
|
119
|
+
case 'working': return currentToolPretty ? `Using ${currentToolPretty}` : 'Working';
|
|
120
|
+
case 'thinking': return 'Thinking';
|
|
121
|
+
case 'notification': return 'Waiting on you';
|
|
122
|
+
case 'idle': {
|
|
123
|
+
if (idleMs && idleMs > 60_000) {
|
|
124
|
+
const mins = Math.floor(idleMs / 60_000);
|
|
125
|
+
if (mins < 60) return `Idle · ${mins}m`;
|
|
126
|
+
const hours = Math.floor(mins / 60);
|
|
127
|
+
return `Idle · ${hours}h`;
|
|
128
|
+
}
|
|
129
|
+
return 'Standing by';
|
|
130
|
+
}
|
|
131
|
+
case 'stale': return 'Away';
|
|
132
|
+
default: return status || 'Active';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fmtHour(h) {
|
|
137
|
+
const n = Number(h);
|
|
138
|
+
if (!Number.isFinite(n)) return '';
|
|
139
|
+
const hh = String(n).padStart(2, '0');
|
|
140
|
+
return `${hh}:00`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Trim "C:\repo\src\app\page.tsx" → "src/app/page.tsx" (3 trailing segments).
|
|
144
|
+
function prettyFilePath(p) {
|
|
145
|
+
if (!p) return '';
|
|
146
|
+
const norm = String(p).replace(/\\/g, '/');
|
|
147
|
+
const parts = norm.split('/').filter(Boolean);
|
|
148
|
+
if (parts.length <= 3) return parts.join('/');
|
|
149
|
+
return parts.slice(-3).join('/');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function buildVars(state, config, aggregate) {
|
|
153
|
+
const sessionReal = (state.tokens?.input || 0) + (state.tokens?.output || 0);
|
|
154
|
+
const sessionCacheRead = state.tokens?.cacheRead || 0;
|
|
155
|
+
const sessionCacheWrite = state.tokens?.cacheWrite || 0;
|
|
156
|
+
// {tokens} / {tokensFmt} now means the grand total (in + out + cache).
|
|
157
|
+
const sessionTokens = sessionReal + sessionCacheRead + sessionCacheWrite;
|
|
158
|
+
const duration = state.sessionStart ? Date.now() - state.sessionStart : 0;
|
|
159
|
+
const projectPretty = humanProject(state.cwd) || 'Claude Code';
|
|
160
|
+
const currentToolPretty = humanTool(state.currentTool);
|
|
161
|
+
const modelPretty = humanModel(state.model);
|
|
162
|
+
|
|
163
|
+
const agg = aggregate || {};
|
|
164
|
+
const allReal = (agg.inputTokens || 0) + (agg.outputTokens || 0);
|
|
165
|
+
const allCacheRead = agg.cacheReadTokens || 0;
|
|
166
|
+
const allCacheWrite = agg.cacheWriteTokens || 0;
|
|
167
|
+
const allCache = allCacheRead + allCacheWrite;
|
|
168
|
+
const allBillable = allReal + allCacheWrite;
|
|
169
|
+
// {allTokens} / {allTokensFmt} now means the grand total across history.
|
|
170
|
+
const allTotal = allReal + allCache;
|
|
171
|
+
const liveSessions = state.liveSessions || [];
|
|
172
|
+
const concurrent = liveSessions.length;
|
|
173
|
+
// "Other" sessions beyond this one — what you actually want to gate the
|
|
174
|
+
// concurrent frame on (== 1 just means "you", == 2+ is interesting).
|
|
175
|
+
const concurrentOther = Math.max(0, concurrent - 1);
|
|
176
|
+
const concurrentListPretty = liveSessions
|
|
177
|
+
.slice(0, 3)
|
|
178
|
+
.map((s) => (typeof s === 'string' ? humanProject(s) : humanProject(s.cwd || s.project || '')))
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join(', ') || '—';
|
|
181
|
+
|
|
182
|
+
const sessionActive = state.sessionStart && state.status !== 'stale' ? 1 : 0;
|
|
183
|
+
|
|
184
|
+
// Today's per-day bucket (from aggregate.byDay) — falls back to zeros.
|
|
185
|
+
const today = agg.byDay?.[dayKey(Date.now())] || {
|
|
186
|
+
activeMs: 0, userMessages: 0, toolCalls: 0,
|
|
187
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, sessions: 0,
|
|
188
|
+
};
|
|
189
|
+
const todayReal = (today.inputTokens || 0) + (today.outputTokens || 0);
|
|
190
|
+
const todayCache = (today.cacheReadTokens || 0) + (today.cacheWriteTokens || 0);
|
|
191
|
+
// {todayTokens} / {todayTokensFmt} = grand total today (in + out + cache).
|
|
192
|
+
const todayTokensSum = todayReal + todayCache;
|
|
193
|
+
|
|
194
|
+
const bestDay = agg.bestDay || null;
|
|
195
|
+
|
|
196
|
+
// This week's bucket.
|
|
197
|
+
const thisWeek = agg.byWeek?.[weekKey(Date.now())] || {
|
|
198
|
+
activeMs: 0, userMessages: 0, toolCalls: 0,
|
|
199
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, sessions: 0,
|
|
200
|
+
};
|
|
201
|
+
const weekTokensSum = (thisWeek.inputTokens || 0) + (thisWeek.outputTokens || 0)
|
|
202
|
+
+ (thisWeek.cacheReadTokens || 0) + (thisWeek.cacheWriteTokens || 0);
|
|
203
|
+
|
|
204
|
+
// Peak hour-of-day.
|
|
205
|
+
const peak = agg.peakHour || null;
|
|
206
|
+
|
|
207
|
+
// Top-edited file.
|
|
208
|
+
const hotspot = agg.topEditedFiles?.[0] || null;
|
|
209
|
+
|
|
210
|
+
// Per-project stats for the current cwd, keyed by the cleaned basename so
|
|
211
|
+
// it lines up with how scanner.aggregateFrom stores them.
|
|
212
|
+
const cwdLeaf = state.cwd ? state.cwd.split(/[\\/]/).filter(Boolean).pop() || '' : '';
|
|
213
|
+
const projectKey = cleanProjectName(cwdLeaf);
|
|
214
|
+
const projectStats = projectKey ? (agg.projects?.[projectKey] || null) : null;
|
|
215
|
+
|
|
216
|
+
// Phase 1 enrichments — code churn, languages, bash, web, subagents, cost.
|
|
217
|
+
const todayLinesAdded = today.linesAdded || 0;
|
|
218
|
+
const todayLinesRemoved = today.linesRemoved || 0;
|
|
219
|
+
const todayLinesNet = todayLinesAdded - todayLinesRemoved;
|
|
220
|
+
const weekLinesAdded = thisWeek.linesAdded || 0;
|
|
221
|
+
const weekLinesRemoved = thisWeek.linesRemoved || 0;
|
|
222
|
+
const weekLinesNet = weekLinesAdded - weekLinesRemoved;
|
|
223
|
+
const allLinesAdded = agg.linesAdded || 0;
|
|
224
|
+
const allLinesRemoved = agg.linesRemoved || 0;
|
|
225
|
+
const allLinesNet = (agg.linesNet ?? (allLinesAdded - allLinesRemoved));
|
|
226
|
+
|
|
227
|
+
// Top language overall (by edits).
|
|
228
|
+
const langSorted = Object.entries(agg.languages || {})
|
|
229
|
+
.sort((x, y) => (y[1].edits || 0) - (x[1].edits || 0));
|
|
230
|
+
const topLang = langSorted[0] || null;
|
|
231
|
+
const languagesLabel = langSorted.slice(0, 3).map(([n]) => n).join(' · ');
|
|
232
|
+
|
|
233
|
+
const topBash = topEntry(agg.bashCommands);
|
|
234
|
+
const topDomain = topEntry(agg.webDomains);
|
|
235
|
+
const topSubagent = topEntry(agg.subagents);
|
|
236
|
+
|
|
237
|
+
// MCP vs built-in.
|
|
238
|
+
const mcpCalls = agg.mcpToolCalls || 0;
|
|
239
|
+
const builtinCalls = agg.builtinToolCalls || 0;
|
|
240
|
+
const totalToolCalls = mcpCalls + builtinCalls;
|
|
241
|
+
const mcpPct = totalToolCalls > 0 ? Math.round((mcpCalls / totalToolCalls) * 100) : 0;
|
|
242
|
+
|
|
243
|
+
// Cost.
|
|
244
|
+
const todayCost = today.cost || 0;
|
|
245
|
+
const weekCost = thisWeek.cost || 0;
|
|
246
|
+
const allCost = agg.estimatedCost || 0;
|
|
247
|
+
// Per-project cost for the current cwd's project.
|
|
248
|
+
const projectCost = projectStats?.cost || 0;
|
|
249
|
+
|
|
250
|
+
// Weekday name from today's date.
|
|
251
|
+
const weekdayLabel = WEEKDAY_NAMES[new Date().getDay()];
|
|
252
|
+
|
|
253
|
+
// Earliest activity timestamp today → "started 09:14".
|
|
254
|
+
const todayStartLabel = today.firstTs ? `started ${fmtHourLocal(today.firstTs)}` : '';
|
|
255
|
+
|
|
256
|
+
const notificationCount = agg.notifications || 0;
|
|
257
|
+
|
|
258
|
+
// Streak milestones: every multiple of 7, plus 30/60/100/365.
|
|
259
|
+
const streak = agg.streak || 0;
|
|
260
|
+
const streakIsMilestone = streak > 0
|
|
261
|
+
&& (streak % 7 === 0 || streak === 30 || streak === 60 || streak === 100 || streak === 365)
|
|
262
|
+
? 1 : 0;
|
|
263
|
+
|
|
264
|
+
// Idle duration for sleeker idle copy.
|
|
265
|
+
const idleMs = state.status === 'idle' && state.lastActivity
|
|
266
|
+
? Math.max(0, Date.now() - state.lastActivity)
|
|
267
|
+
: 0;
|
|
268
|
+
|
|
269
|
+
const currentFilePretty = prettyFilePath(state.currentFile);
|
|
270
|
+
|
|
271
|
+
// ── File / directory / language vars ──────────────────────────────────────
|
|
272
|
+
// Derived from state.currentFile. All empty-string when no file is active,
|
|
273
|
+
// which keeps `requires`-based frame gating working unchanged.
|
|
274
|
+
const currentFileNorm = state.currentFile ? String(state.currentFile).replace(/\\/g, '/') : '';
|
|
275
|
+
const fileName = currentFileNorm ? basename(currentFileNorm) : '';
|
|
276
|
+
const fileExt = currentFileNorm ? (extname(currentFileNorm).toLowerCase() || '') : '';
|
|
277
|
+
const fileLang = currentFileNorm ? (languageOf(currentFileNorm) || '') : '';
|
|
278
|
+
const fileLangUpper = fileLang ? fileLang.toUpperCase() : '';
|
|
279
|
+
const fullDirName = currentFileNorm ? dirname(currentFileNorm) : '';
|
|
280
|
+
const dirNameOnly = fullDirName ? basename(fullDirName) : '';
|
|
281
|
+
|
|
282
|
+
// ── Git vars ──────────────────────────────────────────────────────────────
|
|
283
|
+
// Cached per-cwd in src/git.js. Empty strings when not in a repo.
|
|
284
|
+
const gitBranch = detectGitBranch(state.cwd) || '';
|
|
285
|
+
const gitRepo = detectGitRepo(state.cwd) || '';
|
|
286
|
+
|
|
287
|
+
const messages = state.messages || 0;
|
|
288
|
+
const tools = state.tools || 0;
|
|
289
|
+
const filesEdited = (state.filesEdited || []).length;
|
|
290
|
+
const filesRead = (state.filesRead || []).length;
|
|
291
|
+
const filesOpened = (state.filesOpened || []).length;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
// session — raw
|
|
295
|
+
status: state.status || 'idle',
|
|
296
|
+
statusVerbose: statusVerbose(state.status, currentToolPretty, idleMs),
|
|
297
|
+
idleMs,
|
|
298
|
+
statusIcon: config?.statusIcons?.[state.status] || state.status || 'idle',
|
|
299
|
+
project: projectPretty,
|
|
300
|
+
projectPretty,
|
|
301
|
+
cwd: state.cwd || '',
|
|
302
|
+
model: state.model || 'claude',
|
|
303
|
+
modelPretty,
|
|
304
|
+
messages,
|
|
305
|
+
tools,
|
|
306
|
+
filesOpened,
|
|
307
|
+
filesEdited,
|
|
308
|
+
filesRead,
|
|
309
|
+
// session tokens — defaults are grand total. Use *Real for in+out only.
|
|
310
|
+
tokens: sessionTokens,
|
|
311
|
+
tokensFmt: fmtNum(sessionTokens),
|
|
312
|
+
tokensReal: sessionReal,
|
|
313
|
+
tokensRealFmt: fmtNum(sessionReal),
|
|
314
|
+
inputTokens: fmtNum(state.tokens?.input || 0),
|
|
315
|
+
outputTokens: fmtNum(state.tokens?.output || 0),
|
|
316
|
+
cacheTokens: fmtNum(sessionCacheRead + sessionCacheWrite),
|
|
317
|
+
cacheReadTokens: fmtNum(sessionCacheRead),
|
|
318
|
+
cacheWriteTokens: fmtNum(sessionCacheWrite),
|
|
319
|
+
duration: fmtDuration(duration),
|
|
320
|
+
durationHours: fmtHours(duration),
|
|
321
|
+
currentTool: state.currentTool || '',
|
|
322
|
+
currentToolPretty,
|
|
323
|
+
currentFile: state.currentFile || '',
|
|
324
|
+
currentFilePretty,
|
|
325
|
+
|
|
326
|
+
// ── File / directory / language (v0.3.6) ────────────────────
|
|
327
|
+
fileName,
|
|
328
|
+
fileExt,
|
|
329
|
+
fileLang,
|
|
330
|
+
fileLangUpper,
|
|
331
|
+
dirName: dirNameOnly,
|
|
332
|
+
fullDirName,
|
|
333
|
+
|
|
334
|
+
// ── Git (v0.3.6) ────────────────────────────────────────────
|
|
335
|
+
gitBranch,
|
|
336
|
+
gitRepo,
|
|
337
|
+
|
|
338
|
+
// ── App identity (v0.3.6) ───────────────────────────────────
|
|
339
|
+
appName: config?.appName || 'Claude Code',
|
|
340
|
+
|
|
341
|
+
// Literal single space — handy for blanking a line without `requires`.
|
|
342
|
+
empty: ' ',
|
|
343
|
+
|
|
344
|
+
// pluralized session labels
|
|
345
|
+
messagesLabel: plural(messages, 'prompt'),
|
|
346
|
+
toolsLabel: plural(tools, 'tool call'),
|
|
347
|
+
filesEditedLabel: plural(filesEdited, 'edit'),
|
|
348
|
+
filesReadLabel: plural(filesRead, 'file read'),
|
|
349
|
+
filesOpenedLabel: plural(filesOpened, 'file'),
|
|
350
|
+
|
|
351
|
+
// session lifecycle flag (for `requires` gating)
|
|
352
|
+
sessionActive,
|
|
353
|
+
|
|
354
|
+
// concurrent / live
|
|
355
|
+
concurrent,
|
|
356
|
+
concurrentOther,
|
|
357
|
+
concurrentLabel: plural(concurrent, 'live session'),
|
|
358
|
+
concurrentOtherLabel: plural(concurrentOther, 'other session'),
|
|
359
|
+
concurrentListPretty,
|
|
360
|
+
|
|
361
|
+
// all-time tokens — defaults are grand total incl. cache reads.
|
|
362
|
+
allTokens: allTotal,
|
|
363
|
+
allTokensFmt: fmtNum(allTotal),
|
|
364
|
+
allTokensReal: allReal,
|
|
365
|
+
allTokensRealFmt: fmtNum(allReal),
|
|
366
|
+
allBillable,
|
|
367
|
+
allBillableFmt: fmtNum(allBillable),
|
|
368
|
+
allInputTokens: fmtNum(agg.inputTokens || 0),
|
|
369
|
+
allOutputTokens: fmtNum(agg.outputTokens || 0),
|
|
370
|
+
allCacheTokens: fmtNum(allCache),
|
|
371
|
+
allCacheReadTokens: fmtNum(allCacheRead),
|
|
372
|
+
allCacheWriteTokens: fmtNum(allCacheWrite),
|
|
373
|
+
allHours: fmtHours(agg.activeMs || 0),
|
|
374
|
+
allWallHours: fmtHours(agg.wallMs || 0),
|
|
375
|
+
allMessages: agg.userMessages || 0,
|
|
376
|
+
allMessagesFmt: fmtNum(agg.userMessages || 0),
|
|
377
|
+
allTools: agg.toolCalls || 0,
|
|
378
|
+
allToolsFmt: fmtNum(agg.toolCalls || 0),
|
|
379
|
+
allSessions: agg.sessions || 0,
|
|
380
|
+
allSessionsLabel: plural(agg.sessions || 0, 'session'),
|
|
381
|
+
allSubagentRuns: agg.subagentRuns || 0,
|
|
382
|
+
allFiles: agg.uniqueFiles || 0,
|
|
383
|
+
allFilesFmt: fmtNum(agg.uniqueFiles || 0),
|
|
384
|
+
|
|
385
|
+
// today
|
|
386
|
+
todayActiveMs: today.activeMs || 0,
|
|
387
|
+
todayHours: fmtHours(today.activeMs || 0),
|
|
388
|
+
todayPrompts: today.userMessages || 0,
|
|
389
|
+
todayPromptsLabel: plural(today.userMessages || 0, 'prompt'),
|
|
390
|
+
todayTools: today.toolCalls || 0,
|
|
391
|
+
todayToolsFmt: fmtNum(today.toolCalls || 0),
|
|
392
|
+
todayToolsLabel: plural(today.toolCalls || 0, 'tool call'),
|
|
393
|
+
// today tokens — default is grand total incl. cache.
|
|
394
|
+
todayTokens: todayTokensSum,
|
|
395
|
+
todayTokensFmt: fmtNum(todayTokensSum),
|
|
396
|
+
todayTokensReal: todayReal,
|
|
397
|
+
todayTokensRealFmt: fmtNum(todayReal),
|
|
398
|
+
todayCacheTokensFmt: fmtNum(todayCache),
|
|
399
|
+
todaySessions: today.sessions || 0,
|
|
400
|
+
|
|
401
|
+
// streak / lifetime
|
|
402
|
+
streak,
|
|
403
|
+
streakLabel: streak === 0 ? 'no streak' : `${streak}-day streak`,
|
|
404
|
+
longestStreak: agg.longestStreak || 0,
|
|
405
|
+
daysSinceFirst: agg.daysSinceFirst || 0,
|
|
406
|
+
daysSinceFirstLabel: agg.daysSinceFirst ? `Day ${agg.daysSinceFirst}` : '',
|
|
407
|
+
|
|
408
|
+
// best day
|
|
409
|
+
bestDayDate: bestDay?.day || '',
|
|
410
|
+
bestDayHours: bestDay ? fmtHours(bestDay.activeMs || 0) : '0h',
|
|
411
|
+
bestDayPrompts: bestDay?.userMessages || 0,
|
|
412
|
+
bestDayTokensFmt: bestDay
|
|
413
|
+
? fmtNum((bestDay.inputTokens || 0) + (bestDay.outputTokens || 0) + (bestDay.cacheReadTokens || 0) + (bestDay.cacheWriteTokens || 0))
|
|
414
|
+
: '0',
|
|
415
|
+
|
|
416
|
+
// This week
|
|
417
|
+
weekActiveMs: thisWeek.activeMs || 0,
|
|
418
|
+
weekHours: fmtHours(thisWeek.activeMs || 0),
|
|
419
|
+
weekPrompts: thisWeek.userMessages || 0,
|
|
420
|
+
weekPromptsLabel: plural(thisWeek.userMessages || 0, 'prompt'),
|
|
421
|
+
weekTools: thisWeek.toolCalls || 0,
|
|
422
|
+
weekToolsFmt: fmtNum(thisWeek.toolCalls || 0),
|
|
423
|
+
weekToolsLabel: plural(thisWeek.toolCalls || 0, 'tool call'),
|
|
424
|
+
weekTokens: weekTokensSum,
|
|
425
|
+
weekTokensFmt: fmtNum(weekTokensSum),
|
|
426
|
+
weekSessions: thisWeek.sessions || 0,
|
|
427
|
+
weekSessionsLabel: plural(thisWeek.sessions || 0, 'session'),
|
|
428
|
+
|
|
429
|
+
// Peak hour-of-day
|
|
430
|
+
peakHourNum: peak?.hour ?? null,
|
|
431
|
+
peakHour: peak ? fmtHour(peak.hour) : '',
|
|
432
|
+
peakHourHours: peak ? fmtHours(peak.activeMs || 0) : '0h',
|
|
433
|
+
peakHourActiveLabel: peak ? `${fmtHours(peak.activeMs || 0)} there` : '',
|
|
434
|
+
|
|
435
|
+
// File hotspots
|
|
436
|
+
topEditedFile: hotspot ? basename(hotspot.path) : '',
|
|
437
|
+
topEditedCount: hotspot?.count || 0,
|
|
438
|
+
topEditedCountLabel: hotspot ? plural(hotspot.count, 'edit') : '0 edits',
|
|
439
|
+
|
|
440
|
+
// Per-project (current cwd's project)
|
|
441
|
+
projectHours: projectStats ? fmtHours(projectStats.activeMs || 0) : '0h',
|
|
442
|
+
projectActiveMs: projectStats?.activeMs || 0,
|
|
443
|
+
projectPrompts: projectStats?.userMessages || 0,
|
|
444
|
+
projectPromptsLabel: projectStats ? plural(projectStats.userMessages || 0, 'prompt') : '',
|
|
445
|
+
projectTools: projectStats?.toolCalls || 0,
|
|
446
|
+
projectSessions: projectStats?.sessions || 0,
|
|
447
|
+
projectSessionLabel: projectStats ? `Session #${projectStats.sessions}` : '',
|
|
448
|
+
|
|
449
|
+
// Streak milestone gate (for special rotation frame)
|
|
450
|
+
streakIsMilestone,
|
|
451
|
+
|
|
452
|
+
// ── Code churn ───────────────────────────────────────────────
|
|
453
|
+
linesAdded: allLinesAdded,
|
|
454
|
+
linesAddedFmt: fmtNum(allLinesAdded),
|
|
455
|
+
linesRemoved: allLinesRemoved,
|
|
456
|
+
linesRemovedFmt: fmtNum(allLinesRemoved),
|
|
457
|
+
linesNet: allLinesNet,
|
|
458
|
+
linesNetFmt: fmtLinesNet(allLinesNet),
|
|
459
|
+
todayLinesAdded,
|
|
460
|
+
todayLinesAddedFmt: fmtNum(todayLinesAdded),
|
|
461
|
+
todayLinesRemoved,
|
|
462
|
+
todayLinesRemovedFmt: fmtNum(todayLinesRemoved),
|
|
463
|
+
todayLinesNet,
|
|
464
|
+
todayLinesNetFmt: fmtLinesNet(todayLinesNet),
|
|
465
|
+
weekLinesAdded,
|
|
466
|
+
weekLinesAddedFmt: fmtNum(weekLinesAdded),
|
|
467
|
+
weekLinesNet,
|
|
468
|
+
weekLinesNetFmt: fmtLinesNet(weekLinesNet),
|
|
469
|
+
allLinesAdded,
|
|
470
|
+
allLinesAddedFmt: fmtNum(allLinesAdded),
|
|
471
|
+
allLinesNet,
|
|
472
|
+
allLinesNetFmt: fmtLinesNet(allLinesNet),
|
|
473
|
+
|
|
474
|
+
// ── Languages ────────────────────────────────────────────────
|
|
475
|
+
topLanguage: topLang ? topLang[0] : '',
|
|
476
|
+
topLanguageEdits: topLang ? (topLang[1].edits || 0) : 0,
|
|
477
|
+
topLanguageEditsFmt: topLang ? fmtNum(topLang[1].edits || 0) : '0',
|
|
478
|
+
languagesLabel,
|
|
479
|
+
|
|
480
|
+
// ── Bash commands ────────────────────────────────────────────
|
|
481
|
+
topBashCmd: topBash ? topBash.k : '',
|
|
482
|
+
topBashCmdCount: topBash ? topBash.v : 0,
|
|
483
|
+
topBashCmdLabel: topBash ? `${topBash.k} × ${fmtNum(topBash.v)}` : '',
|
|
484
|
+
|
|
485
|
+
// ── WebFetch domains ────────────────────────────────────────
|
|
486
|
+
topDomain: topDomain ? topDomain.k : '',
|
|
487
|
+
topDomainCount: topDomain ? topDomain.v : 0,
|
|
488
|
+
topDomainLabel: topDomain ? `${topDomain.k} × ${fmtNum(topDomain.v)}` : '',
|
|
489
|
+
|
|
490
|
+
// ── Subagents ───────────────────────────────────────────────
|
|
491
|
+
topSubagent: topSubagent ? topSubagent.k : '',
|
|
492
|
+
topSubagentCount: topSubagent ? topSubagent.v : 0,
|
|
493
|
+
subagentLabel: topSubagent ? `${topSubagent.k} × ${fmtNum(topSubagent.v)}` : '',
|
|
494
|
+
|
|
495
|
+
// ── Tool surface split ──────────────────────────────────────
|
|
496
|
+
mcpToolCalls: mcpCalls,
|
|
497
|
+
mcpToolCallsFmt: fmtNum(mcpCalls),
|
|
498
|
+
builtinToolCalls: builtinCalls,
|
|
499
|
+
builtinToolCallsFmt: fmtNum(builtinCalls),
|
|
500
|
+
mcpToolPercent: mcpPct,
|
|
501
|
+
mcpToolPercentLabel: totalToolCalls ? `${mcpPct}% MCP` : '',
|
|
502
|
+
|
|
503
|
+
// ── Cost ────────────────────────────────────────────────────
|
|
504
|
+
todayCost,
|
|
505
|
+
todayCostFmt: fmtCost(todayCost),
|
|
506
|
+
weekCost,
|
|
507
|
+
weekCostFmt: fmtCost(weekCost),
|
|
508
|
+
allCost,
|
|
509
|
+
allCostFmt: fmtCost(allCost),
|
|
510
|
+
costEstimate: allCost,
|
|
511
|
+
costEstimateFmt: fmtCost(allCost),
|
|
512
|
+
projectCost,
|
|
513
|
+
projectCostFmt: fmtCost(projectCost),
|
|
514
|
+
|
|
515
|
+
// ── Time-of-day / weekday ───────────────────────────────────
|
|
516
|
+
weekdayLabel,
|
|
517
|
+
startTimeLabel: todayStartLabel,
|
|
518
|
+
|
|
519
|
+
// ── Notifications ───────────────────────────────────────────
|
|
520
|
+
notificationCount,
|
|
521
|
+
notificationLabel: notificationCount ? plural(notificationCount, 'notification') : '',
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function fillTemplate(tpl, vars) {
|
|
526
|
+
if (typeof tpl !== 'string') return tpl;
|
|
527
|
+
return tpl.replace(/\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Apply idle/stale transitions based on lastActivity age. Used by both daemon
|
|
531
|
+
// and the `preview` CLI command so they agree.
|
|
532
|
+
//
|
|
533
|
+
// Important: state.liveSessions (set by the caller from findLiveSessions) is
|
|
534
|
+
// the ground truth for "is the user active anywhere right now?". The local
|
|
535
|
+
// state.json only knows about hook-driven activity in this exact daemon
|
|
536
|
+
// instance, so we trust on-disk transcript mtimes over a stale state.json.
|
|
537
|
+
export function applyIdle(state, cfg = {}) {
|
|
538
|
+
const liveSessions = state.liveSessions || [];
|
|
539
|
+
const last = state.lastActivity || 0;
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
const ageMs = now - last;
|
|
542
|
+
const idleMs = (cfg.idleThresholdSec || 60) * 1000;
|
|
543
|
+
const staleMs = Math.max(60_000, (cfg.staleSessionMin || 5) * 60 * 1000);
|
|
544
|
+
const notificationMs = (cfg.notificationWindowSec || 8) * 1000;
|
|
545
|
+
|
|
546
|
+
// Authoritative close signal from the SessionEnd hook — trust it instead
|
|
547
|
+
// of waiting on staleSessionMin. Any other hook clears the flag, so a
|
|
548
|
+
// sibling session staying alive will reset us out of this branch.
|
|
549
|
+
if (state.claudeClosed) {
|
|
550
|
+
return {
|
|
551
|
+
...state,
|
|
552
|
+
status: 'stale',
|
|
553
|
+
currentTool: null,
|
|
554
|
+
currentFile: null,
|
|
555
|
+
sessionStart: null,
|
|
556
|
+
cwd: '',
|
|
557
|
+
messages: 0,
|
|
558
|
+
tools: 0,
|
|
559
|
+
filesOpened: [],
|
|
560
|
+
filesEdited: [],
|
|
561
|
+
filesRead: [],
|
|
562
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Notification is a brief status — hold it for ~8s after the hook fires,
|
|
567
|
+
// then fall through to normal idle/stale processing.
|
|
568
|
+
if (state.status === 'notification') {
|
|
569
|
+
const notifAge = now - (state.lastNotification || 0);
|
|
570
|
+
if (notifAge <= notificationMs) return state;
|
|
571
|
+
state = { ...state, status: 'idle' };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Most-recent disk-level activity across ALL transcripts we can see.
|
|
575
|
+
const mostRecentLiveMs = liveSessions.length
|
|
576
|
+
? Math.max(...liveSessions.map((s) => s.mtime || 0))
|
|
577
|
+
: 0;
|
|
578
|
+
const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
|
|
579
|
+
|
|
580
|
+
// Truly dormant: no live transcripts AND local state is old → stale.
|
|
581
|
+
if (ageMs > staleMs && liveAgeMs > staleMs) {
|
|
582
|
+
return {
|
|
583
|
+
...state,
|
|
584
|
+
status: 'stale',
|
|
585
|
+
currentTool: null,
|
|
586
|
+
currentFile: null,
|
|
587
|
+
sessionStart: null,
|
|
588
|
+
cwd: '',
|
|
589
|
+
messages: 0,
|
|
590
|
+
tools: 0,
|
|
591
|
+
filesOpened: [],
|
|
592
|
+
filesEdited: [],
|
|
593
|
+
filesRead: [],
|
|
594
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Local state is stale but a live transcript exists somewhere on disk.
|
|
599
|
+
// Borrow the most-recent live session as our "active" context, since the
|
|
600
|
+
// user clearly IS working — just not in a session whose hooks feed us.
|
|
601
|
+
if (ageMs > staleMs && liveAgeMs <= staleMs) {
|
|
602
|
+
const recent = liveSessions[0] || {};
|
|
603
|
+
return {
|
|
604
|
+
...state,
|
|
605
|
+
status: 'working',
|
|
606
|
+
cwd: recent.cwd || state.cwd || '',
|
|
607
|
+
sessionStart: recent.mtime || now,
|
|
608
|
+
lastActivity: recent.mtime || now,
|
|
609
|
+
// Hook-derived per-session counters belong to the OLD session — zero them.
|
|
610
|
+
currentTool: null,
|
|
611
|
+
currentFile: null,
|
|
612
|
+
messages: 0,
|
|
613
|
+
tools: 0,
|
|
614
|
+
filesOpened: [],
|
|
615
|
+
filesEdited: [],
|
|
616
|
+
filesRead: [],
|
|
617
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Local state is fresh.
|
|
622
|
+
if (state.status === 'idle') return state;
|
|
623
|
+
if (ageMs > idleMs) {
|
|
624
|
+
// Hook channel is quiet, but a live transcript was modified recently?
|
|
625
|
+
// Keep "working" instead of dropping to "idle".
|
|
626
|
+
if (liveAgeMs <= idleMs) return state;
|
|
627
|
+
// Going idle — wipe "current activity" indicators so rotation frames
|
|
628
|
+
// gated on filesEdited / currentFile / currentTool stop showing stale
|
|
629
|
+
// active-session data. Keep the session counters (messages/tools/tokens)
|
|
630
|
+
// since those still make sense as "this session so far". The cwd stays
|
|
631
|
+
// so frames can still say "Idle in <project>".
|
|
632
|
+
return {
|
|
633
|
+
...state,
|
|
634
|
+
status: 'idle',
|
|
635
|
+
currentTool: null,
|
|
636
|
+
currentFile: null,
|
|
637
|
+
filesOpened: [],
|
|
638
|
+
filesEdited: [],
|
|
639
|
+
filesRead: [],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return state;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// True when `requires` (string or array of strings) all resolve to non-zero / non-empty.
|
|
646
|
+
export function framePasses(frame, vars) {
|
|
647
|
+
const req = frame.requires;
|
|
648
|
+
if (!req) return true;
|
|
649
|
+
const keys = Array.isArray(req) ? req : [req];
|
|
650
|
+
for (const k of keys) {
|
|
651
|
+
const v = vars[k];
|
|
652
|
+
if (v === undefined || v === null || v === 0 || v === '' || v === '—' || v === '0') return false;
|
|
653
|
+
}
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export { fmtNum, fmtDuration, fmtHours, humanModel, humanTool, humanProject, plural };
|