claude-task-viewer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # Claude Task Viewer
2
+
3
+ A web-based Kanban board for viewing Claude Code tasks. Watch your tasks update in real-time as Claude works.
4
+
5
+ ## Installation
6
+
7
+ ### Quick start (npx)
8
+
9
+ ```bash
10
+ npx claude-task-viewer
11
+ ```
12
+
13
+ Then open http://localhost:3456
14
+
15
+ ### From source
16
+
17
+ ```bash
18
+ git clone https://github.com/L1AD/claude-task-viewer.git
19
+ cd claude-task-viewer
20
+ npm install
21
+ npm start
22
+ ```
23
+
24
+ ## Features
25
+
26
+ - **Kanban board** — Tasks organised in Pending, In Progress, and Completed columns
27
+ - **Live updates** — See tasks change status in real-time via SSE
28
+ - **Session browser** — View all your Claude Code sessions
29
+ - **Session names** — Shows custom names (from `/rename`), or the auto-generated slug
30
+ - **All Tasks view** — Aggregate tasks across all sessions
31
+ - **Task details** — Click any task to see full description with markdown rendering
32
+ - **Progress tracking** — Visual progress bars and completion percentages
33
+ - **Dependency tracking** — See which tasks block others
34
+
35
+ ## How it works
36
+
37
+ Claude Code stores tasks in `~/.claude/tasks/`. Each session gets its own folder containing JSON files for each task.
38
+
39
+ ```
40
+ ~/.claude/tasks/
41
+ └── {session-uuid}/
42
+ ├── 1.json
43
+ ├── 2.json
44
+ └── ...
45
+ ```
46
+
47
+ The viewer watches this directory and updates the UI in real-time.
48
+
49
+ ## Task structure
50
+
51
+ ```json
52
+ {
53
+ "id": "1",
54
+ "subject": "Task title",
55
+ "description": "Detailed markdown description",
56
+ "activeForm": "Present tense status shown while in progress",
57
+ "status": "pending | in_progress | completed",
58
+ "blocks": ["task-ids-this-blocks"],
59
+ "blockedBy": ["task-ids-blocking-this"]
60
+ }
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ ### Custom port
66
+
67
+ ```bash
68
+ PORT=8080 npx claude-task-viewer
69
+ ```
70
+
71
+ ### Open browser automatically
72
+
73
+ ```bash
74
+ npx claude-task-viewer --open
75
+ ```
76
+
77
+ ## API
78
+
79
+ The viewer exposes a simple API:
80
+
81
+ | Endpoint | Description |
82
+ |----------|-------------|
83
+ | `GET /api/sessions` | List all sessions with task counts |
84
+ | `GET /api/sessions/:id` | Get all tasks for a session |
85
+ | `GET /api/tasks/all` | Get all tasks across all sessions |
86
+ | `GET /api/events` | SSE stream for live updates |
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "claude-task-viewer",
3
+ "version": "1.0.0",
4
+ "description": "A web-based Kanban board for viewing Claude Code tasks",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "claude-task-viewer": "./server.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.js",
11
+ "dev": "node server.js --open"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/L1AD/claude-task-viewer.git"
16
+ },
17
+ "keywords": [
18
+ "claude",
19
+ "claude-code",
20
+ "anthropic",
21
+ "tasks",
22
+ "todo",
23
+ "kanban",
24
+ "viewer"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "bugs": {
29
+ "url": "https://github.com/L1AD/claude-task-viewer/issues"
30
+ },
31
+ "homepage": "https://github.com/L1AD/claude-task-viewer#readme",
32
+ "dependencies": {
33
+ "chokidar": "^3.5.3",
34
+ "express": "^4.18.2",
35
+ "open": "^10.0.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "files": [
41
+ "server.js",
42
+ "public/**/*"
43
+ ]
44
+ }
@@ -0,0 +1,499 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Claude Task Viewer</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
+ <script>
10
+ tailwind.config = {
11
+ darkMode: 'class',
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ claude: {
16
+ orange: '#E86F33',
17
+ cream: '#F5F0E8'
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ </script>
24
+ <style>
25
+ .prose pre { background: #1f2937; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
26
+ .prose code { background: #374151; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875em; }
27
+ .prose pre code { background: transparent; padding: 0; }
28
+ .status-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
29
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
30
+ .kanban-column { min-height: calc(100vh - 200px); }
31
+ </style>
32
+ </head>
33
+ <body class="bg-gray-950 text-gray-100 min-h-screen">
34
+ <div class="flex h-screen">
35
+ <!-- Sidebar -->
36
+ <aside class="w-72 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0">
37
+ <header class="p-4 border-b border-gray-800">
38
+ <h1 class="text-lg font-semibold flex items-center gap-2">
39
+ <svg class="w-6 h-6 text-claude-orange" viewBox="0 0 24 24" fill="currentColor">
40
+ <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
41
+ </svg>
42
+ Claude Tasks
43
+ </h1>
44
+ <p class="text-xs text-gray-500 mt-1">~/.claude/tasks</p>
45
+ </header>
46
+
47
+ <div class="p-3 border-b border-gray-800">
48
+ <div id="connection-status" class="flex items-center gap-2 text-xs">
49
+ <span class="w-2 h-2 rounded-full bg-yellow-500"></span>
50
+ <span class="text-gray-400">Connecting...</span>
51
+ </div>
52
+ </div>
53
+
54
+ <!-- All Tasks button -->
55
+ <div class="p-2 border-b border-gray-800">
56
+ <button
57
+ id="all-tasks-btn"
58
+ onclick="showAllTasks()"
59
+ class="w-full text-left p-3 rounded-lg transition-colors hover:bg-gray-800/50 flex items-center gap-2"
60
+ >
61
+ <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
62
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
63
+ </svg>
64
+ <span class="text-sm text-gray-300">All Tasks</span>
65
+ </button>
66
+ </div>
67
+
68
+ <nav id="sessions-list" class="flex-1 overflow-y-auto p-2">
69
+ <p class="text-gray-500 text-sm p-2">Loading sessions...</p>
70
+ </nav>
71
+
72
+ <footer class="p-3 border-t border-gray-800 text-xs text-gray-600">
73
+ <a href="https://github.com" class="hover:text-gray-400">GitHub</a>
74
+ <span class="mx-2">·</span>
75
+ <span>v1.0.0</span>
76
+ </footer>
77
+ </aside>
78
+
79
+ <!-- Main content -->
80
+ <main class="flex-1 flex flex-col overflow-hidden">
81
+ <div id="no-session" class="flex-1 flex items-center justify-center text-gray-600">
82
+ <div class="text-center">
83
+ <svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
84
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
85
+ </svg>
86
+ <p>Select a session to view tasks</p>
87
+ </div>
88
+ </div>
89
+
90
+ <div id="session-view" class="flex-1 flex flex-col overflow-hidden hidden">
91
+ <!-- Session header -->
92
+ <header class="p-4 border-b border-gray-800 bg-gray-900/50 flex-shrink-0">
93
+ <div class="flex items-center justify-between">
94
+ <div>
95
+ <h2 id="session-title" class="text-sm font-mono text-gray-400">Session</h2>
96
+ <p id="session-meta" class="text-xs text-gray-500 mt-0.5"></p>
97
+ </div>
98
+ <div class="flex items-center gap-4">
99
+ <div class="flex items-center gap-2">
100
+ <div class="w-32 h-2 bg-gray-800 rounded-full overflow-hidden">
101
+ <div id="progress-bar" class="h-full bg-gradient-to-r from-claude-orange to-orange-400 transition-all duration-500" style="width: 0%"></div>
102
+ </div>
103
+ <span id="progress-percent" class="text-sm font-medium text-claude-orange">0%</span>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </header>
108
+
109
+ <!-- Kanban board -->
110
+ <div class="flex-1 overflow-x-auto p-4">
111
+ <div class="flex gap-4 h-full min-w-max">
112
+ <!-- Pending column -->
113
+ <div class="w-80 flex flex-col">
114
+ <div class="flex items-center gap-2 mb-3 px-1">
115
+ <span class="w-3 h-3 rounded-full bg-gray-500"></span>
116
+ <h3 class="font-medium text-gray-400">Pending</h3>
117
+ <span id="pending-count" class="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded-full">0</span>
118
+ </div>
119
+ <div id="pending-tasks" class="flex-1 space-y-3 kanban-column overflow-y-auto pr-1">
120
+ </div>
121
+ </div>
122
+
123
+ <!-- In Progress column -->
124
+ <div class="w-80 flex flex-col">
125
+ <div class="flex items-center gap-2 mb-3 px-1">
126
+ <span class="w-3 h-3 rounded-full bg-claude-orange status-pulse"></span>
127
+ <h3 class="font-medium text-claude-orange">In Progress</h3>
128
+ <span id="in-progress-count" class="text-xs text-claude-orange/70 bg-claude-orange/20 px-2 py-0.5 rounded-full">0</span>
129
+ </div>
130
+ <div id="in-progress-tasks" class="flex-1 space-y-3 kanban-column overflow-y-auto pr-1">
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Completed column -->
135
+ <div class="w-80 flex flex-col">
136
+ <div class="flex items-center gap-2 mb-3 px-1">
137
+ <span class="w-3 h-3 rounded-full bg-green-500"></span>
138
+ <h3 class="font-medium text-green-400">Completed</h3>
139
+ <span id="completed-count" class="text-xs text-green-400/70 bg-green-500/20 px-2 py-0.5 rounded-full">0</span>
140
+ </div>
141
+ <div id="completed-tasks" class="flex-1 space-y-3 kanban-column overflow-y-auto pr-1">
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </main>
148
+
149
+ <!-- Task detail panel -->
150
+ <aside id="detail-panel" class="w-96 bg-gray-900 border-l border-gray-800 hidden overflow-y-auto flex-shrink-0">
151
+ <header class="p-4 border-b border-gray-800 flex items-center justify-between sticky top-0 bg-gray-900 z-10">
152
+ <h3 class="font-semibold">Task Details</h3>
153
+ <button id="close-detail" class="text-gray-500 hover:text-gray-300">
154
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
155
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
156
+ </svg>
157
+ </button>
158
+ </header>
159
+ <div id="detail-content" class="p-4"></div>
160
+ </aside>
161
+ </div>
162
+
163
+ <script>
164
+ // State
165
+ let sessions = [];
166
+ let currentSessionId = null;
167
+ let currentTasks = [];
168
+ let viewMode = 'session'; // 'session' or 'all'
169
+
170
+ // DOM elements
171
+ const sessionsList = document.getElementById('sessions-list');
172
+ const noSession = document.getElementById('no-session');
173
+ const sessionView = document.getElementById('session-view');
174
+ const sessionTitle = document.getElementById('session-title');
175
+ const sessionMeta = document.getElementById('session-meta');
176
+ const progressPercent = document.getElementById('progress-percent');
177
+ const progressBar = document.getElementById('progress-bar');
178
+ const pendingTasks = document.getElementById('pending-tasks');
179
+ const inProgressTasks = document.getElementById('in-progress-tasks');
180
+ const completedTasks = document.getElementById('completed-tasks');
181
+ const pendingCount = document.getElementById('pending-count');
182
+ const inProgressCount = document.getElementById('in-progress-count');
183
+ const completedCount = document.getElementById('completed-count');
184
+ const detailPanel = document.getElementById('detail-panel');
185
+ const detailContent = document.getElementById('detail-content');
186
+ const connectionStatus = document.getElementById('connection-status');
187
+
188
+ // Fetch sessions
189
+ async function fetchSessions() {
190
+ try {
191
+ const res = await fetch('/api/sessions');
192
+ sessions = await res.json();
193
+ renderSessions();
194
+ } catch (error) {
195
+ console.error('Failed to fetch sessions:', error);
196
+ }
197
+ }
198
+
199
+ // Fetch tasks for a session
200
+ async function fetchTasks(sessionId) {
201
+ try {
202
+ viewMode = 'session';
203
+ const res = await fetch(`/api/sessions/${sessionId}`);
204
+ currentTasks = await res.json();
205
+ currentSessionId = sessionId;
206
+ renderSession();
207
+ } catch (error) {
208
+ console.error('Failed to fetch tasks:', error);
209
+ }
210
+ }
211
+
212
+ // Show all tasks across all sessions
213
+ async function showAllTasks() {
214
+ try {
215
+ viewMode = 'all';
216
+ currentSessionId = null;
217
+ const res = await fetch('/api/tasks/all');
218
+ currentTasks = await res.json();
219
+ renderAllTasks();
220
+ renderSessions();
221
+ } catch (error) {
222
+ console.error('Failed to fetch all tasks:', error);
223
+ }
224
+ }
225
+
226
+ // Render all tasks view
227
+ function renderAllTasks() {
228
+ noSession.classList.add('hidden');
229
+ sessionView.classList.remove('hidden');
230
+
231
+ const totalTasks = currentTasks.length;
232
+ const completed = currentTasks.filter(t => t.status === 'completed').length;
233
+ const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
234
+
235
+ sessionTitle.textContent = 'All Tasks';
236
+ sessionMeta.textContent = `${totalTasks} tasks across ${sessions.length} sessions`;
237
+
238
+ progressPercent.textContent = `${percent}%`;
239
+ progressBar.style.width = `${percent}%`;
240
+
241
+ renderKanban();
242
+ }
243
+
244
+ // Render sessions sidebar
245
+ function renderSessions() {
246
+ // Update all tasks button state
247
+ const allTasksBtn = document.getElementById('all-tasks-btn');
248
+ if (allTasksBtn) {
249
+ allTasksBtn.className = `w-full text-left p-3 rounded-lg transition-colors flex items-center gap-2 ${
250
+ viewMode === 'all' ? 'bg-gray-800' : 'hover:bg-gray-800/50'
251
+ }`;
252
+ }
253
+
254
+ if (sessions.length === 0) {
255
+ sessionsList.innerHTML = `
256
+ <div class="text-gray-500 text-sm p-4 text-center">
257
+ <p>No sessions found</p>
258
+ <p class="mt-2 text-xs">Tasks will appear here when you use Claude Code</p>
259
+ </div>
260
+ `;
261
+ return;
262
+ }
263
+
264
+ sessionsList.innerHTML = sessions.map(session => {
265
+ const total = session.taskCount;
266
+ const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
267
+ const isActive = session.id === currentSessionId && viewMode === 'session';
268
+ const hasInProgress = session.inProgress > 0;
269
+ const displayName = session.name || session.id.slice(0, 8) + '...';
270
+ const projectName = session.project ? session.project.split('/').pop() : null;
271
+
272
+ return `
273
+ <button
274
+ onclick="fetchTasks('${session.id}')"
275
+ class="w-full text-left p-3 rounded-lg transition-colors ${isActive ? 'bg-gray-800' : 'hover:bg-gray-800/50'}"
276
+ >
277
+ <div class="flex items-center justify-between gap-2">
278
+ <span class="text-sm text-gray-200 truncate flex-1 ${session.name ? '' : 'font-mono text-xs text-gray-400'}">${escapeHtml(displayName)}</span>
279
+ ${hasInProgress ? '<span class="w-2 h-2 rounded-full bg-claude-orange status-pulse flex-shrink-0"></span>' : ''}
280
+ </div>
281
+ ${projectName ? `<p class="text-xs text-gray-500 mt-1 truncate">${escapeHtml(projectName)}</p>` : ''}
282
+ <div class="flex items-center gap-2 mt-2">
283
+ <div class="flex-1 h-1.5 bg-gray-700 rounded-full overflow-hidden">
284
+ <div class="h-full bg-claude-orange transition-all" style="width: ${percent}%"></div>
285
+ </div>
286
+ <span class="text-xs text-gray-500">${session.completed}/${total}</span>
287
+ </div>
288
+ <p class="text-xs text-gray-600 mt-1">Tasks updated ${formatDate(session.modifiedAt)}</p>
289
+ </button>
290
+ `;
291
+ }).join('');
292
+ }
293
+
294
+ // Render current session
295
+ function renderSession() {
296
+ noSession.classList.add('hidden');
297
+ sessionView.classList.remove('hidden');
298
+
299
+ const session = sessions.find(s => s.id === currentSessionId);
300
+ if (!session) return;
301
+
302
+ const displayName = session.name || currentSessionId;
303
+ sessionTitle.textContent = displayName;
304
+ const projectName = session.project ? session.project.split('/').pop() : null;
305
+ sessionMeta.textContent = `${currentTasks.length} tasks${projectName ? ' · ' + projectName : ''} · Tasks updated ${formatDate(session.modifiedAt)}`;
306
+
307
+ const completed = currentTasks.filter(t => t.status === 'completed').length;
308
+ const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
309
+
310
+ progressPercent.textContent = `${percent}%`;
311
+ progressBar.style.width = `${percent}%`;
312
+
313
+ renderKanban();
314
+ renderSessions();
315
+ }
316
+
317
+ // Render task card
318
+ function renderTaskCard(task) {
319
+ const isBlocked = task.blockedBy && task.blockedBy.length > 0;
320
+ const statusStyles = {
321
+ pending: 'border-gray-700 bg-gray-800/50',
322
+ in_progress: 'border-claude-orange/30 bg-claude-orange/10',
323
+ completed: 'border-green-500/30 bg-green-500/10'
324
+ };
325
+ const taskId = viewMode === 'all' ? `${task.sessionId?.slice(0,4)}-${task.id}` : task.id;
326
+ const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
327
+
328
+ return `
329
+ <div
330
+ onclick="showTaskDetail('${task.id}', '${task.sessionId || ''}')"
331
+ class="p-3 rounded-lg border ${statusStyles[task.status] || statusStyles.pending} cursor-pointer hover:brightness-110 transition-all ${isBlocked ? 'opacity-60' : ''}"
332
+ >
333
+ <div class="flex items-center gap-2 mb-2">
334
+ <span class="text-xs font-mono text-gray-500">#${taskId}</span>
335
+ ${isBlocked ? '<span class="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">blocked</span>' : ''}
336
+ </div>
337
+ <h4 class="text-sm font-medium ${task.status === 'completed' ? 'line-through text-gray-500' : 'text-gray-200'}">${escapeHtml(task.subject)}</h4>
338
+ ${sessionLabel ? `<p class="text-xs text-blue-400 mt-1">${escapeHtml(sessionLabel)}</p>` : ''}
339
+ ${task.status === 'in_progress' && task.activeForm ? `
340
+ <p class="text-xs text-claude-orange mt-2 flex items-center gap-1">
341
+ <span class="status-pulse">●</span>
342
+ ${escapeHtml(task.activeForm)}
343
+ </p>
344
+ ` : ''}
345
+ ${isBlocked ? `<p class="text-xs text-gray-500 mt-2">Waiting on: ${task.blockedBy.map(id => '#' + id).join(', ')}</p>` : ''}
346
+ ${task.description ? `<p class="text-xs text-gray-500 mt-2 line-clamp-2">${escapeHtml(task.description.split('\n')[0])}</p>` : ''}
347
+ </div>
348
+ `;
349
+ }
350
+
351
+ // Render kanban board
352
+ function renderKanban() {
353
+ const pending = currentTasks.filter(t => t.status === 'pending');
354
+ const inProgress = currentTasks.filter(t => t.status === 'in_progress');
355
+ const completed = currentTasks.filter(t => t.status === 'completed');
356
+
357
+ pendingCount.textContent = pending.length;
358
+ inProgressCount.textContent = inProgress.length;
359
+ completedCount.textContent = completed.length;
360
+
361
+ pendingTasks.innerHTML = pending.length > 0
362
+ ? pending.map(renderTaskCard).join('')
363
+ : '<p class="text-gray-600 text-sm text-center py-8">No pending tasks</p>';
364
+
365
+ inProgressTasks.innerHTML = inProgress.length > 0
366
+ ? inProgress.map(renderTaskCard).join('')
367
+ : '<p class="text-gray-600 text-sm text-center py-8">No tasks in progress</p>';
368
+
369
+ completedTasks.innerHTML = completed.length > 0
370
+ ? completed.map(renderTaskCard).join('')
371
+ : '<p class="text-gray-600 text-sm text-center py-8">No completed tasks</p>';
372
+ }
373
+
374
+ // Show task detail
375
+ function showTaskDetail(taskId, sessionId = null) {
376
+ const task = currentTasks.find(t =>
377
+ t.id === taskId && (!sessionId || t.sessionId === sessionId)
378
+ );
379
+ if (!task) return;
380
+
381
+ detailPanel.classList.remove('hidden');
382
+
383
+ const statusLabels = {
384
+ completed: '<span class="inline-flex items-center gap-1 text-green-400"><span class="w-2 h-2 rounded-full bg-green-500"></span>Completed</span>',
385
+ in_progress: '<span class="inline-flex items-center gap-1 text-claude-orange"><span class="w-2 h-2 rounded-full bg-claude-orange status-pulse"></span>In Progress</span>',
386
+ pending: '<span class="inline-flex items-center gap-1 text-gray-400"><span class="w-2 h-2 rounded-full bg-gray-500"></span>Pending</span>'
387
+ };
388
+
389
+ detailContent.innerHTML = `
390
+ <div class="space-y-4">
391
+ <div>
392
+ <span class="text-xs text-gray-500">Task #${task.id}</span>
393
+ <h3 class="text-lg font-semibold mt-1">${escapeHtml(task.subject)}</h3>
394
+ </div>
395
+
396
+ <div class="text-sm">
397
+ ${statusLabels[task.status] || statusLabels.pending}
398
+ </div>
399
+
400
+ ${task.activeForm && task.status === 'in_progress' ? `
401
+ <div class="text-sm bg-claude-orange/10 border border-claude-orange/20 rounded-lg p-3">
402
+ <span class="text-gray-400">Currently:</span>
403
+ <span class="text-claude-orange ml-1">${escapeHtml(task.activeForm)}</span>
404
+ </div>
405
+ ` : ''}
406
+
407
+ ${task.blockedBy && task.blockedBy.length > 0 ? `
408
+ <div class="text-sm bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
409
+ <span class="text-gray-400">Blocked by:</span>
410
+ <span class="text-yellow-400 ml-1">${task.blockedBy.map(id => '#' + id).join(', ')}</span>
411
+ </div>
412
+ ` : ''}
413
+
414
+ ${task.blocks && task.blocks.length > 0 ? `
415
+ <div class="text-sm bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
416
+ <span class="text-gray-400">Blocks:</span>
417
+ <span class="text-blue-400 ml-1">${task.blocks.map(id => '#' + id).join(', ')}</span>
418
+ </div>
419
+ ` : ''}
420
+
421
+ ${task.description ? `
422
+ <div class="border-t border-gray-800 pt-4 mt-4">
423
+ <h4 class="text-sm font-medium text-gray-400 mb-3">Description</h4>
424
+ <div class="prose prose-invert prose-sm max-w-none text-gray-300">
425
+ ${marked.parse(task.description)}
426
+ </div>
427
+ </div>
428
+ ` : ''}
429
+ </div>
430
+ `;
431
+ }
432
+
433
+ // Close detail panel
434
+ document.getElementById('close-detail').onclick = () => {
435
+ detailPanel.classList.add('hidden');
436
+ };
437
+
438
+ // Setup SSE for live updates
439
+ function setupEventSource() {
440
+ const eventSource = new EventSource('/api/events');
441
+
442
+ eventSource.onopen = () => {
443
+ connectionStatus.innerHTML = `
444
+ <span class="w-2 h-2 rounded-full bg-green-500"></span>
445
+ <span class="text-gray-400">Live</span>
446
+ `;
447
+ };
448
+
449
+ eventSource.onerror = () => {
450
+ connectionStatus.innerHTML = `
451
+ <span class="w-2 h-2 rounded-full bg-red-500"></span>
452
+ <span class="text-gray-400">Disconnected</span>
453
+ `;
454
+ };
455
+
456
+ eventSource.onmessage = (event) => {
457
+ const data = JSON.parse(event.data);
458
+
459
+ if (data.type === 'update') {
460
+ fetchSessions();
461
+ if (data.sessionId === currentSessionId) {
462
+ fetchTasks(currentSessionId);
463
+ }
464
+ }
465
+ };
466
+ }
467
+
468
+ // Helpers
469
+ function formatDate(dateStr) {
470
+ const date = new Date(dateStr);
471
+ const now = new Date();
472
+ const diff = now - date;
473
+
474
+ if (diff < 60000) return 'just now';
475
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
476
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
477
+ return date.toLocaleDateString();
478
+ }
479
+
480
+ function escapeHtml(text) {
481
+ const div = document.createElement('div');
482
+ div.textContent = text;
483
+ return div.innerHTML;
484
+ }
485
+
486
+ // Initialize
487
+ fetchSessions();
488
+ setupEventSource();
489
+
490
+ // Auto-select most recent session with activity
491
+ setTimeout(() => {
492
+ if (sessions.length > 0 && !currentSessionId) {
493
+ const activeSession = sessions.find(s => s.inProgress > 0) || sessions[0];
494
+ fetchTasks(activeSession.id);
495
+ }
496
+ }, 500);
497
+ </script>
498
+ </body>
499
+ </html>
package/server.js ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+
3
+ const express = require('express');
4
+ const path = require('path');
5
+ const fs = require('fs').promises;
6
+ const { existsSync, readdirSync, readFileSync, statSync, createReadStream } = require('fs');
7
+ const readline = require('readline');
8
+ const chokidar = require('chokidar');
9
+ const os = require('os');
10
+
11
+ const app = express();
12
+ const PORT = process.env.PORT || 3456;
13
+ const CLAUDE_DIR = process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
14
+ const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
15
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
16
+
17
+ // SSE clients for live updates
18
+ const clients = new Set();
19
+
20
+ // Cache for session metadata (refreshed periodically)
21
+ let sessionMetadataCache = {};
22
+ let lastMetadataRefresh = 0;
23
+ const METADATA_CACHE_TTL = 10000; // 10 seconds
24
+
25
+ // Serve static files
26
+ app.use(express.static(path.join(__dirname, 'public')));
27
+
28
+ /**
29
+ * Read customTitle and slug from a JSONL file
30
+ * Returns { customTitle, slug } - customTitle from /rename, slug from session
31
+ */
32
+ function readSessionInfoFromJsonl(jsonlPath) {
33
+ const result = { customTitle: null, slug: null };
34
+
35
+ try {
36
+ if (!existsSync(jsonlPath)) return result;
37
+
38
+ // Read first 64KB - should contain custom-title and at least one message with slug
39
+ const fd = require('fs').openSync(jsonlPath, 'r');
40
+ const buffer = Buffer.alloc(65536);
41
+ const bytesRead = require('fs').readSync(fd, buffer, 0, 65536, 0);
42
+ require('fs').closeSync(fd);
43
+
44
+ const content = buffer.toString('utf8', 0, bytesRead);
45
+ const lines = content.split('\n');
46
+
47
+ for (const line of lines) {
48
+ if (!line.trim()) continue;
49
+ try {
50
+ const data = JSON.parse(line);
51
+
52
+ // Check for custom-title entry (from /rename command)
53
+ if (data.type === 'custom-title' && data.customTitle) {
54
+ result.customTitle = data.customTitle;
55
+ }
56
+
57
+ // Check for slug in user/assistant messages
58
+ if (data.slug && !result.slug) {
59
+ result.slug = data.slug;
60
+ }
61
+
62
+ // Stop early if we found both
63
+ if (result.customTitle && result.slug) break;
64
+ } catch (e) {
65
+ // Skip malformed lines
66
+ }
67
+ }
68
+ } catch (e) {
69
+ // Return partial results
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Scan all project directories to find session JSONL files and extract slugs
77
+ */
78
+ function loadSessionMetadata() {
79
+ const now = Date.now();
80
+ if (now - lastMetadataRefresh < METADATA_CACHE_TTL) {
81
+ return sessionMetadataCache;
82
+ }
83
+
84
+ const metadata = {};
85
+
86
+ try {
87
+ if (!existsSync(PROJECTS_DIR)) {
88
+ return metadata;
89
+ }
90
+
91
+ const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true })
92
+ .filter(d => d.isDirectory());
93
+
94
+ for (const projectDir of projectDirs) {
95
+ const projectPath = path.join(PROJECTS_DIR, projectDir.name);
96
+
97
+ // Find all .jsonl files (session logs)
98
+ const files = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
99
+
100
+ for (const file of files) {
101
+ const sessionId = file.replace('.jsonl', '');
102
+ const jsonlPath = path.join(projectPath, file);
103
+
104
+ // Read customTitle and slug from JSONL
105
+ const sessionInfo = readSessionInfoFromJsonl(jsonlPath);
106
+
107
+ // Decode project path from folder name (replace - with /)
108
+ const projectName = projectDir.name.replace(/^-/, '').replace(/-/g, '/');
109
+
110
+ metadata[sessionId] = {
111
+ customTitle: sessionInfo.customTitle,
112
+ slug: sessionInfo.slug,
113
+ project: '/' + projectName,
114
+ jsonlPath: jsonlPath
115
+ };
116
+ }
117
+
118
+ // Also check sessions-index.json for custom names (if /rename was used)
119
+ const indexPath = path.join(projectPath, 'sessions-index.json');
120
+ if (existsSync(indexPath)) {
121
+ try {
122
+ const indexData = JSON.parse(readFileSync(indexPath, 'utf8'));
123
+ const entries = indexData.entries || [];
124
+
125
+ for (const entry of entries) {
126
+ if (entry.sessionId && metadata[entry.sessionId]) {
127
+ // Check for custom name field (might be 'customName', 'name', or similar)
128
+ if (entry.customName) {
129
+ metadata[entry.sessionId].customName = entry.customName;
130
+ }
131
+ if (entry.name) {
132
+ metadata[entry.sessionId].customName = entry.name;
133
+ }
134
+ // Add other useful fields
135
+ metadata[entry.sessionId].gitBranch = entry.gitBranch || null;
136
+ metadata[entry.sessionId].created = entry.created || null;
137
+ }
138
+ }
139
+ } catch (e) {
140
+ // Skip invalid index files
141
+ }
142
+ }
143
+ }
144
+ } catch (e) {
145
+ console.error('Error loading session metadata:', e);
146
+ }
147
+
148
+ sessionMetadataCache = metadata;
149
+ lastMetadataRefresh = now;
150
+ return metadata;
151
+ }
152
+
153
+ /**
154
+ * Get display name for a session: customTitle > slug > null (frontend shows UUID)
155
+ */
156
+ function getSessionDisplayName(sessionId, meta) {
157
+ if (meta?.customTitle) return meta.customTitle;
158
+ if (meta?.slug) return meta.slug;
159
+ return null; // Frontend will show UUID as fallback
160
+ }
161
+
162
+ // API: List all sessions
163
+ app.get('/api/sessions', async (req, res) => {
164
+ try {
165
+ if (!existsSync(TASKS_DIR)) {
166
+ return res.json([]);
167
+ }
168
+
169
+ const metadata = loadSessionMetadata();
170
+ const entries = readdirSync(TASKS_DIR, { withFileTypes: true });
171
+ const sessions = [];
172
+
173
+ for (const entry of entries) {
174
+ if (entry.isDirectory()) {
175
+ const sessionPath = path.join(TASKS_DIR, entry.name);
176
+ const stat = statSync(sessionPath);
177
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
178
+
179
+ // Get task summary
180
+ let completed = 0;
181
+ let inProgress = 0;
182
+ let pending = 0;
183
+
184
+ for (const file of taskFiles) {
185
+ try {
186
+ const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
187
+ if (task.status === 'completed') completed++;
188
+ else if (task.status === 'in_progress') inProgress++;
189
+ else pending++;
190
+ } catch (e) {
191
+ // Skip invalid files
192
+ }
193
+ }
194
+
195
+ // Get metadata for this session
196
+ const meta = metadata[entry.name] || {};
197
+
198
+ sessions.push({
199
+ id: entry.name,
200
+ name: getSessionDisplayName(entry.name, meta),
201
+ slug: meta.slug || null,
202
+ project: meta.project || null,
203
+ gitBranch: meta.gitBranch || null,
204
+ taskCount: taskFiles.length,
205
+ completed,
206
+ inProgress,
207
+ pending,
208
+ createdAt: meta.created || null,
209
+ modifiedAt: stat.mtime.toISOString()
210
+ });
211
+ }
212
+ }
213
+
214
+ // Sort by most recently modified
215
+ sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
216
+
217
+ res.json(sessions);
218
+ } catch (error) {
219
+ console.error('Error listing sessions:', error);
220
+ res.status(500).json({ error: 'Failed to list sessions' });
221
+ }
222
+ });
223
+
224
+ // API: Get tasks for a session
225
+ app.get('/api/sessions/:sessionId', async (req, res) => {
226
+ try {
227
+ const sessionPath = path.join(TASKS_DIR, req.params.sessionId);
228
+
229
+ if (!existsSync(sessionPath)) {
230
+ return res.status(404).json({ error: 'Session not found' });
231
+ }
232
+
233
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
234
+ const tasks = [];
235
+
236
+ for (const file of taskFiles) {
237
+ try {
238
+ const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
239
+ tasks.push(task);
240
+ } catch (e) {
241
+ console.error(`Error parsing ${file}:`, e);
242
+ }
243
+ }
244
+
245
+ // Sort by ID (numeric)
246
+ tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
247
+
248
+ res.json(tasks);
249
+ } catch (error) {
250
+ console.error('Error getting session:', error);
251
+ res.status(500).json({ error: 'Failed to get session' });
252
+ }
253
+ });
254
+
255
+ // API: Get all tasks across all sessions
256
+ app.get('/api/tasks/all', async (req, res) => {
257
+ try {
258
+ if (!existsSync(TASKS_DIR)) {
259
+ return res.json([]);
260
+ }
261
+
262
+ const metadata = loadSessionMetadata();
263
+ const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
264
+ .filter(d => d.isDirectory());
265
+
266
+ const allTasks = [];
267
+
268
+ for (const sessionDir of sessionDirs) {
269
+ const sessionPath = path.join(TASKS_DIR, sessionDir.name);
270
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
271
+ const meta = metadata[sessionDir.name] || {};
272
+
273
+ for (const file of taskFiles) {
274
+ try {
275
+ const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
276
+ allTasks.push({
277
+ ...task,
278
+ sessionId: sessionDir.name,
279
+ sessionName: getSessionDisplayName(sessionDir.name, meta),
280
+ project: meta.project || null
281
+ });
282
+ } catch (e) {
283
+ // Skip invalid files
284
+ }
285
+ }
286
+ }
287
+
288
+ res.json(allTasks);
289
+ } catch (error) {
290
+ console.error('Error getting all tasks:', error);
291
+ res.status(500).json({ error: 'Failed to get all tasks' });
292
+ }
293
+ });
294
+
295
+ // SSE endpoint for live updates
296
+ app.get('/api/events', (req, res) => {
297
+ res.setHeader('Content-Type', 'text/event-stream');
298
+ res.setHeader('Cache-Control', 'no-cache');
299
+ res.setHeader('Connection', 'keep-alive');
300
+
301
+ clients.add(res);
302
+
303
+ req.on('close', () => {
304
+ clients.delete(res);
305
+ });
306
+
307
+ // Send initial ping
308
+ res.write('data: {"type":"connected"}\n\n');
309
+ });
310
+
311
+ // Broadcast update to all SSE clients
312
+ function broadcast(data) {
313
+ const message = `data: ${JSON.stringify(data)}\n\n`;
314
+ for (const client of clients) {
315
+ client.write(message);
316
+ }
317
+ }
318
+
319
+ // Watch for file changes
320
+ if (existsSync(TASKS_DIR)) {
321
+ const watcher = chokidar.watch(TASKS_DIR, {
322
+ persistent: true,
323
+ ignoreInitial: true,
324
+ depth: 2
325
+ });
326
+
327
+ watcher.on('all', (event, filePath) => {
328
+ if (filePath.endsWith('.json')) {
329
+ const relativePath = path.relative(TASKS_DIR, filePath);
330
+ const sessionId = relativePath.split(path.sep)[0];
331
+
332
+ broadcast({
333
+ type: 'update',
334
+ event,
335
+ sessionId,
336
+ file: path.basename(filePath)
337
+ });
338
+ }
339
+ });
340
+
341
+ console.log(`Watching for changes in: ${TASKS_DIR}`);
342
+ }
343
+
344
+ // Also watch projects dir for metadata changes
345
+ if (existsSync(PROJECTS_DIR)) {
346
+ const projectsWatcher = chokidar.watch(path.join(PROJECTS_DIR, '*/*.jsonl'), {
347
+ persistent: true,
348
+ ignoreInitial: true,
349
+ depth: 1
350
+ });
351
+
352
+ projectsWatcher.on('all', (event) => {
353
+ // Invalidate cache on any change
354
+ lastMetadataRefresh = 0;
355
+ broadcast({ type: 'metadata-update' });
356
+ });
357
+ }
358
+
359
+ // Start server
360
+ app.listen(PORT, () => {
361
+ console.log(`Claude Task Viewer running at http://localhost:${PORT}`);
362
+
363
+ // Open browser if --open flag is passed
364
+ if (process.argv.includes('--open')) {
365
+ import('open').then(open => open.default(`http://localhost:${PORT}`));
366
+ }
367
+ });