claude-session-viewer 0.1.0 → 0.1.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.
@@ -1,440 +0,0 @@
1
- import Fastify from 'fastify'
2
- import cors from '@fastify/cors'
3
- import websocket from '@fastify/websocket'
4
- import { homedir } from 'os'
5
- import { join } from 'path'
6
- import { readdir, readFile, stat } from 'fs/promises'
7
- import chokidar from 'chokidar'
8
- import getPort from 'get-port'
9
-
10
- const CLAUDE_DIR = join(homedir(), '.claude')
11
- const DEFAULT_PORT = 3000
12
-
13
- const server = Fastify({
14
- logger: true
15
- })
16
-
17
- // Plugins
18
- await server.register(cors, {
19
- origin: 'http://localhost:5173'
20
- })
21
- await server.register(websocket)
22
-
23
- // Types
24
- interface Session {
25
- id: string
26
- project: string
27
- timestamp: string
28
- messages: any[]
29
- messageCount: number
30
- title?: string
31
- isAgent?: boolean
32
- agentSessions?: Session[]
33
- }
34
-
35
- interface ProjectGroup {
36
- name: string
37
- displayName: string
38
- sessionCount: number
39
- lastActivity: string
40
- sessions: Session[]
41
- }
42
-
43
- // Helper: Parse JSONL file
44
- async function parseJsonl(filePath: string): Promise<any[]> {
45
- const content = await readFile(filePath, 'utf-8')
46
- return content
47
- .split('\n')
48
- .filter(line => line.trim())
49
- .map(line => JSON.parse(line))
50
- }
51
-
52
- // Helper: Clean text by removing tags
53
- function cleanText(text: string): string {
54
- return text
55
- .replace(/<ide_selection>[\s\S]*?<\/ide_selection>/g, ' ')
56
- .replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, ' ')
57
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
58
- .replace(/\s+/g, ' ')
59
- .trim()
60
- }
61
-
62
- function extractFirstText(content: any): string | null {
63
- if (Array.isArray(content)) {
64
- for (const item of content) {
65
- if (item.type === 'text' && item.text) {
66
- const cleaned = cleanText(item.text)
67
- if (cleaned) {
68
- return cleaned
69
- }
70
- }
71
- }
72
- return null
73
- }
74
-
75
- if (typeof content === 'string') {
76
- const cleaned = cleanText(content)
77
- return cleaned || null
78
- }
79
-
80
- return null
81
- }
82
-
83
- // Helper: Extract title from session messages
84
- function extractSessionTitle(messages: any[]): string {
85
- // First, try to find queue-operation / enqueue message
86
- for (const msg of messages) {
87
- if (msg.type === 'queue-operation' && msg.operation === 'enqueue' && msg.content) {
88
- const firstText = extractFirstText(msg.content)
89
- if (firstText) {
90
- return firstText.substring(0, 100).trim()
91
- }
92
- }
93
- }
94
-
95
- // Fallback: Find first user message with actual text content
96
- for (const msg of messages) {
97
- if (msg.type === 'user' && msg.message?.content) {
98
- const firstText = extractFirstText(msg.message.content)
99
- if (firstText) {
100
- return firstText.substring(0, 100).trim()
101
- }
102
- }
103
- }
104
-
105
- return 'Untitled Session'
106
- }
107
-
108
- function getProjectNameFromPath(projectPath: string): string {
109
- return projectPath.split('/').pop()?.replace(/-Users-hanyeol-Projects-/, '') || 'unknown'
110
- }
111
-
112
- function getProjectDisplayName(projectName: string): string {
113
- return projectName.replace(/-Users-hanyeol-Projects-/, '')
114
- }
115
-
116
- function collectAgentDescriptions(messages: any[]): Map<string, string> {
117
- const agentDescriptions = new Map<string, string>()
118
- const toolUseDescriptions = new Map<string, string>()
119
- const toolResultAgentIds = new Map<string, string>()
120
-
121
- for (const msg of messages) {
122
- if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
123
- for (const item of msg.message.content) {
124
- if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
125
- toolUseDescriptions.set(item.id, item.input.description)
126
- }
127
- }
128
- }
129
-
130
- const agentId = msg.agentId || msg.toolUseResult?.agentId
131
- if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
132
- for (const item of msg.message.content) {
133
- if (item.type === 'tool_result' && item.tool_use_id) {
134
- toolResultAgentIds.set(item.tool_use_id, agentId)
135
- }
136
- }
137
- }
138
- }
139
-
140
- for (const [toolUseId, description] of toolUseDescriptions.entries()) {
141
- const agentId = toolResultAgentIds.get(toolUseId)
142
- if (agentId) {
143
- agentDescriptions.set(`agent-${agentId}`, description)
144
- }
145
- }
146
-
147
- return agentDescriptions
148
- }
149
-
150
- function attachAgentSessionsFromMap(
151
- session: Session,
152
- agentDescriptions: Map<string, string>,
153
- agentSessionsMap: Map<string, Session>
154
- ): void {
155
- if (agentDescriptions.size === 0) return
156
-
157
- session.agentSessions = []
158
- for (const [agentSessionId, description] of agentDescriptions) {
159
- const agentSession = agentSessionsMap.get(agentSessionId)
160
- if (agentSession) {
161
- agentSession.title = description
162
- session.agentSessions.push(agentSession)
163
- }
164
- }
165
- session.agentSessions.sort((a, b) =>
166
- new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
167
- )
168
- }
169
-
170
- async function loadAgentSessionsFromFiles(
171
- projectPath: string,
172
- projectName: string,
173
- agentDescriptions: Map<string, string>
174
- ): Promise<Session[]> {
175
- const agentSessions: Session[] = []
176
-
177
- for (const [agentSessionId, description] of agentDescriptions) {
178
- const agentFile = join(projectPath, `${agentSessionId}.jsonl`)
179
- try {
180
- const agentMessages = await parseJsonl(agentFile)
181
- const agentFileStat = await stat(agentFile)
182
- agentSessions.push({
183
- id: agentSessionId,
184
- project: projectName,
185
- timestamp: agentFileStat.mtime.toISOString(),
186
- messages: agentMessages,
187
- messageCount: agentMessages.length,
188
- title: description,
189
- isAgent: true
190
- })
191
- } catch {
192
- // Skip if agent file not found
193
- }
194
- }
195
-
196
- agentSessions.sort((a, b) =>
197
- new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
198
- )
199
- return agentSessions
200
- }
201
-
202
- function findAgentTitleFromParentMessages(messages: any[], agentId: string): string | null {
203
- const agentDescriptions = collectAgentDescriptions(messages)
204
- const description = agentDescriptions.get(`agent-${agentId}`)
205
- return description || null
206
- }
207
-
208
- // Helper: Get all sessions from a project directory
209
- async function getProjectSessions(projectPath: string): Promise<Session[]> {
210
- const files = await readdir(projectPath)
211
- const allSessions: Session[] = []
212
- const agentSessionsMap = new Map<string, Session>()
213
-
214
- // First pass: collect all sessions
215
- for (const file of files) {
216
- if (file.endsWith('.jsonl')) {
217
- const filePath = join(projectPath, file)
218
- const fileStat = await stat(filePath)
219
-
220
- // Skip empty files
221
- if (fileStat.size === 0) continue
222
-
223
- try {
224
- const messages = await parseJsonl(filePath)
225
-
226
- // Filter: Skip sessions with only 1 message that is assistant-only
227
- if (messages.length === 1 && messages[0].type === 'assistant') {
228
- continue
229
- }
230
-
231
- // Extract project name from path
232
- const projectName = getProjectNameFromPath(projectPath)
233
-
234
- // Extract session title
235
- const title = extractSessionTitle(messages)
236
-
237
- const sessionId = file.replace('.jsonl', '')
238
- const isAgent = sessionId.startsWith('agent-')
239
-
240
- const session: Session = {
241
- id: sessionId,
242
- project: projectName,
243
- timestamp: fileStat.mtime.toISOString(),
244
- messages,
245
- messageCount: messages.length,
246
- title,
247
- isAgent
248
- }
249
-
250
- if (isAgent) {
251
- agentSessionsMap.set(sessionId, session)
252
- } else {
253
- allSessions.push(session)
254
- }
255
- } catch (error) {
256
- console.error(`Error parsing ${file}:`, error)
257
- }
258
- }
259
- }
260
-
261
- // Second pass: attach agent sessions to their parent sessions
262
- for (const session of allSessions) {
263
- const agentDescriptions = collectAgentDescriptions(session.messages)
264
- attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap)
265
- }
266
-
267
- return allSessions
268
- }
269
-
270
- // API: Get all sessions grouped by project
271
- server.get('/api/sessions', async (request, reply) => {
272
- try {
273
- const projectsDir = join(CLAUDE_DIR, 'projects')
274
- const projects = await readdir(projectsDir)
275
-
276
- const projectGroups: ProjectGroup[] = []
277
-
278
- for (const project of projects) {
279
- const projectPath = join(projectsDir, project)
280
- const projectStat = await stat(projectPath)
281
-
282
- if (projectStat.isDirectory()) {
283
- const sessions = await getProjectSessions(projectPath)
284
-
285
- if (sessions.length > 0) {
286
- // Sort sessions by timestamp descending
287
- sessions.sort((a, b) =>
288
- new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
289
- )
290
-
291
- const displayName = getProjectDisplayName(project)
292
-
293
- projectGroups.push({
294
- name: project,
295
- displayName,
296
- sessionCount: sessions.length,
297
- lastActivity: sessions[0].timestamp, // Most recent session
298
- sessions
299
- })
300
- }
301
- }
302
- }
303
-
304
- // Sort project groups by last activity descending
305
- projectGroups.sort((a, b) =>
306
- new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
307
- )
308
-
309
- return { projects: projectGroups }
310
- } catch (error) {
311
- console.error('Error reading sessions:', error)
312
- return { projects: [] }
313
- }
314
- })
315
-
316
- // API: Get session by ID
317
- server.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
318
- try {
319
- const { id } = request.params
320
- const projectsDir = join(CLAUDE_DIR, 'projects')
321
- const projects = await readdir(projectsDir)
322
-
323
- const isAgent = id.startsWith('agent-')
324
-
325
- for (const project of projects) {
326
- const projectPath = join(projectsDir, project)
327
- const sessionFile = join(projectPath, `${id}.jsonl`)
328
-
329
- try {
330
- const messages = await parseJsonl(sessionFile)
331
- const fileStat = await stat(sessionFile)
332
- const projectName = getProjectDisplayName(project)
333
- let title = extractSessionTitle(messages)
334
-
335
- // For agent sessions, try to find the description from parent session
336
- if (isAgent) {
337
- const agentId = id.replace('agent-', '')
338
- const files = await readdir(projectPath)
339
- for (const file of files) {
340
- if (!file.startsWith('agent-') && file.endsWith('.jsonl')) {
341
- try {
342
- const parentMessages = await parseJsonl(join(projectPath, file))
343
- const description = findAgentTitleFromParentMessages(parentMessages, agentId)
344
- if (description) {
345
- title = description
346
- break
347
- }
348
- } catch {
349
- continue
350
- }
351
- }
352
- }
353
- }
354
-
355
- // If this is a main session (not agent), attach agent sessions
356
- let agentSessions: Session[] | undefined
357
- if (!isAgent) {
358
- const agentDescriptions = collectAgentDescriptions(messages)
359
- if (agentDescriptions.size > 0) {
360
- agentSessions = await loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions)
361
- }
362
- }
363
-
364
- return {
365
- session: {
366
- id,
367
- project: projectName,
368
- timestamp: fileStat.mtime.toISOString(),
369
- messages,
370
- messageCount: messages.length,
371
- title,
372
- isAgent,
373
- agentSessions
374
- }
375
- }
376
- } catch {
377
- continue
378
- }
379
- }
380
-
381
- return reply.code(404).send({ error: 'Session not found' })
382
- } catch (error) {
383
- console.error('Error reading session:', error)
384
- return reply.code(500).send({ error: 'Internal server error' })
385
- }
386
- })
387
-
388
- // WebSocket: Watch for file changes
389
- server.register(async function (fastify) {
390
- fastify.get('/ws', { websocket: true }, (socket) => {
391
- const projectsDir = join(CLAUDE_DIR, 'projects')
392
-
393
- const watcher = chokidar.watch(projectsDir, {
394
- ignoreInitial: true,
395
- persistent: true
396
- })
397
-
398
- watcher.on('add', (path) => {
399
- socket.send(JSON.stringify({ type: 'file-added', path }))
400
- })
401
-
402
- watcher.on('change', (path) => {
403
- socket.send(JSON.stringify({ type: 'file-changed', path }))
404
- })
405
-
406
- watcher.on('unlink', (path) => {
407
- socket.send(JSON.stringify({ type: 'file-deleted', path }))
408
- })
409
-
410
- socket.on('close', () => {
411
- watcher.close()
412
- })
413
-
414
- socket.on('error', (err: Error) => {
415
- console.error('WebSocket error:', err)
416
- })
417
- })
418
- })
419
-
420
- // Start server
421
- const start = async () => {
422
- try {
423
- const envPort = process.env.PORT ? Number(process.env.PORT) : undefined
424
- const port = Number.isFinite(envPort) ? envPort : await getPort({ port: DEFAULT_PORT })
425
-
426
- await server.listen({ port })
427
-
428
- if (port !== DEFAULT_PORT) {
429
- console.log(`Port ${DEFAULT_PORT} is in use, using port ${port} instead`)
430
- }
431
-
432
- console.log(`Server running on http://localhost:${port}`)
433
- console.log(`Watching Claude directory: ${CLAUDE_DIR}`)
434
- } catch (err) {
435
- server.log.error(err)
436
- process.exit(1)
437
- }
438
- }
439
-
440
- start()
@@ -1,11 +0,0 @@
1
- /** @type {import('tailwindcss').Config} */
2
- export default {
3
- content: [
4
- "./index.html",
5
- "./src/**/*.{js,ts,jsx,tsx}",
6
- ],
7
- theme: {
8
- extend: {},
9
- },
10
- plugins: [],
11
- }
package/tsconfig.json DELETED
@@ -1,31 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "useDefineForClassFields": true,
5
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
- "module": "ESNext",
7
- "skipLibCheck": true,
8
-
9
- /* Bundler mode */
10
- "moduleResolution": "bundler",
11
- "allowImportingTsExtensions": true,
12
- "resolveJsonModule": true,
13
- "isolatedModules": true,
14
- "noEmit": true,
15
- "jsx": "react-jsx",
16
-
17
- /* Linting */
18
- "strict": true,
19
- "noUnusedLocals": true,
20
- "noUnusedParameters": true,
21
- "noFallthroughCasesInSwitch": true,
22
-
23
- /* Path aliases */
24
- "baseUrl": ".",
25
- "paths": {
26
- "@/*": ["./src/*"]
27
- }
28
- },
29
- "include": ["src"],
30
- "references": [{ "path": "./tsconfig.node.json" }]
31
- }
@@ -1,10 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "composite": true,
4
- "skipLibCheck": true,
5
- "module": "ESNext",
6
- "moduleResolution": "bundler",
7
- "allowSyntheticDefaultImports": true
8
- },
9
- "include": ["vite.config.ts"]
10
- }
package/vite.config.ts DELETED
@@ -1,32 +0,0 @@
1
- import { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react'
3
- import path from 'path'
4
-
5
- const serverPort = Number(process.env.VITE_API_PORT) || 3000
6
-
7
- // https://vitejs.dev/config/
8
- export default defineConfig({
9
- plugins: [react()],
10
- resolve: {
11
- alias: {
12
- '@': path.resolve(__dirname, './src'),
13
- },
14
- },
15
- server: {
16
- port: 5173,
17
- proxy: {
18
- '/api': {
19
- target: `http://localhost:${serverPort}`,
20
- changeOrigin: true,
21
- },
22
- '/ws': {
23
- target: `http://localhost:${serverPort}`,
24
- ws: true,
25
- changeOrigin: true,
26
- },
27
- },
28
- },
29
- define: {
30
- 'import.meta.env.VITE_API_PORT': JSON.stringify(serverPort.toString()),
31
- },
32
- })