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/LICENSE +21 -0
- package/README.md +144 -0
- package/package.json +45 -0
- package/public/index.html +3130 -0
- package/server.js +527 -0
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
|
+
});
|