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.
- package/README.md +25 -41
- package/bin/claude-live +0 -0
- package/client/dist/assets/AgentDemoScene-CXlvncAb.js +147 -0
- package/client/dist/assets/ColonizationDemoScene-Cils_y13.js +1 -0
- package/client/dist/assets/EffectDemoScene-BXqCMwER.js +138 -0
- package/client/dist/assets/SakuraPage-BfD0y3D9.js +518 -0
- package/client/dist/assets/index-nnbWctP_.css +1 -0
- package/client/dist/assets/index-zmiMo1Db.js +4430 -0
- package/client/dist/index.html +2 -2
- package/package.json +14 -8
- package/server/history-reader.js +68 -0
- package/server/index.js +115 -7
- package/server/project-tree.js +178 -0
- package/server/session-scanner.js +150 -0
- package/server/transcript-parser.js +177 -0
- package/client/dist/assets/BufferResource-B3YcFk1L.js +0 -185
- package/client/dist/assets/CanvasRenderer-B7cP3KcG.js +0 -1
- package/client/dist/assets/Filter-BXkJkOCD.js +0 -1
- package/client/dist/assets/RenderTargetSystem-DkV5EZ2H.js +0 -172
- package/client/dist/assets/WebGLRenderer-Cgmusykq.js +0 -156
- package/client/dist/assets/WebGPURenderer-B_Gw9-ml.js +0 -41
- package/client/dist/assets/browserAll-wXmCMyRg.js +0 -14
- package/client/dist/assets/index-BGs_09Jl.js +0 -318
- package/client/dist/assets/index-DjcKbX6b.css +0 -1
- package/client/dist/assets/webworkerAll-Hyzs6HuJ.js +0 -83
- package/hooks/hooks.json +0 -161
- package/hooks/run-hook.cmd +0 -38
- package/hooks/send-event +0 -5
- package/hooks/session-start +0 -40
package/client/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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": "
|
|
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
|
-
"
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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' &&
|
|
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' &&
|
|
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 =
|
|
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
|
-
|
|
100
|
-
|
|
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
|
+
}
|