claude-live 2.0.8 → 3.0.1

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.
@@ -11,8 +11,8 @@
11
11
  body { background: #080808; overflow: hidden; }
12
12
  #root { width: 100vw; height: 100vh; }
13
13
  </style>
14
- <script type="module" crossorigin src="/assets/index-BGs_09Jl.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DjcKbX6b.css">
14
+ <script type="module" crossorigin src="/assets/index-zmiMo1Db.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-nnbWctP_.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-live",
3
- "version": "2.0.8",
3
+ "version": "3.0.1",
4
4
  "description": "Realtime Claude Code activity visualizer",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/marisancans/claude-live",
@@ -12,30 +12,36 @@
12
12
  "start": "node server/index.js",
13
13
  "dev": "cd client && vite dev",
14
14
  "build": "cd client && vite build",
15
- "test": "cd client && npx tsc --noEmit"
15
+ "test": "cd client && npx tsc --noEmit",
16
+ "test:server": "vitest run --config vitest.config.server.js"
16
17
  },
17
18
  "files": [
18
19
  "server/",
19
20
  "bin/",
20
21
  "client/dist/",
21
22
  ".claude-plugin/",
22
- "commands/",
23
- "hooks/"
23
+ "commands/"
24
24
  ],
25
25
  "engines": {
26
26
  "node": ">=18"
27
27
  },
28
28
  "devDependencies": {
29
- "howler": "^2.2.4",
30
29
  "@types/howler": "^2.2.12",
31
30
  "@types/react": "^18.2.0",
32
31
  "@types/react-dom": "^18.2.0",
32
+ "@types/three": "^0.183.1",
33
33
  "@vitejs/plugin-react": "^4.2.0",
34
- "pixi-filters": "^6.0.0",
35
- "pixi.js": "^8.0.0",
36
- "react": "^18.2.0",
34
+ "howler": "^2.2.4",
35
+ "react": "^18.2.0",
37
36
  "react-dom": "^18.2.0",
37
+ "three": "^0.183.2",
38
38
  "typescript": "^5.3.0",
39
39
  "vite": "^5.1.0"
40
+ },
41
+ "dependencies": {
42
+ "@dgreenheck/ez-tree": "^1.1.0",
43
+ "@types/three": "^0.183.1",
44
+ "d3-force-3d": "^3.0.6",
45
+ "three": "^0.183.2"
40
46
  }
41
47
  }
@@ -0,0 +1,68 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { TranscriptParser } from './transcript-parser.js';
4
+
5
+ function listSessionFiles(projectsDir) {
6
+ const files = [];
7
+
8
+ let entries;
9
+ try {
10
+ entries = readdirSync(projectsDir, { withFileTypes: true });
11
+ } catch {
12
+ return files;
13
+ }
14
+
15
+ for (const entry of entries) {
16
+ if (!entry.isDirectory()) continue;
17
+ const dirPath = join(projectsDir, entry.name);
18
+ let sessionFiles;
19
+ try {
20
+ sessionFiles = readdirSync(dirPath).filter(name => name.endsWith('.jsonl'));
21
+ } catch {
22
+ continue;
23
+ }
24
+
25
+ for (const file of sessionFiles) {
26
+ const filePath = join(dirPath, file);
27
+ try {
28
+ files.push({ filePath, mtime: statSync(filePath).mtimeMs });
29
+ } catch {
30
+ // ignore unreadable files
31
+ }
32
+ }
33
+ }
34
+
35
+ return files.sort((a, b) => a.mtime - b.mtime);
36
+ }
37
+
38
+ export function readProjectHistoryFromDisk(projectsDir, projectId, options = {}) {
39
+ const normalizedProjectId = resolve(projectId);
40
+ const maxSessions = options.maxSessions ?? 40;
41
+ const maxEvents = options.maxEvents ?? 4000;
42
+ const files = listSessionFiles(projectsDir);
43
+ const selectedFiles = files.slice(-maxSessions);
44
+ const events = [];
45
+
46
+ for (const { filePath } of selectedFiles) {
47
+ const parser = new TranscriptParser(event => {
48
+ if (typeof event.cwd !== 'string') return;
49
+ if (resolve(event.cwd) !== normalizedProjectId) return;
50
+ events.push(event);
51
+ if (events.length > maxEvents) events.splice(0, events.length - maxEvents);
52
+ });
53
+
54
+ let text;
55
+ try {
56
+ text = readFileSync(filePath, 'utf8');
57
+ } catch {
58
+ continue;
59
+ }
60
+
61
+ for (const line of text.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ parser.processLine(line);
64
+ }
65
+ }
66
+
67
+ return events.sort((a, b) => a.timestamp - b.timestamp);
68
+ }
package/server/index.js CHANGED
@@ -3,6 +3,10 @@ import { readFileSync, existsSync, statSync } from 'fs'
3
3
  import { join, extname, resolve, sep } from 'path'
