agentlytics 0.2.8 → 0.2.9
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/README.md +60 -1
- package/deno.json +9 -0
- package/index.js +46 -8
- package/mod.ts +1020 -0
- package/package.json +7 -2
- package/relay-server.js +1 -1
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +13 -8
- package/ui/src/hooks/useLive.jsx +15 -0
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-16-818cf8" alt="editors"></a>
|
|
15
15
|
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
16
16
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
|
|
17
|
+
<a href="https://deno.land"><img src="https://img.shields.io/badge/deno-%E2%89%A52.0-000?logo=deno" alt="deno"></a>
|
|
17
18
|
</p>
|
|
18
19
|
|
|
19
20
|
<p align="center">
|
|
@@ -39,10 +40,61 @@ You switch between Cursor, Windsurf, Claude Code, VS Code Copilot, and more —
|
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
42
|
npx agentlytics
|
|
43
|
+
# or
|
|
44
|
+
pnpm dlx agentlytics
|
|
45
|
+
# or
|
|
46
|
+
yarn dlx agentlytics
|
|
47
|
+
# or
|
|
48
|
+
bunx agentlytics
|
|
42
49
|
```
|
|
43
50
|
|
|
44
51
|
Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
|
|
45
52
|
|
|
53
|
+
### Deno (Sandboxed)
|
|
54
|
+
|
|
55
|
+
Run a lightweight, zero-dependency analytics scan with Deno's permission sandbox — directly from a URL, no install needed:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Only `--allow-read` and `--allow-env` are required. No network access, no file writes, no code execution — just reads your local editor data and prints a summary.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
(● ●) [● ●] Agentlytics — Deno Sandboxed Edition
|
|
65
|
+
{● ●} <● ●> Lightweight CLI analytics for AI coding agents
|
|
66
|
+
|
|
67
|
+
✓ Claude Code 8 sessions
|
|
68
|
+
✓ VS Code 23 sessions
|
|
69
|
+
✓ VS Code Insiders 66 sessions
|
|
70
|
+
● Cursor detected
|
|
71
|
+
✓ Codex CLI 3 sessions
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
Summary
|
|
75
|
+
Sessions 109
|
|
76
|
+
Messages 459
|
|
77
|
+
Projects 18
|
|
78
|
+
Editors 7 of 15 checked
|
|
79
|
+
Date range 2025-04-02 → 2026-03-09
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Add `--json` for machine-readable output:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
deno run --allow-read --allow-env mod.ts --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If you've cloned the repo, you can also use Deno tasks for the full dashboard:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
deno task start # Full dashboard (all permissions)
|
|
92
|
+
deno task scan # Lightweight CLI scan
|
|
93
|
+
deno task scan:json # JSON output
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Node.js
|
|
97
|
+
|
|
46
98
|
```
|
|
47
99
|
$ npx agentlytics
|
|
48
100
|
|
|
@@ -68,6 +120,7 @@ To only build the cache without starting the server:
|
|
|
68
120
|
|
|
69
121
|
```bash
|
|
70
122
|
npx agentlytics --collect
|
|
123
|
+
# or: pnpm dlx agentlytics --collect
|
|
71
124
|
```
|
|
72
125
|
|
|
73
126
|
## Features
|
|
@@ -112,6 +165,7 @@ Relay enables multi-user context sharing across a team. One person starts a rela
|
|
|
112
165
|
|
|
113
166
|
```bash
|
|
114
167
|
npx agentlytics --relay
|
|
168
|
+
# or: pnpm dlx agentlytics --relay
|
|
115
169
|
```
|
|
116
170
|
|
|
117
171
|
Optionally protect with a password:
|
|
@@ -138,6 +192,7 @@ This starts a relay server on port `4638` and prints the join command and MCP en
|
|
|
138
192
|
```bash
|
|
139
193
|
cd /path/to/your-project
|
|
140
194
|
npx agentlytics --join <host:port>
|
|
195
|
+
# or: pnpm dlx agentlytics --join <host:port>
|
|
141
196
|
```
|
|
142
197
|
|
|
143
198
|
If the relay is password-protected:
|
|
@@ -186,7 +241,11 @@ Editor files/APIs → editors/*.js → cache.js (SQLite) → server.js (REST)
|
|
|
186
241
|
Relay: join clients → POST /relay/sync → relay.db (SQLite) → MCP server → AI clients
|
|
187
242
|
```
|
|
188
243
|
|
|
189
|
-
|
|
244
|
+
```
|
|
245
|
+
Deno: Editor files → mod.ts (zero deps) → stdout (CLI/JSON)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`. The Deno sandboxed edition (`mod.ts`) bypasses SQLite entirely and reads editor files directly for a lightweight, permission-minimal CLI report.
|
|
190
249
|
|
|
191
250
|
## API
|
|
192
251
|
|
package/deno.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": {
|
|
3
|
+
"start": "deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-run index.js",
|
|
4
|
+
"collect": "deno run --allow-read --allow-write --allow-env --allow-ffi index.js --collect",
|
|
5
|
+
"scan": "deno run --allow-read --allow-env mod.ts",
|
|
6
|
+
"scan:json": "deno run --allow-read --allow-env mod.ts --json"
|
|
7
|
+
},
|
|
8
|
+
"nodeModulesDir": "auto"
|
|
9
|
+
}
|
package/index.js
CHANGED
|
@@ -6,11 +6,44 @@ const path = require('path');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const { execSync } = require('child_process');
|
|
8
8
|
|
|
9
|
+
// ── Node.js version check ─────────────────────────────────
|
|
10
|
+
const nodeVersion = process.versions.node;
|
|
11
|
+
const [nodeMajor, nodeMinor] = nodeVersion.split('.').map(Number);
|
|
12
|
+
const isNodeSupported =
|
|
13
|
+
(nodeMajor === 20 && nodeMinor >= 19) ||
|
|
14
|
+
(nodeMajor === 22 && nodeMinor >= 12) ||
|
|
15
|
+
nodeMajor === 23 || nodeMajor === 24 || nodeMajor >= 25;
|
|
16
|
+
|
|
17
|
+
if (!isNodeSupported) {
|
|
18
|
+
console.error('');
|
|
19
|
+
console.error(` \x1b[31m✗ Unsupported Node.js version: v${nodeVersion}\x1b[0m`);
|
|
20
|
+
console.error('');
|
|
21
|
+
console.error(` \x1b[1mAgentlytics requires Node.js 20.19+ or 22.12+\x1b[0m`);
|
|
22
|
+
console.error(` \x1b[2mYour current version: v${nodeVersion}\x1b[0m`);
|
|
23
|
+
console.error('');
|
|
24
|
+
console.error(` \x1b[2mTo upgrade, visit: https://nodejs.org\x1b[0m`);
|
|
25
|
+
console.error(` \x1b[2mOr use a version manager: nvm install 22\x1b[0m`);
|
|
26
|
+
console.error('');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
const HOME = os.homedir();
|
|
31
|
+
|
|
32
|
+
// ── Detect package manager ─────────────────────────────────
|
|
33
|
+
function detectPackageManager() {
|
|
34
|
+
const ua = process.env.npm_config_user_agent || '';
|
|
35
|
+
if (ua.startsWith('pnpm/')) return 'pnpm';
|
|
36
|
+
if (ua.startsWith('yarn/')) return 'yarn';
|
|
37
|
+
if (ua.startsWith('bun/')) return 'bun';
|
|
38
|
+
return 'npm';
|
|
39
|
+
}
|
|
40
|
+
const PM = detectPackageManager();
|
|
41
|
+
const PM_RUN = PM === 'npm' ? 'npx' : PM === 'pnpm' ? 'pnpm dlx' : PM === 'yarn' ? 'yarn dlx' : 'bunx';
|
|
10
42
|
const PORT = process.env.PORT || 4637;
|
|
11
43
|
const RELAY_PORT = process.env.RELAY_PORT || 4638;
|
|
12
44
|
const noCache = process.argv.includes('--no-cache');
|
|
13
45
|
const collectOnly = process.argv.includes('--collect');
|
|
46
|
+
const isUiDev = process.argv.includes('--ui-dev');
|
|
14
47
|
const isRelay = process.argv.includes('--relay');
|
|
15
48
|
const joinIndex = process.argv.indexOf('--join');
|
|
16
49
|
const isJoin = joinIndex !== -1;
|
|
@@ -47,7 +80,7 @@ if (isRelay) {
|
|
|
47
80
|
console.log('');
|
|
48
81
|
console.log(chalk.bold(' Share this command with your team:'));
|
|
49
82
|
console.log('');
|
|
50
|
-
console.log(chalk.cyan(`
|
|
83
|
+
console.log(chalk.cyan(` ${PM_RUN} agentlytics --join ${localIp}:${RELAY_PORT} --username <name>`));
|
|
51
84
|
console.log('');
|
|
52
85
|
console.log(chalk.bold(' MCP server endpoint (add to your AI client):'));
|
|
53
86
|
console.log('');
|
|
@@ -76,7 +109,7 @@ if (isJoin) {
|
|
|
76
109
|
let username = usernameIndex !== -1 ? process.argv[usernameIndex + 1] : null;
|
|
77
110
|
|
|
78
111
|
if (!relayAddress) {
|
|
79
|
-
console.error(chalk.red(
|
|
112
|
+
console.error(chalk.red(`\n ✗ Missing relay address. Usage: ${PM_RUN} agentlytics --join <host:port> --username <name>\n`));
|
|
80
113
|
process.exit(1);
|
|
81
114
|
}
|
|
82
115
|
|
|
@@ -135,15 +168,20 @@ console.log('');
|
|
|
135
168
|
const publicIndex = path.join(__dirname, 'public', 'index.html');
|
|
136
169
|
const uiDir = path.join(__dirname, 'ui');
|
|
137
170
|
|
|
138
|
-
if (!collectOnly && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
|
|
171
|
+
if (!collectOnly && !isUiDev && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
|
|
139
172
|
console.log(chalk.cyan(' ⟳ Building dashboard UI (first run)...'));
|
|
140
173
|
try {
|
|
141
174
|
const uiModules = path.join(uiDir, 'node_modules');
|
|
142
175
|
if (fs.existsSync(uiModules)) fs.rmSync(uiModules, { recursive: true, force: true });
|
|
143
176
|
console.log(chalk.dim(' Installing UI dependencies...'));
|
|
144
|
-
|
|
177
|
+
const installCmd = PM === 'npm' ? 'npm install --no-audit --no-fund'
|
|
178
|
+
: PM === 'pnpm' ? 'pnpm install --no-frozen-lockfile'
|
|
179
|
+
: PM === 'yarn' ? 'yarn install'
|
|
180
|
+
: 'bun install';
|
|
181
|
+
const buildCmd = `${PM} run build`;
|
|
182
|
+
execSync(installCmd, { cwd: uiDir, stdio: 'pipe' });
|
|
145
183
|
console.log(chalk.dim(' Compiling frontend...'));
|
|
146
|
-
execSync(
|
|
184
|
+
execSync(buildCmd, { cwd: uiDir, stdio: 'pipe' });
|
|
147
185
|
console.log(chalk.green(' ✓ UI built successfully'));
|
|
148
186
|
} catch (err) {
|
|
149
187
|
console.error(chalk.red(' ✗ UI build failed:'), err.message);
|
|
@@ -152,9 +190,9 @@ if (!collectOnly && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
|
|
|
152
190
|
console.log('');
|
|
153
191
|
}
|
|
154
192
|
|
|
155
|
-
if (!collectOnly && !fs.existsSync(publicIndex)) {
|
|
193
|
+
if (!collectOnly && !isUiDev && !fs.existsSync(publicIndex)) {
|
|
156
194
|
console.error(chalk.red(' ✗ No built UI found at public/index.html'));
|
|
157
|
-
console.error(chalk.dim(
|
|
195
|
+
console.error(chalk.dim(` Run: cd ui && ${PM} install && ${PM} run build`));
|
|
158
196
|
process.exit(1);
|
|
159
197
|
}
|
|
160
198
|
|
|
@@ -365,12 +403,12 @@ const BOT_STYLES = [
|
|
|
365
403
|
}
|
|
366
404
|
|
|
367
405
|
app.listen(port, '0.0.0.0', () => {
|
|
406
|
+
if (isUiDev) return;
|
|
368
407
|
const url = `http://localhost:${port}`;
|
|
369
408
|
console.log(chalk.green(` ✓ Dashboard ready at ${chalk.bold.white(url)}`));
|
|
370
409
|
console.log('');
|
|
371
410
|
console.log(chalk.dim(' Press Ctrl+C to stop\n'));
|
|
372
411
|
|
|
373
|
-
// Auto-open browser
|
|
374
412
|
const open = require('open');
|
|
375
413
|
open(url).catch(() => {});
|
|
376
414
|
});
|
package/mod.ts
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
#!/usr/bin/env -S deno run --allow-read --allow-env
|
|
2
|
+
// ============================================================
|
|
3
|
+
// Agentlytics — Deno Sandboxed Edition
|
|
4
|
+
// Lightweight CLI analytics for AI coding agents
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
|
|
8
|
+
// deno run --allow-read --allow-env mod.ts
|
|
9
|
+
// deno run --allow-read --allow-env mod.ts --json
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
// ── ANSI helpers (zero dependencies) ─────────────────────────
|
|
13
|
+
|
|
14
|
+
const noColor = Deno.env.get("NO_COLOR") !== undefined;
|
|
15
|
+
const bold = (s: string) => noColor ? s : `\x1b[1m${s}\x1b[0m`;
|
|
16
|
+
const dim = (s: string) => noColor ? s : `\x1b[2m${s}\x1b[0m`;
|
|
17
|
+
const green = (s: string) => noColor ? s : `\x1b[32m${s}\x1b[0m`;
|
|
18
|
+
const yellow = (s: string) => noColor ? s : `\x1b[33m${s}\x1b[0m`;
|
|
19
|
+
const cyan = (s: string) => noColor ? s : `\x1b[36m${s}\x1b[0m`;
|
|
20
|
+
const red = (s: string) => noColor ? s : `\x1b[31m${s}\x1b[0m`;
|
|
21
|
+
const hex = (color: string) => {
|
|
22
|
+
if (noColor) return (s: string) => s;
|
|
23
|
+
const r = parseInt(color.slice(1, 3), 16);
|
|
24
|
+
const g = parseInt(color.slice(3, 5), 16);
|
|
25
|
+
const b = parseInt(color.slice(5, 7), 16);
|
|
26
|
+
return (s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Platform detection ───────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const HOME = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "";
|
|
32
|
+
const PLATFORM = Deno.build.os; // "darwin", "windows", "linux"
|
|
33
|
+
|
|
34
|
+
function join(...parts: string[]): string {
|
|
35
|
+
const sep = PLATFORM === "windows" ? "\\" : "/";
|
|
36
|
+
return parts.join(sep).replace(/[/\\]+/g, sep);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function basename(p: string): string {
|
|
40
|
+
const parts = p.replace(/\\/g, "/").split("/");
|
|
41
|
+
return parts[parts.length - 1] || "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── File system helpers ──────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function existsSync(path: string): boolean {
|
|
47
|
+
try { Deno.statSync(path); return true; } catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readTextSync(path: string): string {
|
|
51
|
+
return Deno.readTextFileSync(path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readDirNames(path: string): string[] {
|
|
55
|
+
try {
|
|
56
|
+
return [...Deno.readDirSync(path)].map((e) => e.name);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readDirEntries(path: string): Deno.DirEntry[] {
|
|
63
|
+
try {
|
|
64
|
+
return [...Deno.readDirSync(path)];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fileMtime(path: string): number | null {
|
|
71
|
+
try {
|
|
72
|
+
const info = Deno.statSync(path);
|
|
73
|
+
return info.mtime ? info.mtime.getTime() : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fileBirthtime(path: string): number | null {
|
|
80
|
+
try {
|
|
81
|
+
const info = Deno.statSync(path);
|
|
82
|
+
return info.birthtime ? info.birthtime.getTime() : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDirectory(path: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
return Deno.statSync(path).isDirectory;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getAppDataPath(appName: string): string {
|
|
97
|
+
switch (PLATFORM) {
|
|
98
|
+
case "darwin":
|
|
99
|
+
return join(HOME, "Library", "Application Support", appName);
|
|
100
|
+
case "windows":
|
|
101
|
+
return join(HOME, "AppData", "Roaming", appName);
|
|
102
|
+
default:
|
|
103
|
+
return join(HOME, ".config", appName);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Types ────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
interface Chat {
|
|
110
|
+
source: string;
|
|
111
|
+
composerId: string;
|
|
112
|
+
name: string | null;
|
|
113
|
+
createdAt: number | null;
|
|
114
|
+
lastUpdatedAt: number | null;
|
|
115
|
+
mode: string;
|
|
116
|
+
folder: string | null;
|
|
117
|
+
bubbleCount: number;
|
|
118
|
+
messageCount?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface EditorResult {
|
|
122
|
+
name: string;
|
|
123
|
+
label: string;
|
|
124
|
+
detected: boolean;
|
|
125
|
+
sessions: Chat[];
|
|
126
|
+
note?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Editor: Claude Code ──────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function scanClaude(): EditorResult {
|
|
132
|
+
const claudeDir = join(HOME, ".claude");
|
|
133
|
+
const projectsDir = join(claudeDir, "projects");
|
|
134
|
+
const result: EditorResult = { name: "claude-code", label: "Claude Code", detected: false, sessions: [] };
|
|
135
|
+
|
|
136
|
+
if (!existsSync(projectsDir)) return result;
|
|
137
|
+
result.detected = true;
|
|
138
|
+
|
|
139
|
+
for (const projDir of readDirNames(projectsDir)) {
|
|
140
|
+
const dir = join(projectsDir, projDir);
|
|
141
|
+
if (!isDirectory(dir)) continue;
|
|
142
|
+
|
|
143
|
+
const decodedFolder = projDir.replace(/-/g, "/");
|
|
144
|
+
|
|
145
|
+
// Read sessions-index.json for metadata
|
|
146
|
+
const indexPath = join(dir, "sessions-index.json");
|
|
147
|
+
const indexed = new Map<string, Record<string, unknown>>();
|
|
148
|
+
try {
|
|
149
|
+
const index = JSON.parse(readTextSync(indexPath));
|
|
150
|
+
for (const entry of index.entries || []) {
|
|
151
|
+
indexed.set(entry.sessionId, entry);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* no index */ }
|
|
154
|
+
|
|
155
|
+
const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl"));
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const sessionId = file.replace(".jsonl", "");
|
|
158
|
+
const fullPath = join(dir, file);
|
|
159
|
+
const entry = indexed.get(sessionId);
|
|
160
|
+
|
|
161
|
+
let msgCount = 0;
|
|
162
|
+
try {
|
|
163
|
+
const content = readTextSync(fullPath);
|
|
164
|
+
const lines = content.split("\n").filter(Boolean);
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
try {
|
|
167
|
+
const obj = JSON.parse(line);
|
|
168
|
+
if (obj.type === "user" || obj.type === "assistant") msgCount++;
|
|
169
|
+
} catch { /* skip */ }
|
|
170
|
+
}
|
|
171
|
+
} catch { /* skip */ }
|
|
172
|
+
|
|
173
|
+
if (entry) {
|
|
174
|
+
const e = entry as Record<string, unknown>;
|
|
175
|
+
result.sessions.push({
|
|
176
|
+
source: "claude-code",
|
|
177
|
+
composerId: sessionId,
|
|
178
|
+
name: cleanPrompt(e.firstPrompt as string),
|
|
179
|
+
createdAt: e.created ? new Date(e.created as string).getTime() : null,
|
|
180
|
+
lastUpdatedAt: e.modified ? new Date(e.modified as string).getTime() : fileMtime(fullPath),
|
|
181
|
+
mode: "claude",
|
|
182
|
+
folder: (e.projectPath as string) || decodedFolder,
|
|
183
|
+
bubbleCount: (e.messageCount as number) || msgCount,
|
|
184
|
+
messageCount: msgCount,
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
const meta = peekClaudeMeta(fullPath);
|
|
188
|
+
result.sessions.push({
|
|
189
|
+
source: "claude-code",
|
|
190
|
+
composerId: sessionId,
|
|
191
|
+
name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
|
|
192
|
+
createdAt: meta.timestamp || fileBirthtime(fullPath),
|
|
193
|
+
lastUpdatedAt: fileMtime(fullPath),
|
|
194
|
+
mode: "claude",
|
|
195
|
+
folder: meta.cwd || decodedFolder,
|
|
196
|
+
bubbleCount: msgCount,
|
|
197
|
+
messageCount: msgCount,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function peekClaudeMeta(filePath: string): { firstPrompt: string | null; cwd: string | null; timestamp: number | null } {
|
|
206
|
+
const meta = { firstPrompt: null as string | null, cwd: null as string | null, timestamp: null as number | null };
|
|
207
|
+
try {
|
|
208
|
+
const buf = readTextSync(filePath);
|
|
209
|
+
for (const line of buf.split("\n")) {
|
|
210
|
+
if (!line) continue;
|
|
211
|
+
const obj = JSON.parse(line);
|
|
212
|
+
if (!meta.cwd && obj.cwd) meta.cwd = obj.cwd;
|
|
213
|
+
if (!meta.timestamp && obj.timestamp) {
|
|
214
|
+
meta.timestamp = typeof obj.timestamp === "string" ? new Date(obj.timestamp).getTime() : obj.timestamp;
|
|
215
|
+
}
|
|
216
|
+
if (!meta.firstPrompt && obj.type === "user" && obj.message?.content) {
|
|
217
|
+
const text = typeof obj.message.content === "string"
|
|
218
|
+
? obj.message.content
|
|
219
|
+
: obj.message.content.filter((c: Record<string, unknown>) => c.type === "text").map((c: Record<string, unknown>) => c.text).join(" ");
|
|
220
|
+
meta.firstPrompt = text.substring(0, 200);
|
|
221
|
+
}
|
|
222
|
+
if (meta.cwd && meta.firstPrompt) break;
|
|
223
|
+
}
|
|
224
|
+
} catch { /* skip */ }
|
|
225
|
+
return meta;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function cleanPrompt(prompt: string | null | undefined): string | null {
|
|
229
|
+
if (!prompt || prompt === "No prompt") return null;
|
|
230
|
+
const clean = prompt
|
|
231
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
232
|
+
.replace(/<[^>]+>/g, "")
|
|
233
|
+
.replace(/\s+/g, " ")
|
|
234
|
+
.trim()
|
|
235
|
+
.substring(0, 120);
|
|
236
|
+
return clean || null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Editor: VS Code / Copilot Chat ──────────────────────────
|
|
240
|
+
|
|
241
|
+
function scanVSCode(): EditorResult[] {
|
|
242
|
+
const variants = [
|
|
243
|
+
{ id: "vscode", label: "VS Code", appSupport: getAppDataPath("Code") },
|
|
244
|
+
{ id: "vscode-insiders", label: "VS Code Insiders", appSupport: getAppDataPath("Code - Insiders") },
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const results: EditorResult[] = [];
|
|
248
|
+
|
|
249
|
+
for (const variant of variants) {
|
|
250
|
+
const result: EditorResult = { name: variant.id, label: variant.label, detected: false, sessions: [] };
|
|
251
|
+
if (!existsSync(variant.appSupport)) { results.push(result); continue; }
|
|
252
|
+
result.detected = true;
|
|
253
|
+
|
|
254
|
+
// Global (empty window) chat sessions
|
|
255
|
+
const globalDir = join(variant.appSupport, "User", "globalStorage", "emptyWindowChatSessions");
|
|
256
|
+
if (existsSync(globalDir)) {
|
|
257
|
+
collectVSCodeSessions(globalDir, null, variant.id, result.sessions);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Workspace chat sessions
|
|
261
|
+
const wsRoot = join(variant.appSupport, "User", "workspaceStorage");
|
|
262
|
+
if (existsSync(wsRoot)) {
|
|
263
|
+
for (const wsHash of readDirNames(wsRoot)) {
|
|
264
|
+
const wsDir = join(wsRoot, wsHash);
|
|
265
|
+
if (!isDirectory(wsDir)) continue;
|
|
266
|
+
const chatDir = join(wsDir, "chatSessions");
|
|
267
|
+
if (!existsSync(chatDir)) continue;
|
|
268
|
+
const folder = getVSCodeWorkspaceFolder(wsDir);
|
|
269
|
+
collectVSCodeSessions(chatDir, folder, variant.id, result.sessions);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
results.push(result);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getVSCodeWorkspaceFolder(wsDir: string): string | null {
|
|
280
|
+
const wsJson = join(wsDir, "workspace.json");
|
|
281
|
+
if (!existsSync(wsJson)) return null;
|
|
282
|
+
try {
|
|
283
|
+
const data = JSON.parse(readTextSync(wsJson));
|
|
284
|
+
const uri = data.folder || data.workspace;
|
|
285
|
+
if (uri) return decodeURIComponent(uri.replace("file://", ""));
|
|
286
|
+
} catch { /* skip */ }
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function collectVSCodeSessions(dir: string, folder: string | null, source: string, chats: Chat[]) {
|
|
291
|
+
const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl") || f.endsWith(".json"));
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
const filePath = join(dir, file);
|
|
294
|
+
try {
|
|
295
|
+
const meta = peekVSCodeMeta(filePath);
|
|
296
|
+
chats.push({
|
|
297
|
+
source,
|
|
298
|
+
composerId: meta.sessionId || file.replace(/\.(jsonl|json)$/, ""),
|
|
299
|
+
name: meta.title || meta.firstUserText || null,
|
|
300
|
+
createdAt: meta.createdAt || fileBirthtime(filePath),
|
|
301
|
+
lastUpdatedAt: fileMtime(filePath),
|
|
302
|
+
mode: "copilot",
|
|
303
|
+
folder,
|
|
304
|
+
bubbleCount: meta.requestCount || 0,
|
|
305
|
+
});
|
|
306
|
+
} catch { /* skip */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function peekVSCodeMeta(filePath: string): {
|
|
311
|
+
sessionId: string | null;
|
|
312
|
+
title: string | null;
|
|
313
|
+
createdAt: number | null;
|
|
314
|
+
requestCount: number;
|
|
315
|
+
firstUserText: string | null;
|
|
316
|
+
} {
|
|
317
|
+
if (filePath.endsWith(".json")) {
|
|
318
|
+
try {
|
|
319
|
+
const data = JSON.parse(readTextSync(filePath));
|
|
320
|
+
return {
|
|
321
|
+
sessionId: data.sessionId || null,
|
|
322
|
+
title: data.customTitle || null,
|
|
323
|
+
createdAt: data.creationDate || data.lastMessageDate || null,
|
|
324
|
+
requestCount: data.requests?.length || 0,
|
|
325
|
+
firstUserText: (data.requests?.[0]?.message?.text || "").substring(0, 120) || null,
|
|
326
|
+
};
|
|
327
|
+
} catch { return { sessionId: null, title: null, createdAt: null, requestCount: 0, firstUserText: null }; }
|
|
328
|
+
}
|
|
329
|
+
// JSONL
|
|
330
|
+
try {
|
|
331
|
+
const content = readTextSync(filePath);
|
|
332
|
+
const firstNewline = content.indexOf("\n");
|
|
333
|
+
const firstLine = firstNewline > 0 ? content.substring(0, firstNewline) : content;
|
|
334
|
+
const init = JSON.parse(firstLine);
|
|
335
|
+
const state = init.v || {};
|
|
336
|
+
let title = state.customTitle || null;
|
|
337
|
+
if (!title) {
|
|
338
|
+
const titleIdx = content.indexOf('"customTitle"');
|
|
339
|
+
if (titleIdx !== -1) {
|
|
340
|
+
const lineStart = content.lastIndexOf("\n", titleIdx) + 1;
|
|
341
|
+
const lineEnd = content.indexOf("\n", titleIdx);
|
|
342
|
+
const patchLine = content.substring(lineStart, lineEnd > 0 ? lineEnd : undefined);
|
|
343
|
+
try {
|
|
344
|
+
const patch = JSON.parse(patchLine);
|
|
345
|
+
if (patch.kind === 1 && patch.k[0] === "customTitle") title = patch.v;
|
|
346
|
+
} catch { /* skip */ }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
sessionId: state.sessionId || null,
|
|
351
|
+
title,
|
|
352
|
+
createdAt: state.creationDate || null,
|
|
353
|
+
requestCount: state.requests?.length || 0,
|
|
354
|
+
firstUserText: null,
|
|
355
|
+
};
|
|
356
|
+
} catch { return { sessionId: null, title: null, createdAt: null, requestCount: 0, firstUserText: null }; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Editor: Cursor (file-based chats only, no SQLite) ────────
|
|
360
|
+
|
|
361
|
+
function scanCursor(): EditorResult {
|
|
362
|
+
const cursorChatsDir = join(HOME, ".cursor", "chats");
|
|
363
|
+
const result: EditorResult = { name: "cursor", label: "Cursor", detected: false, sessions: [] };
|
|
364
|
+
|
|
365
|
+
// Check if Cursor app data exists
|
|
366
|
+
const cursorApp = getAppDataPath("Cursor");
|
|
367
|
+
if (!existsSync(cursorChatsDir) && !existsSync(cursorApp)) return result;
|
|
368
|
+
result.detected = true;
|
|
369
|
+
|
|
370
|
+
// Scan ~/.cursor/chats/<workspace>/<chatId>/ for agent-mode sessions
|
|
371
|
+
if (existsSync(cursorChatsDir)) {
|
|
372
|
+
for (const workspace of readDirNames(cursorChatsDir)) {
|
|
373
|
+
const wsDir = join(cursorChatsDir, workspace);
|
|
374
|
+
if (!isDirectory(wsDir)) continue;
|
|
375
|
+
for (const chatId of readDirNames(wsDir)) {
|
|
376
|
+
const chatDir = join(wsDir, chatId);
|
|
377
|
+
if (!isDirectory(chatDir)) continue;
|
|
378
|
+
|
|
379
|
+
// Try to read composer_data or similar JSON
|
|
380
|
+
const files = readDirNames(chatDir);
|
|
381
|
+
const jsonFile = files.find((f) => f.endsWith(".json"));
|
|
382
|
+
if (jsonFile) {
|
|
383
|
+
const filePath = join(chatDir, jsonFile);
|
|
384
|
+
try {
|
|
385
|
+
const data = JSON.parse(readTextSync(filePath));
|
|
386
|
+
result.sessions.push({
|
|
387
|
+
source: "cursor",
|
|
388
|
+
composerId: chatId,
|
|
389
|
+
name: data.name || data.title || null,
|
|
390
|
+
createdAt: data.createdAt || fileBirthtime(filePath),
|
|
391
|
+
lastUpdatedAt: fileMtime(filePath),
|
|
392
|
+
mode: "agent",
|
|
393
|
+
folder: data.folder || data.workspacePath || null,
|
|
394
|
+
bubbleCount: data.bubbleCount || 0,
|
|
395
|
+
});
|
|
396
|
+
} catch { /* skip */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Note: state.vscdb (SQLite) sessions require --allow-ffi
|
|
403
|
+
if (existsSync(join(cursorApp, "User", "globalStorage", "state.vscdb"))) {
|
|
404
|
+
result.note = "SQLite sessions available (run full version for complete data)";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Editor: Codex CLI ────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
function scanCodex(): EditorResult {
|
|
413
|
+
const codexHome = Deno.env.get("CODEX_HOME") || join(HOME, ".codex");
|
|
414
|
+
const result: EditorResult = { name: "codex", label: "Codex CLI", detected: false, sessions: [] };
|
|
415
|
+
|
|
416
|
+
const sessionDirs = [join(codexHome, "sessions"), join(codexHome, "archived_sessions")];
|
|
417
|
+
let found = false;
|
|
418
|
+
for (const dir of sessionDirs) {
|
|
419
|
+
if (!existsSync(dir)) continue;
|
|
420
|
+
found = true;
|
|
421
|
+
walkJsonlFiles(dir, (filePath) => {
|
|
422
|
+
try {
|
|
423
|
+
const content = readTextSync(filePath);
|
|
424
|
+
const lines = content.split("\n").filter(Boolean);
|
|
425
|
+
if (lines.length === 0) return;
|
|
426
|
+
|
|
427
|
+
const first = JSON.parse(lines[0]);
|
|
428
|
+
let msgCount = 0;
|
|
429
|
+
let model: string | null = null;
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
try {
|
|
432
|
+
const obj = JSON.parse(line);
|
|
433
|
+
if (obj.type === "message" || obj.role) msgCount++;
|
|
434
|
+
if (!model && obj.model) model = obj.model;
|
|
435
|
+
} catch { /* skip */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
result.sessions.push({
|
|
439
|
+
source: "codex",
|
|
440
|
+
composerId: first.id || basename(filePath).replace(".jsonl", ""),
|
|
441
|
+
name: first.instructions?.substring(0, 120) || null,
|
|
442
|
+
createdAt: first.created_at ? first.created_at * 1000 : fileBirthtime(filePath),
|
|
443
|
+
lastUpdatedAt: fileMtime(filePath),
|
|
444
|
+
mode: "codex",
|
|
445
|
+
folder: first.cwd || null,
|
|
446
|
+
bubbleCount: msgCount,
|
|
447
|
+
messageCount: msgCount,
|
|
448
|
+
});
|
|
449
|
+
} catch { /* skip */ }
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
result.detected = found;
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function walkJsonlFiles(dir: string, cb: (path: string) => void) {
|
|
458
|
+
for (const entry of readDirEntries(dir)) {
|
|
459
|
+
const fullPath = join(dir, entry.name);
|
|
460
|
+
if (entry.isDirectory) {
|
|
461
|
+
walkJsonlFiles(fullPath, cb);
|
|
462
|
+
} else if (entry.isFile && entry.name.endsWith(".jsonl")) {
|
|
463
|
+
cb(fullPath);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Editor: Gemini CLI ───────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
function scanGemini(): EditorResult {
|
|
471
|
+
const geminiDir = join(HOME, ".gemini");
|
|
472
|
+
const tmpDir = join(geminiDir, "tmp");
|
|
473
|
+
const result: EditorResult = { name: "gemini-cli", label: "Gemini CLI", detected: false, sessions: [] };
|
|
474
|
+
|
|
475
|
+
if (!existsSync(tmpDir)) return result;
|
|
476
|
+
result.detected = true;
|
|
477
|
+
|
|
478
|
+
// Load project map
|
|
479
|
+
const projectMap = new Map<string, string>();
|
|
480
|
+
try {
|
|
481
|
+
const data = JSON.parse(readTextSync(join(geminiDir, "projects.json")));
|
|
482
|
+
if (data.projects) {
|
|
483
|
+
for (const [folderPath, projName] of Object.entries(data.projects)) {
|
|
484
|
+
projectMap.set(projName as string, folderPath);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch { /* skip */ }
|
|
488
|
+
|
|
489
|
+
for (const projName of readDirNames(tmpDir)) {
|
|
490
|
+
const projDir = join(tmpDir, projName);
|
|
491
|
+
if (!isDirectory(projDir)) continue;
|
|
492
|
+
const folder = projectMap.get(projName) || null;
|
|
493
|
+
|
|
494
|
+
for (const file of readDirNames(projDir).filter((f) => f.endsWith(".jsonl"))) {
|
|
495
|
+
const filePath = join(projDir, file);
|
|
496
|
+
try {
|
|
497
|
+
const content = readTextSync(filePath);
|
|
498
|
+
const lines = content.split("\n").filter(Boolean);
|
|
499
|
+
let msgCount = 0;
|
|
500
|
+
let firstUserText: string | null = null;
|
|
501
|
+
for (const line of lines) {
|
|
502
|
+
try {
|
|
503
|
+
const obj = JSON.parse(line);
|
|
504
|
+
if (obj.role === "user" || obj.role === "model") msgCount++;
|
|
505
|
+
if (!firstUserText && obj.role === "user") {
|
|
506
|
+
const text = obj.parts?.[0]?.text || "";
|
|
507
|
+
firstUserText = text.substring(0, 120) || null;
|
|
508
|
+
}
|
|
509
|
+
} catch { /* skip */ }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
result.sessions.push({
|
|
513
|
+
source: "gemini-cli",
|
|
514
|
+
composerId: file.replace(".jsonl", ""),
|
|
515
|
+
name: firstUserText,
|
|
516
|
+
createdAt: fileBirthtime(filePath),
|
|
517
|
+
lastUpdatedAt: fileMtime(filePath),
|
|
518
|
+
mode: "gemini",
|
|
519
|
+
folder,
|
|
520
|
+
bubbleCount: msgCount,
|
|
521
|
+
messageCount: msgCount,
|
|
522
|
+
});
|
|
523
|
+
} catch { /* skip */ }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── Editor: Command Code ─────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
function scanCommandCode(): EditorResult {
|
|
533
|
+
const projectsDir = join(HOME, ".commandcode", "projects");
|
|
534
|
+
const result: EditorResult = { name: "commandcode", label: "Command Code", detected: false, sessions: [] };
|
|
535
|
+
|
|
536
|
+
if (!existsSync(projectsDir)) return result;
|
|
537
|
+
result.detected = true;
|
|
538
|
+
|
|
539
|
+
for (const projDir of readDirNames(projectsDir)) {
|
|
540
|
+
const dir = join(projectsDir, projDir);
|
|
541
|
+
if (!isDirectory(dir)) continue;
|
|
542
|
+
const decodedFolder = "/" + projDir.replace(/-/g, "/");
|
|
543
|
+
|
|
544
|
+
const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl") && !f.includes(".checkpoints."));
|
|
545
|
+
for (const file of files) {
|
|
546
|
+
const sessionId = file.replace(".jsonl", "");
|
|
547
|
+
const fullPath = join(dir, file);
|
|
548
|
+
const metaPath = join(dir, `${sessionId}.meta.json`);
|
|
549
|
+
|
|
550
|
+
let title: string | null = null;
|
|
551
|
+
try {
|
|
552
|
+
const meta = JSON.parse(readTextSync(metaPath));
|
|
553
|
+
title = meta.title || null;
|
|
554
|
+
} catch { /* skip */ }
|
|
555
|
+
|
|
556
|
+
let msgCount = 0;
|
|
557
|
+
try {
|
|
558
|
+
const content = readTextSync(fullPath);
|
|
559
|
+
for (const line of content.split("\n")) {
|
|
560
|
+
if (!line) continue;
|
|
561
|
+
try {
|
|
562
|
+
const obj = JSON.parse(line);
|
|
563
|
+
if (obj.type === "user" || obj.type === "assistant") msgCount++;
|
|
564
|
+
} catch { /* skip */ }
|
|
565
|
+
}
|
|
566
|
+
} catch { /* skip */ }
|
|
567
|
+
|
|
568
|
+
result.sessions.push({
|
|
569
|
+
source: "commandcode",
|
|
570
|
+
composerId: sessionId,
|
|
571
|
+
name: title,
|
|
572
|
+
createdAt: fileBirthtime(fullPath),
|
|
573
|
+
lastUpdatedAt: fileMtime(fullPath),
|
|
574
|
+
mode: "commandcode",
|
|
575
|
+
folder: decodedFolder,
|
|
576
|
+
bubbleCount: msgCount,
|
|
577
|
+
messageCount: msgCount,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Editor: Copilot CLI ──────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
function scanCopilot(): EditorResult {
|
|
588
|
+
const sessionStateDir = join(HOME, ".copilot", "session-state");
|
|
589
|
+
const result: EditorResult = { name: "copilot-cli", label: "Copilot CLI", detected: false, sessions: [] };
|
|
590
|
+
|
|
591
|
+
if (!existsSync(sessionStateDir)) return result;
|
|
592
|
+
result.detected = true;
|
|
593
|
+
|
|
594
|
+
for (const sessionDir of readDirNames(sessionStateDir)) {
|
|
595
|
+
const dir = join(sessionStateDir, sessionDir);
|
|
596
|
+
if (!isDirectory(dir)) continue;
|
|
597
|
+
|
|
598
|
+
// Parse workspace.yaml
|
|
599
|
+
const yamlPath = join(dir, "workspace.yaml");
|
|
600
|
+
let folder: string | null = null;
|
|
601
|
+
let summary: string | null = null;
|
|
602
|
+
let createdAt: string | null = null;
|
|
603
|
+
let updatedAt: string | null = null;
|
|
604
|
+
if (existsSync(yamlPath)) {
|
|
605
|
+
try {
|
|
606
|
+
const raw = readTextSync(yamlPath);
|
|
607
|
+
for (const line of raw.split("\n")) {
|
|
608
|
+
const match = line.match(/^(\w+):\s*(.*)$/);
|
|
609
|
+
if (!match) continue;
|
|
610
|
+
if (match[1] === "cwd" || match[1] === "git_root") folder = folder || match[2].trim();
|
|
611
|
+
if (match[1] === "summary") summary = match[2].trim();
|
|
612
|
+
if (match[1] === "created_at") createdAt = match[2].trim();
|
|
613
|
+
if (match[1] === "updated_at") updatedAt = match[2].trim();
|
|
614
|
+
}
|
|
615
|
+
} catch { /* skip */ }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Count events
|
|
619
|
+
const eventsPath = join(dir, "events.jsonl");
|
|
620
|
+
let msgCount = 0;
|
|
621
|
+
if (existsSync(eventsPath)) {
|
|
622
|
+
try {
|
|
623
|
+
const content = readTextSync(eventsPath);
|
|
624
|
+
for (const line of content.split("\n")) {
|
|
625
|
+
if (!line) continue;
|
|
626
|
+
try {
|
|
627
|
+
const obj = JSON.parse(line);
|
|
628
|
+
if (obj.type === "user.message" || obj.type === "assistant.message") msgCount++;
|
|
629
|
+
} catch { /* skip */ }
|
|
630
|
+
}
|
|
631
|
+
} catch { /* skip */ }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
result.sessions.push({
|
|
635
|
+
source: "copilot-cli",
|
|
636
|
+
composerId: sessionDir,
|
|
637
|
+
name: summary,
|
|
638
|
+
createdAt: createdAt ? new Date(createdAt).getTime() : fileBirthtime(dir),
|
|
639
|
+
lastUpdatedAt: updatedAt ? new Date(updatedAt).getTime() : fileMtime(dir),
|
|
640
|
+
mode: "copilot",
|
|
641
|
+
folder,
|
|
642
|
+
bubbleCount: msgCount,
|
|
643
|
+
messageCount: msgCount,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return result;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── Editor: Kiro ─────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
function scanKiro(): EditorResult {
|
|
653
|
+
const kiroAgentDir = join(getAppDataPath("Kiro"), "User", "globalStorage", "kiro.kiroagent");
|
|
654
|
+
const wsSessionsDir = join(kiroAgentDir, "workspace-sessions");
|
|
655
|
+
const result: EditorResult = { name: "kiro", label: "Kiro", detected: false, sessions: [] };
|
|
656
|
+
|
|
657
|
+
if (!existsSync(kiroAgentDir)) return result;
|
|
658
|
+
result.detected = true;
|
|
659
|
+
|
|
660
|
+
if (existsSync(wsSessionsDir)) {
|
|
661
|
+
for (const folder of readDirNames(wsSessionsDir)) {
|
|
662
|
+
const wsDir = join(wsSessionsDir, folder);
|
|
663
|
+
if (!isDirectory(wsDir)) continue;
|
|
664
|
+
|
|
665
|
+
// Decode base64 folder name
|
|
666
|
+
let workspacePath: string | null = null;
|
|
667
|
+
try { workspacePath = atob(folder); } catch { /* skip */ }
|
|
668
|
+
|
|
669
|
+
const indexPath = join(wsDir, "sessions.json");
|
|
670
|
+
let sessions: Record<string, unknown>[] = [];
|
|
671
|
+
try { sessions = JSON.parse(readTextSync(indexPath)); } catch { continue; }
|
|
672
|
+
|
|
673
|
+
for (const session of sessions) {
|
|
674
|
+
const sessionFile = join(wsDir, `${session.sessionId}.json`);
|
|
675
|
+
const exists = existsSync(sessionFile);
|
|
676
|
+
|
|
677
|
+
result.sessions.push({
|
|
678
|
+
source: "kiro",
|
|
679
|
+
composerId: session.sessionId as string,
|
|
680
|
+
name: (session.title as string) || null,
|
|
681
|
+
createdAt: parseInt(session.dateCreated as string) || null,
|
|
682
|
+
lastUpdatedAt: exists ? fileMtime(sessionFile) : parseInt(session.dateCreated as string) || null,
|
|
683
|
+
mode: "kiro",
|
|
684
|
+
folder: workspacePath,
|
|
685
|
+
bubbleCount: (session.messageCount as number) || 0,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Editor: Goose (file-based sessions) ──────────────────────
|
|
695
|
+
|
|
696
|
+
function scanGoose(): EditorResult {
|
|
697
|
+
const gooseDir = join(HOME, ".local", "share", "goose", "sessions");
|
|
698
|
+
const result: EditorResult = { name: "goose", label: "Goose", detected: false, sessions: [] };
|
|
699
|
+
|
|
700
|
+
if (!existsSync(gooseDir)) return result;
|
|
701
|
+
result.detected = true;
|
|
702
|
+
|
|
703
|
+
// Scan JSONL session files
|
|
704
|
+
for (const file of readDirNames(gooseDir).filter((f) => f.endsWith(".jsonl"))) {
|
|
705
|
+
const filePath = join(gooseDir, file);
|
|
706
|
+
try {
|
|
707
|
+
const content = readTextSync(filePath);
|
|
708
|
+
const lines = content.split("\n").filter(Boolean);
|
|
709
|
+
let msgCount = 0;
|
|
710
|
+
let firstUserText: string | null = null;
|
|
711
|
+
|
|
712
|
+
for (const line of lines) {
|
|
713
|
+
try {
|
|
714
|
+
const obj = JSON.parse(line);
|
|
715
|
+
if (obj.role === "user" || obj.role === "assistant") msgCount++;
|
|
716
|
+
if (!firstUserText && obj.role === "user") {
|
|
717
|
+
const text = typeof obj.content === "string" ? obj.content : "";
|
|
718
|
+
firstUserText = text.substring(0, 120) || null;
|
|
719
|
+
}
|
|
720
|
+
} catch { /* skip */ }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
result.sessions.push({
|
|
724
|
+
source: "goose",
|
|
725
|
+
composerId: file.replace(".jsonl", ""),
|
|
726
|
+
name: firstUserText,
|
|
727
|
+
createdAt: fileBirthtime(filePath),
|
|
728
|
+
lastUpdatedAt: fileMtime(filePath),
|
|
729
|
+
mode: "goose",
|
|
730
|
+
folder: null,
|
|
731
|
+
bubbleCount: msgCount,
|
|
732
|
+
messageCount: msgCount,
|
|
733
|
+
});
|
|
734
|
+
} catch { /* skip */ }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Note about SQLite sessions
|
|
738
|
+
const dbPath = join(gooseDir, "sessions.db");
|
|
739
|
+
if (existsSync(dbPath)) {
|
|
740
|
+
result.note = "SQLite sessions available (run full version for complete data)";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Editor: OpenCode (file-based sessions) ───────────────────
|
|
747
|
+
|
|
748
|
+
function scanOpenCode(): EditorResult {
|
|
749
|
+
const storageDir = join(HOME, ".local", "share", "opencode", "storage");
|
|
750
|
+
const sessionDir = join(storageDir, "session");
|
|
751
|
+
const result: EditorResult = { name: "opencode", label: "OpenCode", detected: false, sessions: [] };
|
|
752
|
+
|
|
753
|
+
if (!existsSync(sessionDir)) return result;
|
|
754
|
+
result.detected = true;
|
|
755
|
+
|
|
756
|
+
for (const file of readDirNames(sessionDir).filter((f) => f.endsWith(".json"))) {
|
|
757
|
+
const filePath = join(sessionDir, file);
|
|
758
|
+
try {
|
|
759
|
+
const data = JSON.parse(readTextSync(filePath));
|
|
760
|
+
result.sessions.push({
|
|
761
|
+
source: "opencode",
|
|
762
|
+
composerId: data.id || file.replace(".json", ""),
|
|
763
|
+
name: data.title || null,
|
|
764
|
+
createdAt: data.time_created ? new Date(data.time_created).getTime() : fileBirthtime(filePath),
|
|
765
|
+
lastUpdatedAt: data.time_updated ? new Date(data.time_updated).getTime() : fileMtime(filePath),
|
|
766
|
+
mode: "opencode",
|
|
767
|
+
folder: data.directory || null,
|
|
768
|
+
bubbleCount: 0,
|
|
769
|
+
});
|
|
770
|
+
} catch { /* skip */ }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return result;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── Editor: Windsurf / Antigravity (detection only) ──────────
|
|
777
|
+
|
|
778
|
+
function scanWindsurf(): EditorResult[] {
|
|
779
|
+
const variants = [
|
|
780
|
+
{ id: "windsurf", label: "Windsurf", dataDir: join(HOME, ".codeium", "windsurf") },
|
|
781
|
+
{ id: "windsurf-next", label: "Windsurf Next", dataDir: join(HOME, ".codeium", "windsurf-next") },
|
|
782
|
+
{ id: "antigravity", label: "Antigravity", dataDir: join(HOME, ".codeium", "antigravity") },
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
return variants.map((v) => {
|
|
786
|
+
const detected = existsSync(v.dataDir);
|
|
787
|
+
return {
|
|
788
|
+
name: v.id,
|
|
789
|
+
label: v.label,
|
|
790
|
+
detected,
|
|
791
|
+
sessions: [],
|
|
792
|
+
note: detected ? "Requires running editor + full version for session data" : undefined,
|
|
793
|
+
};
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Editor: Zed (detection only) ─────────────────────────────
|
|
798
|
+
|
|
799
|
+
function scanZed(): EditorResult {
|
|
800
|
+
let zedPath: string;
|
|
801
|
+
switch (PLATFORM) {
|
|
802
|
+
case "darwin":
|
|
803
|
+
zedPath = join(HOME, "Library", "Application Support", "Zed");
|
|
804
|
+
break;
|
|
805
|
+
case "windows":
|
|
806
|
+
zedPath = join(HOME, "AppData", "Local", "Zed");
|
|
807
|
+
break;
|
|
808
|
+
default:
|
|
809
|
+
zedPath = join(HOME, ".config", "Zed");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const threadsDb = join(zedPath, "threads", "threads.db");
|
|
813
|
+
const detected = existsSync(zedPath);
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
name: "zed",
|
|
817
|
+
label: "Zed",
|
|
818
|
+
detected,
|
|
819
|
+
sessions: [],
|
|
820
|
+
note: detected && existsSync(threadsDb) ? "SQLite sessions available (run full version for complete data)" : undefined,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── Aggregation & Display ────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
function formatDate(ts: number | null): string {
|
|
827
|
+
if (!ts) return "unknown";
|
|
828
|
+
const d = new Date(ts);
|
|
829
|
+
return d.toISOString().split("T")[0];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function formatRelative(ts: number | null): string {
|
|
833
|
+
if (!ts) return "";
|
|
834
|
+
const diff = Date.now() - ts;
|
|
835
|
+
const mins = Math.floor(diff / 60000);
|
|
836
|
+
if (mins < 60) return `${mins}m ago`;
|
|
837
|
+
const hours = Math.floor(mins / 60);
|
|
838
|
+
if (hours < 24) return `${hours}h ago`;
|
|
839
|
+
const days = Math.floor(hours / 24);
|
|
840
|
+
if (days < 30) return `${days}d ago`;
|
|
841
|
+
const months = Math.floor(days / 30);
|
|
842
|
+
return `${months}mo ago`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function main() {
|
|
846
|
+
const args = Deno.args;
|
|
847
|
+
const jsonOutput = args.includes("--json");
|
|
848
|
+
const showHelp = args.includes("--help") || args.includes("-h");
|
|
849
|
+
|
|
850
|
+
if (showHelp) {
|
|
851
|
+
console.log(`
|
|
852
|
+
${bold("Agentlytics")} — Deno Sandboxed Edition
|
|
853
|
+
Lightweight CLI analytics for your AI coding agents.
|
|
854
|
+
|
|
855
|
+
${bold("Usage:")}
|
|
856
|
+
deno run --allow-read --allow-env mod.ts [options]
|
|
857
|
+
deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
|
|
858
|
+
|
|
859
|
+
${bold("Options:")}
|
|
860
|
+
--json Output results as JSON
|
|
861
|
+
--help, -h Show this help message
|
|
862
|
+
|
|
863
|
+
${bold("Permissions:")}
|
|
864
|
+
--allow-read Read local editor data files (required)
|
|
865
|
+
--allow-env Access HOME directory path (required)
|
|
866
|
+
|
|
867
|
+
${bold("Full version:")}
|
|
868
|
+
For the complete dashboard with SQLite support, cost analytics,
|
|
869
|
+
and web UI, install the full version:
|
|
870
|
+
npx agentlytics
|
|
871
|
+
deno task start ${dim("(from cloned repo)")}
|
|
872
|
+
`);
|
|
873
|
+
Deno.exit(0);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Collect from all editors
|
|
877
|
+
const allResults: EditorResult[] = [];
|
|
878
|
+
|
|
879
|
+
allResults.push(scanClaude());
|
|
880
|
+
allResults.push(...scanVSCode());
|
|
881
|
+
allResults.push(scanCursor());
|
|
882
|
+
allResults.push(scanCodex());
|
|
883
|
+
allResults.push(scanGemini());
|
|
884
|
+
allResults.push(scanCopilot());
|
|
885
|
+
allResults.push(scanCommandCode());
|
|
886
|
+
allResults.push(scanKiro());
|
|
887
|
+
allResults.push(scanGoose());
|
|
888
|
+
allResults.push(scanOpenCode());
|
|
889
|
+
allResults.push(...scanWindsurf());
|
|
890
|
+
allResults.push(scanZed());
|
|
891
|
+
|
|
892
|
+
// Gather all sessions
|
|
893
|
+
const allSessions = allResults.flatMap((r) => r.sessions);
|
|
894
|
+
allSessions.sort((a, b) => (b.lastUpdatedAt || b.createdAt || 0) - (a.lastUpdatedAt || a.createdAt || 0));
|
|
895
|
+
|
|
896
|
+
// Unique projects
|
|
897
|
+
const projects = new Set(allSessions.map((s) => s.folder).filter(Boolean));
|
|
898
|
+
|
|
899
|
+
// Date range
|
|
900
|
+
const timestamps = allSessions.map((s) => s.createdAt || s.lastUpdatedAt || 0).filter((t) => t > 0);
|
|
901
|
+
const oldest = timestamps.length > 0 ? Math.min(...timestamps) : null;
|
|
902
|
+
const newest = timestamps.length > 0 ? Math.max(...timestamps) : null;
|
|
903
|
+
|
|
904
|
+
// Total messages
|
|
905
|
+
const totalMessages = allSessions.reduce((sum, s) => sum + (s.messageCount || s.bubbleCount || 0), 0);
|
|
906
|
+
|
|
907
|
+
if (jsonOutput) {
|
|
908
|
+
const output = {
|
|
909
|
+
timestamp: new Date().toISOString(),
|
|
910
|
+
platform: PLATFORM,
|
|
911
|
+
editors: allResults.map((r) => ({
|
|
912
|
+
name: r.name,
|
|
913
|
+
label: r.label,
|
|
914
|
+
detected: r.detected,
|
|
915
|
+
sessionCount: r.sessions.length,
|
|
916
|
+
note: r.note || null,
|
|
917
|
+
})),
|
|
918
|
+
summary: {
|
|
919
|
+
totalSessions: allSessions.length,
|
|
920
|
+
totalMessages,
|
|
921
|
+
totalProjects: projects.size,
|
|
922
|
+
dateRange: {
|
|
923
|
+
oldest: oldest ? new Date(oldest).toISOString() : null,
|
|
924
|
+
newest: newest ? new Date(newest).toISOString() : null,
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
sessions: allSessions,
|
|
928
|
+
};
|
|
929
|
+
console.log(JSON.stringify(output, null, 2));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Pretty CLI output ────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
const c1 = hex("#818cf8"), c2 = hex("#f472b6"), c3 = hex("#34d399"), c4 = hex("#fbbf24");
|
|
936
|
+
|
|
937
|
+
console.log("");
|
|
938
|
+
console.log(` ${c1("(● ●)")} ${c2("[● ●]")} ${bold("Agentlytics")} ${dim("— Deno Sandboxed Edition")}`);
|
|
939
|
+
console.log(` ${c3("{● ●}")} ${c4("<● ●>")} ${dim("Lightweight CLI analytics for AI coding agents")}`);
|
|
940
|
+
console.log("");
|
|
941
|
+
|
|
942
|
+
// Editor detection table
|
|
943
|
+
const detected = allResults.filter((r) => r.detected);
|
|
944
|
+
const notDetected = allResults.filter((r) => !r.detected);
|
|
945
|
+
|
|
946
|
+
for (const r of allResults) {
|
|
947
|
+
if (r.detected && r.sessions.length > 0) {
|
|
948
|
+
const count = r.sessions.length;
|
|
949
|
+
console.log(` ${green("✓")} ${bold(r.label.padEnd(22))} ${dim(`${count} session${count === 1 ? "" : "s"}`)}`);
|
|
950
|
+
} else if (r.detected) {
|
|
951
|
+
const note = r.note ? dim(` (${r.note})`) : "";
|
|
952
|
+
console.log(` ${yellow("●")} ${bold(r.label.padEnd(22))} ${dim("detected")}${note}`);
|
|
953
|
+
} else {
|
|
954
|
+
console.log(` ${dim("–")} ${dim(r.label.padEnd(22) + "–")}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
console.log("");
|
|
959
|
+
|
|
960
|
+
if (allSessions.length === 0) {
|
|
961
|
+
console.log(dim(" No sessions found. Make sure your editors have been used."));
|
|
962
|
+
console.log("");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Summary stats
|
|
967
|
+
console.log(` ${bold("Summary")}`);
|
|
968
|
+
console.log(` ${"Sessions".padEnd(18)} ${bold(String(allSessions.length))}`);
|
|
969
|
+
if (totalMessages > 0) {
|
|
970
|
+
console.log(` ${"Messages".padEnd(18)} ${bold(String(totalMessages))}`);
|
|
971
|
+
}
|
|
972
|
+
console.log(` ${"Projects".padEnd(18)} ${bold(String(projects.size))}`);
|
|
973
|
+
console.log(` ${"Editors".padEnd(18)} ${bold(String(detected.filter((r) => r.sessions.length > 0).length))} ${dim(`of ${allResults.length} checked`)}`);
|
|
974
|
+
if (oldest && newest) {
|
|
975
|
+
console.log(` ${"Date range".padEnd(18)} ${dim(`${formatDate(oldest)} → ${formatDate(newest)}`)}`);
|
|
976
|
+
}
|
|
977
|
+
console.log("");
|
|
978
|
+
|
|
979
|
+
// Top 5 recent sessions
|
|
980
|
+
const recent = allSessions.slice(0, 5);
|
|
981
|
+
if (recent.length > 0) {
|
|
982
|
+
console.log(` ${bold("Recent Sessions")}`);
|
|
983
|
+
for (const s of recent) {
|
|
984
|
+
const name = (s.name || "Untitled").substring(0, 50);
|
|
985
|
+
const editor = allResults.find((r) => r.name === s.source)?.label || s.source;
|
|
986
|
+
const time = formatRelative(s.lastUpdatedAt || s.createdAt);
|
|
987
|
+
console.log(` ${dim("•")} ${name}`);
|
|
988
|
+
console.log(` ${dim(`${editor} · ${time}${s.folder ? ` · ${basename(s.folder)}` : ""}`)}`);
|
|
989
|
+
}
|
|
990
|
+
console.log("");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Top projects
|
|
994
|
+
if (projects.size > 0) {
|
|
995
|
+
const projectCounts = new Map<string, number>();
|
|
996
|
+
for (const s of allSessions) {
|
|
997
|
+
if (s.folder) {
|
|
998
|
+
projectCounts.set(s.folder, (projectCounts.get(s.folder) || 0) + 1);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const topProjects = [...projectCounts.entries()]
|
|
1002
|
+
.sort((a, b) => b[1] - a[1])
|
|
1003
|
+
.slice(0, 5);
|
|
1004
|
+
|
|
1005
|
+
if (topProjects.length > 0) {
|
|
1006
|
+
console.log(` ${bold("Top Projects")}`);
|
|
1007
|
+
for (const [folder, count] of topProjects) {
|
|
1008
|
+
console.log(` ${dim("•")} ${basename(folder).padEnd(30)} ${dim(`${count} session${count === 1 ? "" : "s"}`)}`);
|
|
1009
|
+
}
|
|
1010
|
+
console.log("");
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Footer
|
|
1015
|
+
console.log(dim(" For the full dashboard with cost analytics and web UI:"));
|
|
1016
|
+
console.log(cyan(" npx agentlytics"));
|
|
1017
|
+
console.log("");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"ui/package-lock.json",
|
|
24
24
|
"ui/vite.config.js",
|
|
25
25
|
"ui/eslint.config.js",
|
|
26
|
-
"README.md"
|
|
26
|
+
"README.md",
|
|
27
|
+
"mod.ts",
|
|
28
|
+
"deno.json"
|
|
27
29
|
],
|
|
28
30
|
"scripts": {
|
|
29
31
|
"start": "node index.js",
|
|
@@ -46,6 +48,9 @@
|
|
|
46
48
|
],
|
|
47
49
|
"author": "fkadev",
|
|
48
50
|
"license": "ISC",
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20.19.0"
|
|
53
|
+
},
|
|
49
54
|
"repository": {
|
|
50
55
|
"type": "git",
|
|
51
56
|
"url": "https://github.com/f/agentlytics"
|
package/relay-server.js
CHANGED
|
@@ -542,7 +542,7 @@ function createRelayApp() {
|
|
|
542
542
|
if (fs.existsSync(index)) {
|
|
543
543
|
res.sendFile(index);
|
|
544
544
|
} else {
|
|
545
|
-
res.status(404).send('UI not built. Run: cd ui && npm install && npm run build');
|
|
545
|
+
res.status(404).send('UI not built. Run: cd ui && npm install && npm run build (or use your preferred package manager)');
|
|
546
546
|
}
|
|
547
547
|
});
|
|
548
548
|
|
package/ui/package.json
CHANGED
package/ui/src/App.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
|
|
3
3
|
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
|
+
import { useLive } from './hooks/useLive'
|
|
6
7
|
import AnimatedLogo from './components/AnimatedLogo'
|
|
7
8
|
import AnimatedLoader from './components/AnimatedLoader'
|
|
8
9
|
import LoginScreen from './components/LoginScreen'
|
|
@@ -70,7 +71,7 @@ function NavDropdown({ icon: Icon, label, items }) {
|
|
|
70
71
|
export default function App() {
|
|
71
72
|
const [overview, setOverview] = useState(null)
|
|
72
73
|
const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
|
|
73
|
-
const
|
|
74
|
+
const { live, toggle: toggleLive } = useLive()
|
|
74
75
|
const [mode, setMode] = useState(null) // 'local' | 'relay'
|
|
75
76
|
const [needsAuth, setNeedsAuth] = useState(false)
|
|
76
77
|
const [authed, setAuthed] = useState(!!getAuthToken())
|
|
@@ -105,25 +106,29 @@ export default function App() {
|
|
|
105
106
|
if (mode === 'local') refreshOverview()
|
|
106
107
|
}, [mode])
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
const rescanAndRefresh = useCallback(async (onProgress) => {
|
|
110
|
+
await refetchAgents(onProgress)
|
|
111
|
+
const data = await fetchOverview()
|
|
112
|
+
setOverview(data)
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
// Live mode: rescan & refresh every 60s
|
|
109
116
|
useEffect(() => {
|
|
110
117
|
if (live && mode === 'local') {
|
|
111
118
|
liveRef.current = setInterval(() => {
|
|
112
|
-
|
|
119
|
+
rescanAndRefresh().catch(() => {})
|
|
113
120
|
}, 60000)
|
|
114
121
|
} else {
|
|
115
122
|
if (liveRef.current) clearInterval(liveRef.current)
|
|
116
123
|
liveRef.current = null
|
|
117
124
|
}
|
|
118
125
|
return () => { if (liveRef.current) clearInterval(liveRef.current) }
|
|
119
|
-
}, [live,
|
|
126
|
+
}, [live, rescanAndRefresh])
|
|
120
127
|
|
|
121
128
|
const handleRefetch = async () => {
|
|
122
129
|
setRefetchState({ scanned: 0, total: 0 })
|
|
123
130
|
try {
|
|
124
|
-
await
|
|
125
|
-
const data = await fetchOverview()
|
|
126
|
-
setOverview(data)
|
|
131
|
+
await rescanAndRefresh((p) => setRefetchState({ scanned: p.scanned, total: p.total }))
|
|
127
132
|
} catch (e) { console.error(e) }
|
|
128
133
|
setRefetchState(null)
|
|
129
134
|
}
|
|
@@ -187,7 +192,7 @@ export default function App() {
|
|
|
187
192
|
{!isRelay && (
|
|
188
193
|
<>
|
|
189
194
|
<button
|
|
190
|
-
onClick={
|
|
195
|
+
onClick={toggleLive}
|
|
191
196
|
className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
|
|
192
197
|
style={{
|
|
193
198
|
color: live ? '#22c55e' : 'var(--c-text3)',
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
const KEY = 'agentlytics-live'
|
|
4
|
+
|
|
5
|
+
export function useLive() {
|
|
6
|
+
const [live, setLive] = useState(() => localStorage.getItem(KEY) === 'true')
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
localStorage.setItem(KEY, live)
|
|
10
|
+
}, [live])
|
|
11
|
+
|
|
12
|
+
const toggle = useCallback(() => setLive(l => !l), [])
|
|
13
|
+
|
|
14
|
+
return { live, toggle }
|
|
15
|
+
}
|