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 +21 -0
- package/README.md +90 -0
- package/package.json +44 -0
- package/public/index.html +499 -0
- package/server.js +367 -0
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
|
+
});
|