claude-code-session-manager 0.14.0 → 0.16.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-CFh7PFkf.js} +1 -1
  2. package/dist/assets/{cssMode-DUMBBgBO.js → cssMode-Cll4QpTW.js} +1 -1
  3. package/dist/assets/{freemarker2-BjnoO8uS.js → freemarker2-lo-fLEl5.js} +1 -1
  4. package/dist/assets/{handlebars-CggfdTjZ.js → handlebars-CMA5gb0k.js} +1 -1
  5. package/dist/assets/{html-Bcnp_pNA.js → html-BQ2X1VHF.js} +1 -1
  6. package/dist/assets/{htmlMode-CFws8rf5.js → htmlMode-Da8nU8Ys.js} +1 -1
  7. package/dist/assets/{index-DOo1Vtox.css → index-6uZy0Pbe.css} +1 -1
  8. package/dist/assets/{index-0geebEag.js → index-Bs_D2jQM.js} +1473 -1410
  9. package/dist/assets/{javascript-DI4e0TwT.js → javascript-BfjktvBd.js} +1 -1
  10. package/dist/assets/{jsonMode-Ca0fgEAF.js → jsonMode-Duc74W4E.js} +1 -1
  11. package/dist/assets/{liquid-E7hk4hx9.js → liquid-CONgoaDI.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-Wh7EFavn.js → lspLanguageFeatures-DdHwUVu5.js} +1 -1
  13. package/dist/assets/{mdx-iXIhpMBJ.js → mdx-DIQgvOto.js} +1 -1
  14. package/dist/assets/{python-laxy9eG1.js → python-BcGmrT3q.js} +1 -1
  15. package/dist/assets/{razor-CatPL_eG.js → razor-B2RyqYc0.js} +1 -1
  16. package/dist/assets/{tsMode-D2WL55Cf.js → tsMode-CRnbNLOY.js} +1 -1
  17. package/dist/assets/{typescript-B5DFbVoi.js → typescript-CeTpx2hJ.js} +1 -1
  18. package/dist/assets/{xml-BsStStQW.js → xml-DIN_EFpG.js} +1 -1
  19. package/dist/assets/{yaml-rg2IIDpu.js → yaml-DpCxufnr.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +3 -1
  22. package/src/main/index.cjs +82 -4
  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
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard } = require('electron');
1
+ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
2
2
  const { spawn, execFile, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
@@ -9,6 +9,7 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
10
10
  const configMgr = require('./config.cjs');
11
11
  const transcripts = require('./transcripts.cjs');
12
+ const usageMatrix = require('./usageMatrix.cjs');
12
13
  const sessionsStore = require('./sessionsStore.cjs');
13
14
  const billing = require('./usage.cjs');
14
15
  const logs = require('./logs.cjs');
@@ -34,11 +35,14 @@ const searchIpc = require('./search.cjs');
34
35
  const repoAnalyzer = require('./repoAnalyzer.cjs');
35
36
  const hivesIpc = require('./hives.cjs');
36
37
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
37
- const { checkInsideHome } = require('./lib/insideHome.cjs');
38
+ const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
38
39
  const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
39
40
 
40
41
  let mainWindow = null;
41
42
  let rebooting = false;
43
+ // powerSaveBlocker handle — keeps the system from suspending while the app runs
44
+ // so the scheduler's polling and jobs aren't frozen. -1 = not held.
45
+ let powerBlockerId = -1;
42
46
 
43
47
  // Boot diagnostics — populated at app.whenReady so the renderer can poll their
44
48
  // state via IPC and surface toasts on the failure paths. The first-paint
@@ -187,6 +191,7 @@ async function rebootApp() {
187
191
  ptyManager.attachWindow(mainWindow);
188
192
  configMgr.attachWindow(mainWindow);
189
193
  transcripts.attachWindow(mainWindow);
194
+ usageMatrix.attachWindow(mainWindow);
190
195
  voiceHotkey.init(mainWindow).catch((e) => {
191
196
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
192
197
  });
@@ -546,6 +551,7 @@ ipcMain.handle('app:archive-project', validated(schemas.archiveProject, async ({
546
551
  registerPtyHandlers();
547
552
  configMgr.registerConfigHandlers();
548
553
  transcripts.registerTranscriptHandlers();
554
+ usageMatrix.registerHandlers();
549
555
  sessionsStore.registerSessionsHandlers();
550
556
  billing.registerBillingHandlers();
551
557
  logs.registerLogHandlers();
@@ -591,6 +597,29 @@ if (process.env.SM_E2E === '1') {
591
597
  try { app.disableHardwareAcceleration(); } catch { /* */ }
592
598
  }
593
599
 
600
+ // smfile:// — privileged scheme that serves home-scoped files to the in-app
601
+ // Editor's HTML preview iframe. Loading the user's HTML via a custom scheme
602
+ // (rather than srcdoc) gives the iframe document its OWN origin + empty CSP, so
603
+ // the page's own visualization scripts run — while the iframe is still
604
+ // sandboxed without allow-same-origin (opaque origin), keeping it walled off
605
+ // from the host app, its IPC bridge, and the user's filesystem. Must be
606
+ // registered before app.whenReady() (Electron rejects late scheme privileges).
607
+ protocol.registerSchemesAsPrivileged([
608
+ { scheme: 'smfile', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
609
+ ]);
610
+
611
+ // Common content types for previewed assets. Anything unknown is served as
612
+ // octet-stream so the browser sniffs / downloads rather than mis-rendering.
613
+ const SMFILE_MIME = {
614
+ '.html': 'text/html', '.htm': 'text/html',
615
+ '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
616
+ '.json': 'application/json', '.svg': 'image/svg+xml',
617
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
618
+ '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
619
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
620
+ '.txt': 'text/plain', '.csv': 'text/csv', '.xml': 'application/xml',
621
+ };
622
+
594
623
  // Single-instance lock (PRD F1 v2 §requestSingleInstanceLock).
595
624
  // In dev mode we skip the lock so two-dev-instance workflows still work.
596
625
  // E2E tests also skip so playwright.electron.launch can run multiple specs
@@ -682,7 +711,7 @@ app.whenReady().then(async () => {
682
711
  // — adds ~3 packages and ~2MB to the renderer build; the network fetch
683
712
  // happens once per cold launch and is cached by Electron's HTTP cache.
684
713
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
685
- "img-src 'self' data: blob:",
714
+ "img-src 'self' data: blob: smfile:",
686
715
  "font-src 'self' data: https://fonts.gstatic.com",
687
716
  // schemastore.org is used by Monaco for JSON schema validation
688
717
  // (settings.json, keybindings.json — see App.tsx::installMonacoSchemas).
@@ -691,10 +720,18 @@ app.whenReady().then(async () => {
691
720
  "connect-src 'self' https://api.anthropic.com https://registry.npmjs.org https://json.schemastore.org https://www.schemastore.org",
692
721
  "media-src 'self' blob:",
693
722
  "worker-src 'self' blob:",
694
- "frame-src 'none'",
723
+ // smfile: powers the Editor's sandboxed HTML preview iframe. The iframe is
724
+ // sandboxed WITHOUT allow-same-origin, so its document has an opaque origin
725
+ // and cannot reach the host even though it may run its own scripts.
726
+ "frame-src smfile:",
695
727
  "frame-ancestors 'none'",
696
728
  ].join('; ') + ';';
697
729
  session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
730
+ // smfile:// serves the Editor's HTML-preview documents. They must NOT
731
+ // inherit the app CSP — `frame-ancestors 'none'` would forbid framing them,
732
+ // and `script-src 'self'` would block the page's own visualization scripts.
733
+ // Isolation comes from the iframe sandbox (opaque origin), not from CSP.
734
+ if (details.url.startsWith('smfile:')) { cb({}); return; }
698
735
  cb({
699
736
  responseHeaders: {
700
737
  ...details.responseHeaders,
@@ -703,6 +740,29 @@ app.whenReady().then(async () => {
703
740
  });
704
741
  });
705
742
 
743
+ // smfile:// handler — serves a single home-scoped file for the Editor's HTML
744
+ // preview iframe (and its relative assets: ./chart.js, ./data.json, images).
745
+ // URL shape: smfile://local/<absolute-path>. The pathname IS the absolute
746
+ // path; assertInsideHome enforces containment + rejects symlink escapes. The
747
+ // sandboxed iframe's relative requests resolve against this same scheme, so
748
+ // co-located assets load while everything stays inside home.
749
+ protocol.handle('smfile', async (request) => {
750
+ try {
751
+ const url = new URL(request.url);
752
+ const abs = decodeURIComponent(url.pathname);
753
+ const { realPath } = assertInsideHome(abs); // throws if outside home
754
+ const st = await fs.promises.stat(realPath);
755
+ if (st.isDirectory()) return new Response('Not a file', { status: 404 });
756
+ if (st.size > 25 * 1024 * 1024) return new Response('Too large', { status: 413 });
757
+ const buf = await fs.promises.readFile(realPath);
758
+ const ext = path.extname(realPath).toLowerCase();
759
+ const type = SMFILE_MIME[ext] || 'application/octet-stream';
760
+ return new Response(buf, { headers: { 'content-type': type } });
761
+ } catch (err) {
762
+ return new Response(`smfile error: ${err && err.message}`, { status: 404 });
763
+ }
764
+ });
765
+
706
766
  // Grant microphone / media permissions only for trusted origins.
707
767
  const MEDIA_PERMS = new Set(['media', 'audioCapture', 'microphone']);
708
768
  const isTrustedOrigin = (url) =>
@@ -777,6 +837,7 @@ app.whenReady().then(async () => {
777
837
  ptyManager.attachWindow(mainWindow);
778
838
  configMgr.attachWindow(mainWindow);
779
839
  transcripts.attachWindow(mainWindow);
840
+ usageMatrix.attachWindow(mainWindow);
780
841
  voiceHotkey.init(mainWindow).catch((e) => {
781
842
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
782
843
  });
@@ -788,6 +849,19 @@ app.whenReady().then(async () => {
788
849
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
789
850
  });
790
851
 
852
+ // Keep the machine awake while the app is open. The scheduler polls billing
853
+ // usage every 2 min and runs `claude -p` jobs that must survive an idle
854
+ // laptop — a system suspend (GNOME/Pop!_OS idle or lid timeout) would freeze
855
+ // both. `prevent-app-suspension` stops suspend but still lets the display
856
+ // dim/sleep, so battery impact is limited to keeping the CPU resumable.
857
+ // On Linux this routes through the org.freedesktop.login1 inhibitor.
858
+ try {
859
+ powerBlockerId = powerSaveBlocker.start('prevent-app-suspension');
860
+ logs.writeLine({ scope: 'main', level: 'info', message: 'powerSaveBlocker started', meta: { id: powerBlockerId } });
861
+ } catch (e) {
862
+ logs.writeLine({ scope: 'main', level: 'warn', message: 'powerSaveBlocker failed', meta: { error: e?.message } });
863
+ }
864
+
791
865
  // OTEL: load persisted config and start the exporter only if `enabled`.
792
866
  // Failures are non-fatal — the app must keep working without telemetry.
793
867
  otelSettings.load()
@@ -806,6 +880,10 @@ app.on('will-quit', () => {
806
880
  // PRD F1 v2 §IPC plumbing: must unregisterAll on will-quit.
807
881
  try { globalShortcut.unregisterAll(); } catch { /* */ }
808
882
  voiceHotkey.disposeOnQuit();
883
+ if (powerBlockerId !== -1) {
884
+ try { powerSaveBlocker.stop(powerBlockerId); } catch { /* */ }
885
+ powerBlockerId = -1;
886
+ }
809
887
  });
810
888
 
811
889
  app.on('window-all-closed', () => {
@@ -25,6 +25,7 @@ const os = require('node:os');
25
25
  const chokidar = require('chokidar');
26
26
  const otel = require('./otel.cjs');
27
27
  const logs = require('./logs.cjs');
28
+ const usageMatrix = require('./usageMatrix.cjs');
28
29
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
29
30
 
30
31
  let window = null;
@@ -142,6 +143,14 @@ async function flush(sub, { emit = true } = {}) {
142
143
  // Ring buffer (cap at 500 entries to bound memory).
143
144
  sub.buffer.push(ev);
144
145
  if (sub.buffer.length > 500) sub.buffer.shift();
146
+ // Feed the AgOps aggregator on every event — both replay and live, so
147
+ // freshly-attached tabs land with full history reflected in the matrix.
148
+ usageMatrix.recordEvent({
149
+ tabId: sub.tabId,
150
+ cwd: sub.cwd,
151
+ sessionUuid: sub.sessionUuid,
152
+ ev,
153
+ });
145
154
  if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
146
155
  // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
147
156
  // so backfilled transcripts show up in the trace store.
@@ -206,6 +215,8 @@ function unsubscribe(tabId) {
206
215
  if (!sub) return;
207
216
  sub.watcher?.close().catch(() => {});
208
217
  subs.delete(tabId);
218
+ // Drop the tab from the AgOps matrix — "active sessions" only.
219
+ usageMatrix.removeTab(tabId);
209
220
  }
210
221
 
211
222
  function getBuffer(tabId) {
@@ -216,6 +227,7 @@ function getBuffer(tabId) {
216
227
  function closeAll() {
217
228
  for (const sub of subs.values()) sub.watcher?.close().catch(() => {});
218
229
  subs.clear();
230
+ usageMatrix.closeAll();
219
231
  }
220
232
 
221
233
  function registerTranscriptHandlers() {
@@ -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 }),