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.
- package/dist/assets/{TiptapBody-CzNkD0kT.js → TiptapBody-CFh7PFkf.js} +1 -1
- package/dist/assets/{cssMode-DUMBBgBO.js → cssMode-Cll4QpTW.js} +1 -1
- package/dist/assets/{freemarker2-BjnoO8uS.js → freemarker2-lo-fLEl5.js} +1 -1
- package/dist/assets/{handlebars-CggfdTjZ.js → handlebars-CMA5gb0k.js} +1 -1
- package/dist/assets/{html-Bcnp_pNA.js → html-BQ2X1VHF.js} +1 -1
- package/dist/assets/{htmlMode-CFws8rf5.js → htmlMode-Da8nU8Ys.js} +1 -1
- package/dist/assets/{index-DOo1Vtox.css → index-6uZy0Pbe.css} +1 -1
- package/dist/assets/{index-0geebEag.js → index-Bs_D2jQM.js} +1473 -1410
- package/dist/assets/{javascript-DI4e0TwT.js → javascript-BfjktvBd.js} +1 -1
- package/dist/assets/{jsonMode-Ca0fgEAF.js → jsonMode-Duc74W4E.js} +1 -1
- package/dist/assets/{liquid-E7hk4hx9.js → liquid-CONgoaDI.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-Wh7EFavn.js → lspLanguageFeatures-DdHwUVu5.js} +1 -1
- package/dist/assets/{mdx-iXIhpMBJ.js → mdx-DIQgvOto.js} +1 -1
- package/dist/assets/{python-laxy9eG1.js → python-BcGmrT3q.js} +1 -1
- package/dist/assets/{razor-CatPL_eG.js → razor-B2RyqYc0.js} +1 -1
- package/dist/assets/{tsMode-D2WL55Cf.js → tsMode-CRnbNLOY.js} +1 -1
- package/dist/assets/{typescript-B5DFbVoi.js → typescript-CeTpx2hJ.js} +1 -1
- package/dist/assets/{xml-BsStStQW.js → xml-DIN_EFpG.js} +1 -1
- package/dist/assets/{yaml-rg2IIDpu.js → yaml-DpCxufnr.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/main/index.cjs +82 -4
- package/src/main/transcripts.cjs +12 -0
- package/src/main/usageMatrix.cjs +336 -0
- package/src/preload/api.d.ts +52 -0
- package/src/preload/index.cjs +8 -0
package/src/main/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
package/src/main/transcripts.cjs
CHANGED
|
@@ -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
|
+
};
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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>;
|
package/src/preload/index.cjs
CHANGED
|
@@ -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 }),
|