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/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 };