claude-code-session-manager 0.14.0 → 0.15.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.
Files changed (26) hide show
  1. package/dist/assets/{TiptapBody-CzNkD0kT.js → TiptapBody-D0tfDVZb.js} +1 -1
  2. package/dist/assets/{cssMode-DUMBBgBO.js → cssMode-C3tZkaJ9.js} +1 -1
  3. package/dist/assets/{freemarker2-BjnoO8uS.js → freemarker2-DEh8tC5X.js} +1 -1
  4. package/dist/assets/{handlebars-CggfdTjZ.js → handlebars-D_6KmsOK.js} +1 -1
  5. package/dist/assets/{html-Bcnp_pNA.js → html-DF1KVjzv.js} +1 -1
  6. package/dist/assets/{htmlMode-CFws8rf5.js → htmlMode-DEakPokt.js} +1 -1
  7. package/dist/assets/{index-DOo1Vtox.css → index-D-kX3T0V.css} +1 -1
  8. package/dist/assets/{index-0geebEag.js → index-Zg61GP50.js} +511 -511
  9. package/dist/assets/{javascript-DI4e0TwT.js → javascript-Du7a359D.js} +1 -1
  10. package/dist/assets/{jsonMode-Ca0fgEAF.js → jsonMode-W3BJwbUD.js} +1 -1
  11. package/dist/assets/{liquid-E7hk4hx9.js → liquid-DDj1fqca.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-Wh7EFavn.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
  13. package/dist/assets/{mdx-iXIhpMBJ.js → mdx-DUqSETvC.js} +1 -1
  14. package/dist/assets/{python-laxy9eG1.js → python-D7S2lUAn.js} +1 -1
  15. package/dist/assets/{razor-CatPL_eG.js → razor-19nfZNaZ.js} +1 -1
  16. package/dist/assets/{tsMode-D2WL55Cf.js → tsMode-CQke5zpL.js} +1 -1
  17. package/dist/assets/{typescript-B5DFbVoi.js → typescript-D4ge0PUF.js} +1 -1
  18. package/dist/assets/{xml-BsStStQW.js → xml-D42QDS7q.js} +1 -1
  19. package/dist/assets/{yaml-rg2IIDpu.js → yaml-CEiz9NyU.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/index.cjs +4 -0
  23. package/src/main/transcripts.cjs +12 -0
  24. package/src/main/usageMatrix.cjs +336 -0
  25. package/src/preload/api.d.ts +52 -0
  26. package/src/preload/index.cjs +8 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * usageMatrix.cjs — per-tab agentic-ops aggregator.
3
+ *
4
+ * Watches the same classified transcript events that transcripts.cjs streams
5
+ * to the renderer and maintains a small rolling state per tab: turn count,
6
+ * token velocity (5-min window), cache age, subagent fan-out, and behavioral
7
+ * flags (geometric context growth, cache cold-start risk, long session).
8
+ *
9
+ * Lives in main rather than the renderer so a renderer reload does not lose
10
+ * the rolling counters — multi-hour sessions are the primary use case and
11
+ * losing the velocity series at a reload would make the dashboard useless
12
+ * exactly when it matters most.
13
+ *
14
+ * Broadcasts `usage:matrix:tick` on a 5-second cadence (and once on demand
15
+ * via the `usage:matrix:snapshot` handler at mount).
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const path = require('node:path');
21
+ const { ipcMain } = require('electron');
22
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
+
24
+ const TICK_MS = 5_000;
25
+ const TOKEN_WINDOW_MS = 5 * 60_000; // 5 min rolling window for tokens/min
26
+ const TURN_RING = 20; // per-turn input-token series cap
27
+ const TOOL_RING = 100; // recent tool-name series cap
28
+ const CACHE_TTL_MS = 60 * 60_000; // Anthropic prefix cache ~60 min
29
+ const CACHE_WARN_MS = 50 * 60_000; // start warning at 50 min
30
+
31
+ const BURN_LOW = 5_000; // tokens/min
32
+ const BURN_CRITICAL = 30_000; // tokens/min
33
+ const LONG_SESSION_TURNS = 70;
34
+ const GEOMETRIC_MIN_TOKENS = 50_000; // don't flag tiny series
35
+
36
+ /** Map<tabId, TabState> */
37
+ const tabs = new Map();
38
+ let window = null;
39
+ let tickTimer = null;
40
+ let dirty = false;
41
+
42
+ function attachWindow(w) {
43
+ window = w;
44
+ if (!tickTimer) {
45
+ tickTimer = setInterval(broadcastIfChanged, TICK_MS);
46
+ }
47
+ }
48
+
49
+ function workspaceName(cwd) {
50
+ if (!cwd) return '(unknown)';
51
+ return path.basename(cwd) || cwd;
52
+ }
53
+
54
+ function blankTab(tabId, cwd, sessionUuid) {
55
+ return {
56
+ tabId,
57
+ cwd,
58
+ sessionUuid,
59
+ workspace: workspaceName(cwd),
60
+ startedAt: Date.now(),
61
+ lastEventAt: 0,
62
+ lastAssistantAt: 0,
63
+ lastUserAt: 0,
64
+ lastPlanAt: 0,
65
+ turns: 0,
66
+ cumulativeUsage: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 },
67
+ perTurnInputTokens: [],
68
+ tokenWindow: [], // [{ts, tokens}]
69
+ toolSeries: [], // [{ts, name}]
70
+ agentSpawns: 0,
71
+ agentsActive: new Map(), // toolUseId -> {at}
72
+ };
73
+ }
74
+
75
+ function ensureTab(tabId, cwd, sessionUuid) {
76
+ let t = tabs.get(tabId);
77
+ if (!t) {
78
+ t = blankTab(tabId, cwd, sessionUuid);
79
+ tabs.set(tabId, t);
80
+ }
81
+ return t;
82
+ }
83
+
84
+ /**
85
+ * Feed one classified transcript event into the per-tab aggregator. Called
86
+ * from transcripts.cjs for every event, both during replay and live.
87
+ */
88
+ function recordEvent({ tabId, cwd, sessionUuid, ev }) {
89
+ if (!tabId || !ev) return;
90
+ const t = ensureTab(tabId, cwd, sessionUuid);
91
+ const now = Date.now();
92
+ t.lastEventAt = now;
93
+
94
+ switch (ev.kind) {
95
+ case 'usage': {
96
+ // Each usage rollup = one assistant turn in Claude Code's transcript.
97
+ const u = ev.data || {};
98
+ const inTok = (u.input_tokens || 0)
99
+ + (u.cache_creation_input_tokens || 0)
100
+ + (u.cache_read_input_tokens || 0);
101
+ const outTok = u.output_tokens || 0;
102
+ t.cumulativeUsage.input += u.input_tokens || 0;
103
+ t.cumulativeUsage.output += outTok;
104
+ t.cumulativeUsage.cacheRead += u.cache_read_input_tokens || 0;
105
+ t.cumulativeUsage.cacheCreate += u.cache_creation_input_tokens || 0;
106
+ t.turns += 1;
107
+ t.lastAssistantAt = now;
108
+
109
+ t.perTurnInputTokens.push(inTok);
110
+ if (t.perTurnInputTokens.length > TURN_RING) t.perTurnInputTokens.shift();
111
+
112
+ t.tokenWindow.push({ ts: now, tokens: inTok + outTok });
113
+ pruneWindow(t.tokenWindow, now);
114
+ dirty = true;
115
+ break;
116
+ }
117
+ case 'tool_use': {
118
+ const name = ev.data?.name;
119
+ if (name) {
120
+ t.toolSeries.push({ ts: now, name });
121
+ if (t.toolSeries.length > TOOL_RING) t.toolSeries.shift();
122
+ }
123
+ dirty = true;
124
+ break;
125
+ }
126
+ case 'agent_spawn': {
127
+ t.agentSpawns += 1;
128
+ const id = ev.data?.toolUseId;
129
+ if (id) t.agentsActive.set(id, { at: now });
130
+ dirty = true;
131
+ break;
132
+ }
133
+ case 'tool_result': {
134
+ const id = ev.data?.toolUseId;
135
+ if (id && t.agentsActive.has(id)) {
136
+ t.agentsActive.delete(id);
137
+ dirty = true;
138
+ }
139
+ break;
140
+ }
141
+ case 'plan': {
142
+ t.lastPlanAt = now;
143
+ dirty = true;
144
+ break;
145
+ }
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+
151
+ function pruneWindow(arr, now) {
152
+ const cutoff = now - TOKEN_WINDOW_MS;
153
+ while (arr.length && arr[0].ts < cutoff) arr.shift();
154
+ }
155
+
156
+ function removeTab(tabId) {
157
+ if (tabs.delete(tabId)) dirty = true;
158
+ }
159
+
160
+ /**
161
+ * Build a derived snapshot. Heavy work happens here, not on the hot
162
+ * recordEvent path — recordEvent only appends to ring buffers and flips a
163
+ * dirty bit.
164
+ */
165
+ function buildSnapshot() {
166
+ const now = Date.now();
167
+ const out = [];
168
+
169
+ let totalSubagentsActive = 0;
170
+ let totalSubagentsSpawned = 0;
171
+ let totalTokensToday = 0;
172
+ let combinedTokensPerMin = 0;
173
+
174
+ for (const t of tabs.values()) {
175
+ pruneWindow(t.tokenWindow, now);
176
+ const windowTokens = t.tokenWindow.reduce((s, e) => s + e.tokens, 0);
177
+ const windowMin = Math.min(TOKEN_WINDOW_MS, now - (t.tokenWindow[0]?.ts ?? now)) / 60_000;
178
+ const tokensPerMin = windowMin > 0.1 ? Math.round(windowTokens / windowMin) : 0;
179
+ combinedTokensPerMin += tokensPerMin;
180
+
181
+ const intensity = tokensPerMin >= BURN_CRITICAL
182
+ ? 'critical'
183
+ : tokensPerMin >= BURN_LOW
184
+ ? 'medium'
185
+ : tokensPerMin > 0
186
+ ? 'low'
187
+ : 'idle';
188
+
189
+ const cacheAgeMs = t.lastAssistantAt ? now - t.lastAssistantAt : null;
190
+ const cacheState = cacheAgeMs == null
191
+ ? 'cold'
192
+ : cacheAgeMs > CACHE_TTL_MS
193
+ ? 'cold'
194
+ : cacheAgeMs > CACHE_WARN_MS
195
+ ? 'expiring'
196
+ : 'warm';
197
+
198
+ const avgPerTurn = t.perTurnInputTokens.length > 0
199
+ ? Math.round(t.perTurnInputTokens.reduce((s, n) => s + n, 0) / t.perTurnInputTokens.length)
200
+ : 0;
201
+
202
+ const geometricGrowth = detectGeometricGrowth(t.perTurnInputTokens);
203
+
204
+ // State derivation. Order matters — earlier branches win.
205
+ let state;
206
+ if (t.lastPlanAt > 0 && (now - t.lastPlanAt) < 60_000 && t.lastPlanAt >= t.lastAssistantAt) {
207
+ state = 'plan';
208
+ } else if (t.agentsActive.size > 0 && (now - t.lastAssistantAt) > 10_000) {
209
+ state = 'background';
210
+ } else if (t.lastEventAt > 0 && (now - t.lastEventAt) < 30_000) {
211
+ state = 'executing';
212
+ } else {
213
+ state = 'idle';
214
+ }
215
+
216
+ const subagentsActive = t.agentsActive.size;
217
+ totalSubagentsActive += subagentsActive;
218
+ totalSubagentsSpawned += t.agentSpawns;
219
+ totalTokensToday += t.cumulativeUsage.input + t.cumulativeUsage.output;
220
+
221
+ const alerts = [];
222
+ if (geometricGrowth) {
223
+ alerts.push({
224
+ level: 'warn',
225
+ code: 'geometric-growth',
226
+ message: `Per-turn input grew geometrically (avg ${(avgPerTurn / 1000).toFixed(0)}k). Run /compact.`,
227
+ });
228
+ }
229
+ if (t.turns >= LONG_SESSION_TURNS) {
230
+ alerts.push({
231
+ level: 'warn',
232
+ code: 'long-session',
233
+ message: `Session is at turn ${t.turns}. Context resubmissions cost ${(avgPerTurn / 1000).toFixed(0)}k tokens each.`,
234
+ });
235
+ }
236
+ if (cacheState === 'expiring' && state === 'idle') {
237
+ const minLeft = Math.max(0, Math.round((CACHE_TTL_MS - (cacheAgeMs ?? 0)) / 60_000));
238
+ alerts.push({
239
+ level: 'info',
240
+ code: 'cache-cold-start',
241
+ message: `Cache cold-start in ${minLeft}m. Next command will incur full cache_creation cost.`,
242
+ });
243
+ }
244
+ if (cacheState === 'cold' && t.lastAssistantAt > 0) {
245
+ alerts.push({
246
+ level: 'info',
247
+ code: 'cache-cold',
248
+ message: 'Cache expired. Next command pays full cache_creation pricing.',
249
+ });
250
+ }
251
+ if (subagentsActive >= 4) {
252
+ alerts.push({
253
+ level: 'info',
254
+ code: 'subagent-fanout',
255
+ message: `${subagentsActive} subagents in flight. Consider waiting before fanning out more.`,
256
+ });
257
+ }
258
+
259
+ out.push({
260
+ tabId: t.tabId,
261
+ workspace: t.workspace,
262
+ cwd: t.cwd,
263
+ state,
264
+ turns: t.turns,
265
+ lastEventAt: t.lastEventAt,
266
+ lastAssistantAt: t.lastAssistantAt,
267
+ cacheAgeMs,
268
+ cacheState,
269
+ tokensPerMin,
270
+ intensity,
271
+ avgTokensPerTurn: avgPerTurn,
272
+ cumulativeUsage: { ...t.cumulativeUsage },
273
+ subagentsActive,
274
+ subagentsSpawned: t.agentSpawns,
275
+ geometricGrowth,
276
+ alerts,
277
+ });
278
+ }
279
+
280
+ // Most active first.
281
+ out.sort((a, b) => b.tokensPerMin - a.tokensPerMin || b.lastEventAt - a.lastEventAt);
282
+
283
+ return {
284
+ generatedAt: now,
285
+ tabs: out,
286
+ totals: {
287
+ activeSessions: out.length,
288
+ subagentsActive: totalSubagentsActive,
289
+ subagentsSpawned: totalSubagentsSpawned,
290
+ tokensTotal: totalTokensToday,
291
+ combinedTokensPerMin,
292
+ },
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Geometric growth: avg of last 3 turns > 1.8 × avg of prior 3 turns,
298
+ * and last-3 avg above GEOMETRIC_MIN_TOKENS so trivial sessions don't flag.
299
+ */
300
+ function detectGeometricGrowth(series) {
301
+ if (series.length < 6) return false;
302
+ const tail = series.slice(-3);
303
+ const prev = series.slice(-6, -3);
304
+ const tailAvg = tail.reduce((s, n) => s + n, 0) / 3;
305
+ const prevAvg = prev.reduce((s, n) => s + n, 0) / 3;
306
+ if (tailAvg < GEOMETRIC_MIN_TOKENS) return false;
307
+ if (prevAvg <= 0) return false;
308
+ return tailAvg / prevAvg >= 1.8;
309
+ }
310
+
311
+ function broadcastIfChanged() {
312
+ if (!dirty) return;
313
+ dirty = false;
314
+ if (!window) return;
315
+ sendIfAlive(window, 'usage:matrix:tick', buildSnapshot());
316
+ }
317
+
318
+ function registerHandlers() {
319
+ ipcMain.handle('usage:matrix:snapshot', () => buildSnapshot());
320
+ }
321
+
322
+ function closeAll() {
323
+ if (tickTimer) {
324
+ clearInterval(tickTimer);
325
+ tickTimer = null;
326
+ }
327
+ tabs.clear();
328
+ }
329
+
330
+ module.exports = {
331
+ attachWindow,
332
+ recordEvent,
333
+ removeTab,
334
+ registerHandlers,
335
+ closeAll,
336
+ };
@@ -130,6 +130,54 @@ export type BillingFetchResult =
130
130
  | { kind: 'transient'; message: string; httpStatus: number | null }
131
131
  | { kind: 'config'; message: string };
132
132
 
133
+ // ── Usage matrix (AgOps dashboard, main-side aggregator)
134
+ export type UsageMatrixState = 'idle' | 'executing' | 'plan' | 'background';
135
+ export type UsageMatrixIntensity = 'idle' | 'low' | 'medium' | 'critical';
136
+ export type UsageMatrixCacheState = 'warm' | 'expiring' | 'cold';
137
+
138
+ export interface UsageMatrixAlert {
139
+ level: 'info' | 'warn';
140
+ code:
141
+ | 'geometric-growth'
142
+ | 'long-session'
143
+ | 'cache-cold-start'
144
+ | 'cache-cold'
145
+ | 'subagent-fanout';
146
+ message: string;
147
+ }
148
+
149
+ export interface UsageMatrixTab {
150
+ tabId: string;
151
+ workspace: string;
152
+ cwd: string;
153
+ state: UsageMatrixState;
154
+ turns: number;
155
+ lastEventAt: number;
156
+ lastAssistantAt: number;
157
+ cacheAgeMs: number | null;
158
+ cacheState: UsageMatrixCacheState;
159
+ tokensPerMin: number;
160
+ intensity: UsageMatrixIntensity;
161
+ avgTokensPerTurn: number;
162
+ cumulativeUsage: { input: number; output: number; cacheRead: number; cacheCreate: number };
163
+ subagentsActive: number;
164
+ subagentsSpawned: number;
165
+ geometricGrowth: boolean;
166
+ alerts: UsageMatrixAlert[];
167
+ }
168
+
169
+ export interface UsageMatrixSnapshot {
170
+ generatedAt: number;
171
+ tabs: UsageMatrixTab[];
172
+ totals: {
173
+ activeSessions: number;
174
+ subagentsActive: number;
175
+ subagentsSpawned: number;
176
+ tokensTotal: number;
177
+ combinedTokensPerMin: number;
178
+ };
179
+ }
180
+
133
181
  export interface VoiceHotkeyConfig {
134
182
  accelerator: string;
135
183
  mode: 'hold' | 'toggle';
@@ -787,6 +835,10 @@ export interface SessionManagerAPI {
787
835
  billing: {
788
836
  fetch: () => Promise<BillingFetchResult>;
789
837
  };
838
+ usageMatrix: {
839
+ snapshot: () => Promise<UsageMatrixSnapshot>;
840
+ onTick: (handler: (snap: UsageMatrixSnapshot) => void) => () => void;
841
+ };
790
842
  logs: {
791
843
  write: (scope: string, level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: unknown) => void;
792
844
  dir: () => Promise<string>;
@@ -76,6 +76,14 @@ contextBridge.exposeInMainWorld('api', {
76
76
  billing: {
77
77
  fetch: () => ipcRenderer.invoke('billing:fetch'),
78
78
  },
79
+ usageMatrix: {
80
+ snapshot: () => ipcRenderer.invoke('usage:matrix:snapshot'),
81
+ onTick: (handler) => {
82
+ const listener = (_e, payload) => handler(payload);
83
+ ipcRenderer.on('usage:matrix:tick', listener);
84
+ return () => ipcRenderer.removeListener('usage:matrix:tick', listener);
85
+ },
86
+ },
79
87
  logs: {
80
88
  write: (scope, level, message, meta) =>
81
89
  ipcRenderer.send('log:write', { scope, level, message, meta }),