4
4
  import { fileURLToPath } from 'url'
5
5
  import { dirname } from 'path'
6
+ import { homedir } from 'os'
7
+ import { SessionScanner } from './session-scanner.js'
8
+ import { buildProjectTree, listActiveProjects } from './project-tree.js'
9
+ import { readProjectHistoryFromDisk } from './history-reader.js'
6
10
 
7
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
8
12
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version
@@ -26,22 +30,55 @@ const MIME = {
26
30
  }
27
31
 
28
32
  const clients = new Set()
33
+ const eventHistory = [] // all events seen since server start
34
+ const MAX_HISTORY = 5000
35
+ const CORS_HEADERS = {
36
+ 'Access-Control-Allow-Origin': '*',
37
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
38
+ 'Access-Control-Allow-Headers': 'Content-Type',
39
+ }
40
+
41
+ function filterHistory(events, { sessionId, projectId }) {
42
+ return events.filter(event => {
43
+ if (sessionId && event.session_id !== sessionId) return false
44
+ if (projectId && resolve(event.cwd || '') !== projectId) return false
45
+ return true
46
+ })
47
+ }
29
48
 
30
49
  function broadcast(data) {
31
50
  const msg = `data: ${JSON.stringify(data)}\n\n`
32
51
  for (const res of clients) {
33
52
  try { res.write(msg) } catch { clients.delete(res) }
34
53
  }
54
+ // Buffer for history API
55
+ if (data.type === 'event') {
56
+ eventHistory.push(data.data)
57
+ if (eventHistory.length > MAX_HISTORY) eventHistory.splice(0, eventHistory.length - MAX_HISTORY)
58
+ }
35
59
  }
36
60
 
