claude-code-kanban 1.9.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/server.js ADDED
@@ -0,0 +1,527 @@
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
+
14
+ // Parse --dir flag for custom Claude directory
15
+ function getClaudeDir() {
16
+ const dirIndex = process.argv.findIndex(arg => arg.startsWith('--dir'));
17
+ if (dirIndex !== -1) {
18
+ const arg = process.argv[dirIndex];
19
+ if (arg.includes('=')) {
20
+ const dir = arg.split('=')[1];
21
+ return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
22
+ } else if (process.argv[dirIndex + 1]) {
23
+ const dir = process.argv[dirIndex + 1];
24
+ return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
25
+ }
26
+ }
27
+ return process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
28
+ }
29
+
30
+ const CLAUDE_DIR = getClaudeDir();
31
+ const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
32
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
33
+ const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
34
+
35
+ function isTeamSession(sessionId) {
36
+ return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
37
+ }
38
+
39
+ const teamConfigCache = new Map();
40
+ const TEAM_CACHE_TTL = 5000;
41
+
42
+ function loadTeamConfig(teamName) {
43
+ const cached = teamConfigCache.get(teamName);
44
+ if (cached && Date.now() - cached.ts < TEAM_CACHE_TTL) return cached.data;
45
+ try {
46
+ const configPath = path.join(TEAMS_DIR, teamName, 'config.json');
47
+ if (!existsSync(configPath)) return null;
48
+ const data = JSON.parse(readFileSync(configPath, 'utf8'));
49
+ teamConfigCache.set(teamName, { data, ts: Date.now() });
50
+ return data;
51
+ } catch (e) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // SSE clients for live updates
57
+ const clients = new Set();
58
+
59
+ // Cache for session metadata (refreshed periodically)
60
+ let sessionMetadataCache = {};
61
+ let lastMetadataRefresh = 0;
62
+ const METADATA_CACHE_TTL = 10000; // 10 seconds
63
+
64
+ // Parse JSON bodies
65
+ app.use(express.json());
66
+
67
+ // Serve static files
68
+ app.use(express.static(path.join(__dirname, 'public')));
69
+
70
+ /**
71
+ * Read customTitle and slug from a JSONL file
72
+ * Returns { customTitle, slug } - customTitle from /rename, slug from session
73
+ */
74
+ function readSessionInfoFromJsonl(jsonlPath) {
75
+ const result = { customTitle: null, slug: null, projectPath: null };
76
+
77
+ try {
78
+ if (!existsSync(jsonlPath)) return result;
79
+
80
+ // Read first 64KB - should contain custom-title and at least one message with slug/cwd
81
+ const fd = require('fs').openSync(jsonlPath, 'r');
82
+ const buffer = Buffer.alloc(65536);
83
+ const bytesRead = require('fs').readSync(fd, buffer, 0, 65536, 0);
84
+ require('fs').closeSync(fd);
85
+
86
+ const content = buffer.toString('utf8', 0, bytesRead);
87
+ const lines = content.split('\n');
88
+
89
+ for (const line of lines) {
90
+ if (!line.trim()) continue;
91
+ try {
92
+ const data = JSON.parse(line);
93
+
94
+ // Check for custom-title entry (from /rename command)
95
+ if (data.type === 'custom-title' && data.customTitle) {
96
+ result.customTitle = data.customTitle;
97
+ }
98
+
99
+ // Check for slug in user/assistant messages
100
+ if (data.slug && !result.slug) {
101
+ result.slug = data.slug;
102
+ }
103
+
104
+ // Extract project path from cwd field (actual path, no encoding issues)
105
+ if (data.cwd && !result.projectPath) {
106
+ result.projectPath = data.cwd;
107
+ }
108
+
109
+ // Stop early if we found all three
110
+ if (result.customTitle && result.slug && result.projectPath) break;
111
+ } catch (e) {
112
+ // Skip malformed lines
113
+ }
114
+ }
115
+ } catch (e) {
116
+ // Return partial results
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Scan all project directories to find session JSONL files and extract slugs
124
+ */
125
+ function loadSessionMetadata() {
126
+ const now = Date.now();
127
+ if (now - lastMetadataRefresh < METADATA_CACHE_TTL) {
128
+ return sessionMetadataCache;
129
+ }
130
+
131
+ const metadata = {};
132
+
133
+ try {
134
+ if (!existsSync(PROJECTS_DIR)) {
135
+ return metadata;
136
+ }
137
+
138
+ const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true })
139
+ .filter(d => d.isDirectory());
140
+
141
+ for (const projectDir of projectDirs) {
142
+ const projectPath = path.join(PROJECTS_DIR, projectDir.name);
143
+
144
+ // Find all .jsonl files (session logs)
145
+ const files = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
146
+
147
+ for (const file of files) {
148
+ const sessionId = file.replace('.jsonl', '');
149
+ const jsonlPath = path.join(projectPath, file);
150
+
151
+ // Read customTitle, slug, and actual project path from JSONL
152
+ const sessionInfo = readSessionInfoFromJsonl(jsonlPath);
153
+
154
+ metadata[sessionId] = {
155
+ customTitle: sessionInfo.customTitle,
156
+ slug: sessionInfo.slug,
157
+ project: sessionInfo.projectPath || null,
158
+ jsonlPath: jsonlPath
159
+ };
160
+ }
161
+
162
+ // Also check sessions-index.json for custom names (if /rename was used)
163
+ const indexPath = path.join(projectPath, 'sessions-index.json');
164
+ if (existsSync(indexPath)) {
165
+ try {
166
+ const indexData = JSON.parse(readFileSync(indexPath, 'utf8'));
167
+ const entries = indexData.entries || [];
168
+
169
+ for (const entry of entries) {
170
+ if (entry.sessionId && metadata[entry.sessionId]) {
171
+ // Add other useful fields
172
+ metadata[entry.sessionId].description = entry.description || null;
173
+ metadata[entry.sessionId].gitBranch = entry.gitBranch || null;
174
+ metadata[entry.sessionId].created = entry.created || null;
175
+ }
176
+ }
177
+ } catch (e) {
178
+ // Skip invalid index files
179
+ }
180
+ }
181
+ }
182
+ } catch (e) {
183
+ console.error('Error loading session metadata:', e);
184
+ }
185
+
186
+ sessionMetadataCache = metadata;
187
+ lastMetadataRefresh = now;
188
+ return metadata;
189
+ }
190
+
191
+ /**
192
+ * Get display name for a session: customTitle > slug > null (frontend shows UUID)
193
+ */
194
+ function getSessionDisplayName(sessionId, meta) {
195
+ if (meta?.customTitle) return meta.customTitle;
196
+ if (meta?.slug) return meta.slug;
197
+ return null; // Frontend will show UUID as fallback
198
+ }
199
+
200
+ // API: List all sessions
201
+ app.get('/api/sessions', async (req, res) => {
202
+ // Prevent browser caching
203
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
204
+ res.setHeader('Pragma', 'no-cache');
205
+ res.setHeader('Expires', '0');
206
+
207
+ try {
208
+ // Parse limit parameter (default: 20, "all" for unlimited)
209
+ const limitParam = req.query.limit || '20';
210
+ const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
211
+
212
+ const metadata = loadSessionMetadata();
213
+ const sessionsMap = new Map();
214
+
215
+ // First, add sessions that have tasks directories
216
+ if (existsSync(TASKS_DIR)) {
217
+ const entries = readdirSync(TASKS_DIR, { withFileTypes: true });
218
+
219
+ for (const entry of entries) {
220
+ if (entry.isDirectory()) {
221
+ const sessionPath = path.join(TASKS_DIR, entry.name);
222
+ const stat = statSync(sessionPath);
223
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
224
+
225
+ // Get task summary and find newest task file
226
+ let completed = 0;
227
+ let inProgress = 0;
228
+ let pending = 0;
229
+ let newestTaskMtime = null;
230
+
231
+ for (const file of taskFiles) {
232
+ try {
233
+ const taskPath = path.join(sessionPath, file);
234
+ const task = JSON.parse(readFileSync(taskPath, 'utf8'));
235
+ if (task.status === 'completed') completed++;
236
+ else if (task.status === 'in_progress') inProgress++;
237
+ else pending++;
238
+
239
+ // Track newest task file mtime
240
+ const taskStat = statSync(taskPath);
241
+ if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
242
+ newestTaskMtime = taskStat.mtime;
243
+ }
244
+ } catch (e) {
245
+ // Skip invalid files
246
+ }
247
+ }
248
+
249
+ // Get metadata for this session
250
+ const meta = metadata[entry.name] || {};
251
+
252
+ // Use newest task file mtime, or fall back to directory mtime if no tasks
253
+ const modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
254
+
255
+ const isTeam = isTeamSession(entry.name);
256
+ const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
257
+
258
+ sessionsMap.set(entry.name, {
259
+ id: entry.name,
260
+ name: getSessionDisplayName(entry.name, meta),
261
+ slug: meta.slug || null,
262
+ project: meta.project || null,
263
+ description: meta.description || null,
264
+ gitBranch: meta.gitBranch || null,
265
+ taskCount: taskFiles.length,
266
+ completed,
267
+ inProgress,
268
+ pending,
269
+ createdAt: meta.created || null,
270
+ modifiedAt: modifiedAt,
271
+ isTeam,
272
+ memberCount
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ // Convert map to array and sort by most recently modified
279
+ let sessions = Array.from(sessionsMap.values());
280
+ sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
281
+
282
+ // Apply limit if specified
283
+ if (limit !== null && limit > 0) {
284
+ sessions = sessions.slice(0, limit);
285
+ }
286
+
287
+ res.json(sessions);
288
+ } catch (error) {
289
+ console.error('Error listing sessions:', error);
290
+ res.status(500).json({ error: 'Failed to list sessions' });
291
+ }
292
+ });
293
+
294
+ // API: Get tasks for a session
295
+ app.get('/api/sessions/:sessionId', async (req, res) => {
296
+ try {
297
+ const sessionPath = path.join(TASKS_DIR, req.params.sessionId);
298
+
299
+ if (!existsSync(sessionPath)) {
300
+ return res.status(404).json({ error: 'Session not found' });
301
+ }
302
+
303
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
304
+ const tasks = [];
305
+
306
+ for (const file of taskFiles) {
307
+ try {
308
+ const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
309
+ tasks.push(task);
310
+ } catch (e) {
311
+ console.error(`Error parsing ${file}:`, e);
312
+ }
313
+ }
314
+
315
+ // Sort by ID (numeric)
316
+ tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
317
+
318
+ res.json(tasks);
319
+ } catch (error) {
320
+ console.error('Error getting session:', error);
321
+ res.status(500).json({ error: 'Failed to get session' });
322
+ }
323
+ });
324
+
325
+ // API: Get team config
326
+ app.get('/api/teams/:name', (req, res) => {
327
+ const config = loadTeamConfig(req.params.name);
328
+ if (!config) return res.status(404).json({ error: 'Team not found' });
329
+ res.json(config);
330
+ });
331
+
332
+ // API: Get all tasks across all sessions
333
+ app.get('/api/tasks/all', async (req, res) => {
334
+ try {
335
+ if (!existsSync(TASKS_DIR)) {
336
+ return res.json([]);
337
+ }
338
+
339
+ const metadata = loadSessionMetadata();
340
+ const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
341
+ .filter(d => d.isDirectory());
342
+
343
+ const allTasks = [];
344
+
345
+ for (const sessionDir of sessionDirs) {
346
+ const sessionPath = path.join(TASKS_DIR, sessionDir.name);
347
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
348
+ const meta = metadata[sessionDir.name] || {};
349
+
350
+ for (const file of taskFiles) {
351
+ try {
352
+ const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
353
+ allTasks.push({
354
+ ...task,
355
+ sessionId: sessionDir.name,
356
+ sessionName: getSessionDisplayName(sessionDir.name, meta),
357
+ project: meta.project || null
358
+ });
359
+ } catch (e) {
360
+ // Skip invalid files
361
+ }
362
+ }
363
+ }
364
+
365
+ res.json(allTasks);
366
+ } catch (error) {
367
+ console.error('Error getting all tasks:', error);
368
+ res.status(500).json({ error: 'Failed to get all tasks' });
369
+ }
370
+ });
371
+
372
+ // API: Add note to a task
373
+ app.post('/api/tasks/:sessionId/:taskId/note', async (req, res) => {
374
+ try {
375
+ const { sessionId, taskId } = req.params;
376
+ const { note } = req.body;
377
+
378
+ if (!note || !note.trim()) {
379
+ return res.status(400).json({ error: 'Note cannot be empty' });
380
+ }
381
+
382
+ const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
383
+
384
+ if (!existsSync(taskPath)) {
385
+ return res.status(404).json({ error: 'Task not found' });
386
+ }
387
+
388
+ // Read current task
389
+ const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
390
+
391
+ // Append note to description
392
+ const noteBlock = `\n\n---\n\n#### [Note added by user]\n\n${note.trim()}`;
393
+ task.description = (task.description || '') + noteBlock;
394
+
395
+ // Write updated task
396
+ await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
397
+
398
+ res.json({ success: true, task });
399
+ } catch (error) {
400
+ console.error('Error adding note:', error);
401
+ res.status(500).json({ error: 'Failed to add note' });
402
+ }
403
+ });
404
+
405
+ // API: Delete a task
406
+ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
407
+ try {
408
+ const { sessionId, taskId } = req.params;
409
+ const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
410
+
411
+ if (!existsSync(taskPath)) {
412
+ return res.status(404).json({ error: 'Task not found' });
413
+ }
414
+
415
+ // Check if this task blocks other tasks
416
+ const sessionPath = path.join(TASKS_DIR, sessionId);
417
+ const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
418
+
419
+ for (const file of taskFiles) {
420
+ const otherTask = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
421
+ if (otherTask.blockedBy && otherTask.blockedBy.includes(taskId)) {
422
+ return res.status(400).json({
423
+ error: 'Cannot delete task that blocks other tasks',
424
+ blockedTasks: [otherTask.id]
425
+ });
426
+ }
427
+ }
428
+
429
+ // Delete the task file
430
+ await fs.unlink(taskPath);
431
+
432
+ res.json({ success: true, taskId });
433
+ } catch (error) {
434
+ console.error('Error deleting task:', error);
435
+ res.status(500).json({ error: 'Failed to delete task' });
436
+ }
437
+ });
438
+
439
+ // SSE endpoint for live updates
440
+ app.get('/api/events', (req, res) => {
441
+ res.setHeader('Content-Type', 'text/event-stream');
442
+ res.setHeader('Cache-Control', 'no-cache');
443
+ res.setHeader('Connection', 'keep-alive');
444
+
445
+ clients.add(res);
446
+
447
+ req.on('close', () => {
448
+ clients.delete(res);
449
+ });
450
+
451
+ // Send initial ping
452
+ res.write('data: {"type":"connected"}\n\n');
453
+ });
454
+
455
+ // Broadcast update to all SSE clients
456
+ function broadcast(data) {
457
+ const message = `data: ${JSON.stringify(data)}\n\n`;
458
+ for (const client of clients) {
459
+ client.write(message);
460
+ }
461
+ }
462
+
463
+ // Watch for file changes (chokidar handles non-existent paths)
464
+ const watcher = chokidar.watch(TASKS_DIR, {
465
+ persistent: true,
466
+ ignoreInitial: true,
467
+ depth: 2
468
+ });
469
+
470
+ watcher.on('all', (event, filePath) => {
471
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
472
+ const relativePath = path.relative(TASKS_DIR, filePath);
473
+ const sessionId = relativePath.split(path.sep)[0];
474
+
475
+ broadcast({
476
+ type: 'update',
477
+ event,
478
+ sessionId,
479
+ file: path.basename(filePath)
480
+ });
481
+ }
482
+ });
483
+
484
+ console.log(`Watching for changes in: ${TASKS_DIR}`);
485
+
486
+ // Watch teams directory for config changes
487
+ const teamsWatcher = chokidar.watch(TEAMS_DIR, {
488
+ persistent: true,
489
+ ignoreInitial: true,
490
+ depth: 3
491
+ });
492
+
493
+ teamsWatcher.on('all', (event, filePath) => {
494
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
495
+ const relativePath = path.relative(TEAMS_DIR, filePath);
496
+ const teamName = relativePath.split(path.sep)[0];
497
+ teamConfigCache.delete(teamName);
498
+ broadcast({ type: 'team-update', teamName });
499
+ }
500
+ });
501
+
502
+ console.log(`Watching for team changes in: ${TEAMS_DIR}`);
503
+
504
+ // Also watch projects dir for metadata changes
505
+ const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
506
+ persistent: true,
507
+ ignoreInitial: true,
508
+ depth: 2
509
+ });
510
+
511
+ projectsWatcher.on('all', (event, filePath) => {
512
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.jsonl')) {
513
+ // Invalidate cache on any change
514
+ lastMetadataRefresh = 0;
515
+ broadcast({ type: 'metadata-update' });
516
+ }
517
+ });
518
+
519
+ // Start server
520
+ app.listen(PORT, () => {
521
+ console.log(`Claude Task Viewer running at http://localhost:${PORT}`);
522
+
523
+ // Open browser if --open flag is passed
524
+ if (process.argv.includes('--open')) {
525
+ import('open').then(open => open.default(`http://localhost:${PORT}`));
526
+ }
527
+ });