37
61
  const server = createServer((req, res) => {
38
- // POST /hook — receive event, broadcast to SSE clients
39
- if (req.method === 'POST' && req.url === '/hook') {
62
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
63
+ res.setHeader(key, value)
64
+ }
65
+ if (req.method === 'OPTIONS') {
66
+ res.writeHead(204)
67
+ res.end()
68
+ return
69
+ }
70
+
71
+ const requestUrl = new URL(req.url || '/', 'http://localhost')
72
+ const pathname = requestUrl.pathname
73
+
74
+ // POST /hook — used by debug panel to inject test events
75
+ if (req.method === 'POST' && pathname === '/hook') {
40
76
  let body = ''
41
77
  req.on('data', c => body += c)
42
78
  req.on('end', () => {
43
79
  try {
44
80
  const event = JSON.parse(body)
81
+ if (!event.id) event.id = `hook-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
45
82
  broadcast({ type: 'event', data: event })
46
83
  res.writeHead(200, { 'Content-Type': 'application/json' })
47
84
  res.end('{"ok":true}')
@@ -54,14 +91,70 @@ const server = createServer((req, res) => {
54
91
  }
55
92
 
56
93
  // GET /health — health check
57
- if (req.method === 'GET' && req.url === '/health') {
94
+ if (req.method === 'GET' && pathname === '/health') {
58
95
  res.writeHead(200, { 'Content-Type': 'application/json' })
59
96
  res.end(JSON.stringify({ ok: true, version: VERSION, clients: clients.size, port: PORT }))
60
97
  return
61
98
  }
62
99
 
100
+ if (req.method === 'GET' && pathname === '/api/projects') {
101
+ res.writeHead(200, { 'Content-Type': 'application/json' })
102
+ res.end(JSON.stringify({ projects: listActiveProjects(eventHistory) }))
103
+ return
104
+ }
105
+
106
+ if (req.method === 'GET' && pathname === '/api/project-tree') {
107
+ const projects = listActiveProjects(eventHistory)
108
+ const projectId = requestUrl.searchParams.get('project')
109
+ if (!projectId) {
110
+ res.writeHead(400, { 'Content-Type': 'application/json' })
111
+ res.end(JSON.stringify({ error: 'missing project query parameter' }))
112
+ return
113
+ }
114
+
115
+ const normalizedId = resolve(projectId)
116
+ const project = projects.find(item => resolve(item.root) === normalizedId)
117
+ if (!project) {
118
+ res.writeHead(404, { 'Content-Type': 'application/json' })
119
+ res.end(JSON.stringify({ error: 'project not active or unavailable' }))
120
+ return
121
+ }
122
+
123
+ try {
124
+ const tree = buildProjectTree(project.root)
125
+ res.writeHead(200, { 'Content-Type': 'application/json' })
126
+ res.end(JSON.stringify(tree))
127
+ } catch (error) {
128
+ res.writeHead(500, { 'Content-Type': 'application/json' })
129
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'failed to build tree' }))
130
+ }
131
+ return
132
+ }
133
+
134
+ // GET /api/history?session=ID — events for a specific session since last compact
135
+ if (req.method === 'GET' && pathname === '/api/history') {
136
+ const sessionFilter = requestUrl.searchParams.get('session')
137
+ const projectFilter = requestUrl.searchParams.get('project')
138
+ const persisted = requestUrl.searchParams.get('persisted') === '1'
139
+ const normalizedProject = projectFilter ? resolve(projectFilter) : null
140
+ let events = persisted && normalizedProject
141
+ ? readProjectHistoryFromDisk(PROJECTS_DIR, normalizedProject)
142
+ : eventHistory
143
+
144
+ events = filterHistory(events, { sessionId: sessionFilter, projectId: normalizedProject })
145
+ // Only return events since the last PostCompact — history before that is irrelevant
146
+ let lastCompact = -1
147
+ for (let i = events.length - 1; i >= 0; i--) {
148
+ if (events[i].hook_event_name === 'PostCompact') { lastCompact = i; break }
149
+ }
150
+ if (lastCompact >= 0) events = events.slice(lastCompact + 1)
151
+ res.writeHead(200, { 'Content-Type': 'application/json' })
152
+ res.end(JSON.stringify(events))
153
+ return
154
+ }
155
+
63
156
  // GET /events — SSE stream
64
- if (req.method === 'GET' && req.url === '/events') {
157
+ if (req.method === 'GET' && pathname === '/events') {
65
158
  res.writeHead(200, {
66
159
  'Content-Type': 'text/event-stream',
67
160
  'Cache-Control': 'no-cache',
@@ -77,7 +170,7 @@ const server = createServer((req, res) => {
77
170
  }
78
171
 
79
172
  // Static files
80
- const urlPath = new URL(req.url, 'http://localhost').pathname
173
+ const urlPath = pathname
81
174
  let filePath = join(DIST, urlPath === '/' ? 'index.html' : urlPath)
82
175
  if (!resolve(filePath).startsWith(resolve(DIST) + sep) && resolve(filePath) !== resolve(DIST)) {
83
176
  filePath = join(DIST, 'index.html')
@@ -96,6 +189,21 @@ const server = createServer((req, res) => {
96
189
  res.end(readFileSync(filePath))
97
190
  })
98
191
 
99
- server.listen(PORT, () => {
100
- console.log(`claude-live running at http://localhost:${PORT}`)
192
+ const PROJECTS_DIR = process.env.CLAUDE_PROJECTS_DIR
193
+ || join(homedir(), '.claude', 'projects')
194
+
195
+ const scanner = new SessionScanner(PROJECTS_DIR, event => {
196
+ broadcast({ type: 'event', data: event })
101
197
  })
198
+
199
+ // Only auto-start when run directly (not imported for testing)
200
+ const isMainModule = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
201
+ if (isMainModule) {
202
+ server.listen(PORT, () => {
203
+ console.log(`claude-live running at http://localhost:${PORT}`)
204
+ scanner.start()
205
+ console.log(`watching ${PROJECTS_DIR} for sessions`)
206
+ })
207
+ }
208
+
209
+ export { server, scanner, broadcast }
@@ -0,0 +1,178 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { basename, join, resolve, sep } from 'node:path';
3
+
4
+ const DEFAULT_IGNORES = new Set([
5
+ '.git',
6
+ '.cache',
7
+ '.next',
8
+ '.nuxt',
9
+ '.output',
10
+ '.parcel-cache',
11
+ '.pnpm-store',
12
+ '.superpowers',
13
+ '.svelte-kit',
14
+ '.turbo',
15
+ '.worktrees',
16
+ '.yarn',
17
+ 'build',
18
+ 'coverage',
19
+ 'dist',
20
+ 'node_modules',
21
+ 'out',
22
+ 'target',
23
+ 'tmp',
24
+ 'temp',
25
+ ]);
26
+
27
+ const DEFAULT_LIMITS = {
28
+ maxDepth: 7,
29
+ maxChildren: 48,
30
+ maxNodes: 1200,
31
+ };
32
+
33
+ function toPosixPath(value) {
34
+ return value.split(sep).join('/');
35
+ }
36
+
37
+ function makeProjectLabel(rootPath) {
38
+ return basename(rootPath) || rootPath;
39
+ }
40
+
41
+ function shouldIgnore(entry) {
42
+ if (DEFAULT_IGNORES.has(entry.name)) return true;
43
+ if (entry.isSymbolicLink()) return true;
44
+ return false;
45
+ }
46
+
47
+ function compareEntries(a, b) {
48
+ if (a.isDirectory() && !b.isDirectory()) return -1;
49
+ if (!a.isDirectory() && b.isDirectory()) return 1;
50
+ return a.name.localeCompare(b.name);
51
+ }
52
+
53
+ export function listActiveProjects(events) {
54
+ const projects = new Map();
55
+
56
+ for (const event of events) {
57
+ if (!event || typeof event.cwd !== 'string' || !event.cwd) continue;
58
+ const projectId = resolve(event.cwd);
59
+ let project = projects.get(projectId);
60
+ if (!project) {
61
+ project = {
62
+ id: projectId,
63
+ root: projectId,
64
+ label: makeProjectLabel(projectId),
65
+ eventCount: 0,
66
+ lastEventTime: 0,
67
+ sessionIds: new Set(),
68
+ };
69
+ projects.set(projectId, project);
70
+ }
71
+
72
+ project.eventCount += 1;
73
+ project.lastEventTime = Math.max(project.lastEventTime, Number(event.timestamp) || 0);
74
+ if (event.session_id) project.sessionIds.add(event.session_id);
75
+ }
76
+
77
+ return [...projects.values()]
78
+ .map(project => ({
79
+ id: project.id,
80
+ root: project.root,
81
+ label: project.label,
82
+ eventCount: project.eventCount,
83
+ lastEventTime: project.lastEventTime,
84
+ sessionCount: project.sessionIds.size,
85
+ }))
86
+ .sort((a, b) => b.lastEventTime - a.lastEventTime || a.label.localeCompare(b.label));
87
+ }
88
+
89
+ export function buildProjectTree(rootPath, options = {}) {
90
+ const root = resolve(rootPath);
91
+ const limits = { ...DEFAULT_LIMITS, ...options };
92
+ const stats = {
93
+ directories: 0,
94
+ files: 0,
95
+ totalNodes: 0,
96
+ maxDepthReached: 0,
97
+ truncated: false,
98
+ };
99
+
100
+ function visit(absPath, relPath, depth) {
101
+ if (stats.totalNodes >= limits.maxNodes) {
102
+ stats.truncated = true;
103
+ return null;
104
+ }
105
+
106
+ let st;
107
+ try {
108
+ st = statSync(absPath);
109
+ } catch {
110
+ return null;
111
+ }
112
+
113
+ const pathId = relPath || '.';
114
+ const name = relPath ? basename(absPath) : makeProjectLabel(root);
115
+ stats.totalNodes += 1;
116
+ stats.maxDepthReached = Math.max(stats.maxDepthReached, depth);
117
+
118
+ if (!st.isDirectory()) {
119
+ stats.files += 1;
120
+ return {
121
+ id: pathId,
122
+ name,
123
+ path: pathId,
124
+ type: 'file',
125
+ depth,
126
+ };
127
+ }
128
+
129
+ stats.directories += 1;
130
+ const node = {
131
+ id: pathId,
132
+ name,
133
+ path: pathId,
134
+ type: 'folder',
135
+ depth,
136
+ children: [],
137
+ };
138
+
139
+ if (depth >= limits.maxDepth) {
140
+ try {
141
+ if (readdirSync(absPath).length > 0) stats.truncated = true;
142
+ } catch {
143
+ // ignore unreadable directories
144
+ }
145
+ return node;
146
+ }
147
+
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(absPath, { withFileTypes: true })
151
+ .filter(entry => !shouldIgnore(entry))
152
+ .sort(compareEntries);
153
+ } catch {
154
+ return node;
155
+ }
156
+
157
+ if (entries.length > limits.maxChildren) {
158
+ entries = entries.slice(0, limits.maxChildren);
159
+ stats.truncated = true;
160
+ }
161
+
162
+ for (const entry of entries) {
163
+ const childRelPath = relPath ? toPosixPath(join(relPath, entry.name)) : entry.name;
164
+ const childNode = visit(join(absPath, entry.name), childRelPath, depth + 1);
165
+ if (childNode) node.children.push(childNode);
166
+ }
167
+
168
+ return node;
169
+ }
170
+
171
+ return {
172
+ projectId: root,
173
+ rootPath: root,
174
+ label: makeProjectLabel(root),
175
+ tree: visit(root, '', 0),
176
+ stats,
177
+ };
178
+ }
@@ -0,0 +1,150 @@
1
+ import { readdirSync, statSync, openSync, readSync, closeSync, watch } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { TranscriptParser } from './transcript-parser.js';
4
+
5
+ const MAX_SESSIONS = 50;
6
+ const SCAN_INTERVAL = 5000;
7
+ const POLL_INTERVAL = 3000;
8
+ const ACTIVE_AGE_MS = 10 * 60 * 1000; // Only watch sessions modified in last 10 minutes
9
+
10
+ export class SessionScanner {
11
+ constructor(projectsDir, onEvent) {
12
+ this.projectsDir = projectsDir;
13
+ this.onEvent = onEvent;
14
+ this.sessions = new Map();
15
+ this._scanTimer = null;
16
+ this._pollTimer = null;
17
+ this._dirWatcher = null;
18
+ }
19
+
20
+ start() {
21
+ this.scan();
22
+ this._scanTimer = setInterval(() => this.scan(), SCAN_INTERVAL);
23
+ this._pollTimer = setInterval(() => this.pollAll(), POLL_INTERVAL);
24
+ try {
25
+ this._dirWatcher = watch(this.projectsDir, { recursive: false }, () => {
26
+ this.scan();
27
+ });
28
+ } catch {
29
+ // fs.watch may not be supported
30
+ }
31
+ }
32
+
33
+ stop() {
34
+ if (this._scanTimer) { clearInterval(this._scanTimer); this._scanTimer = null; }
35
+ if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
36
+ if (this._dirWatcher) { this._dirWatcher.close(); this._dirWatcher = null; }
37
+ for (const session of this.sessions.values()) {
38
+ if (session.watcher) { session.watcher.close(); }
39
+ }
40
+ this.sessions.clear();
41
+ }
42
+
43
+ scan() {
44
+ let subdirs;
45
+ try {
46
+ subdirs = readdirSync(this.projectsDir, { withFileTypes: true })
47
+ .filter(d => d.isDirectory())
48
+ .map(d => d.name);
49
+ } catch {
50
+ return;
51
+ }
52
+
53
+ // Prune stale sessions (no longer recently modified)
54
+ const now = Date.now();
55
+ for (const [path, session] of this.sessions) {
56
+ try {
57
+ const mtime = statSync(path).mtimeMs;
58
+ if (now - mtime >= ACTIVE_AGE_MS) {
59
+ if (session.watcher) session.watcher.close();
60
+ this.sessions.delete(path);
61
+ }
62
+ } catch {
63
+ if (session.watcher) session.watcher.close();
64
+ this.sessions.delete(path);
65
+ }
66
+ }
67
+
68
+ // Collect all JSONL files with their mtimes
69
+ const candidates = [];
70
+ for (const subdir of subdirs) {
71
+ const dirPath = join(this.projectsDir, subdir);
72
+ let files;
73
+ try {
74
+ files = readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const file of files) {
79
+ const filePath = join(dirPath, file);
80
+ if (this.sessions.has(filePath)) continue;
81
+ try {
82
+ const mtime = statSync(filePath).mtimeMs;
83
+ // Only consider recently active sessions
84
+ if (now - mtime < ACTIVE_AGE_MS) {
85
+ candidates.push({ filePath, mtime });
86
+ }
87
+ } catch { continue; }
88
+ }
89
+ }
90
+
91
+ // Sort by most recent first so active sessions get priority
92
+ candidates.sort((a, b) => b.mtime - a.mtime);
93
+
94
+ for (const { filePath } of candidates) {
95
+ if (this.sessions.size >= MAX_SESSIONS) break;
96
+
97
+ const parser = new TranscriptParser(this.onEvent);
98
+ let watcher = null;
99
+ try {
100
+ watcher = watch(filePath, () => this._readNewLines(this.sessions.get(filePath)));
101
+ } catch { /* ignore */ }
102
+
103
+ const session = { filePath, offset: 0, parser, watcher };
104
+ this.sessions.set(filePath, session);
105
+ this._readNewLines(session);
106
+ }
107
+ }
108
+
109
+ pollAll() {
110
+ for (const session of this.sessions.values()) {
111
+ this._readNewLines(session);
112
+ }
113
+ }
114
+
115
+ _readNewLines(session) {
116
+ if (!session) return;
117
+ let size;
118
+ try {
119
+ size = statSync(session.filePath).size;
120
+ } catch {
121
+ return;
122
+ }
123
+
124
+ if (size === session.offset) return;
125
+
126
+ if (size < session.offset) {
127
+ session.offset = 0;
128
+ session.parser = new TranscriptParser(this.onEvent);
129
+ }
130
+
131
+ const bytesToRead = size - session.offset;
132
+ const buf = Buffer.alloc(bytesToRead);
133
+ let fd;
134
+ try {
135
+ fd = openSync(session.filePath, 'r');
136
+ readSync(fd, buf, 0, bytesToRead, session.offset);
137
+ } finally {
138
+ if (fd !== undefined) closeSync(fd);
139
+ }
140
+
141
+ const text = buf.toString('utf8');
142
+ const lines = text.split('\n');
143
+ for (const line of lines) {
144
+ if (line.trim()) {
145
+ session.parser.processLine(line);
146
+ }
147
+ }
148
+ session.offset = size;
149
+ }
150
+ }