claude-code-kanban 2.1.0 → 2.2.0-rc.10

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 CHANGED
@@ -15,7 +15,8 @@ const {
15
15
  readSessionInfoFromJsonl,
16
16
  buildAgentProgressMap,
17
17
  readCompactSummaries,
18
- findTerminatedTeammates
18
+ findTerminatedTeammates,
19
+ extractPromptFromTranscript
19
20
  } = require('./lib/parsers');
20
21
 
21
22
  const isSetupCommand = process.argv.includes('--install') || process.argv.includes('--uninstall');
@@ -61,6 +62,11 @@ const SESSION_STALE_MS = 300000;
61
62
 
62
63
  const WAITING_RESOLVE_GRACE_MS = 15000;
63
64
 
65
+ function persistAgent(dir, agent) {
66
+ const file = path.join(dir, agent.agentId + '.json');
67
+ fs.writeFile(file, JSON.stringify(agent), 'utf8').catch(() => {});
68
+ }
69
+
64
70
  function checkWaitingForUser(agentDir, logMtime) {
65
71
  try {
66
72
  const data = JSON.parse(readFileSync(path.join(agentDir, '_waiting.json'), 'utf8'));
@@ -189,6 +195,9 @@ const terminatedCache = new Map();
189
195
  const compactSummaryCache = new Map();
190
196
  const taskCountsCache = new Map();
191
197
  const contextStatusCache = new Map();
198
+ const TASK_MAPS_DIR = path.join(AGENT_ACTIVITY_DIR, '_task-maps');
199
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
200
+ function isUUID(s) { return UUID_RE.test(s); }
192
201
 
193
202
  function evictStaleCache(cache) {
194
203
  if (cache.size <= MAX_CACHE_ENTRIES) return;
@@ -196,6 +205,62 @@ function evictStaleCache(cache) {
196
205
  if (oldest !== undefined) cache.delete(oldest);
197
206
  }
198
207
 
208
+ let sessionToTaskListCache = null;
209
+ let lastTaskMapScan = 0;
210
+ const TASK_MAP_SCAN_TTL = 5000;
211
+
212
+ function loadAllTaskMaps() {
213
+ const now = Date.now();
214
+ if (sessionToTaskListCache && now - lastTaskMapScan < TASK_MAP_SCAN_TTL) return sessionToTaskListCache;
215
+
216
+ const sessionToList = {};
217
+ const listToSessions = {};
218
+ if (!existsSync(TASK_MAPS_DIR)) {
219
+ sessionToTaskListCache = { sessionToList, listToSessions };
220
+ lastTaskMapScan = now;
221
+ return sessionToTaskListCache;
222
+ }
223
+ try {
224
+ for (const file of readdirSync(TASK_MAPS_DIR).filter(f => f.endsWith('.json'))) {
225
+ const taskListName = file.replace(/\.json$/, '');
226
+ const mapPath = path.join(TASK_MAPS_DIR, file);
227
+ try {
228
+ const map = JSON.parse(readFileSync(mapPath, 'utf8'));
229
+ listToSessions[taskListName] = map;
230
+ for (const sessionId of Object.keys(map)) {
231
+ sessionToList[sessionId] = taskListName;
232
+ }
233
+ } catch (e) { /* skip invalid */ }
234
+ }
235
+ } catch (e) { /* ignore */ }
236
+ sessionToTaskListCache = { sessionToList, listToSessions };
237
+ lastTaskMapScan = now;
238
+ return sessionToTaskListCache;
239
+ }
240
+
241
+ function getCustomTaskDir(sessionId) {
242
+ const { sessionToList } = loadAllTaskMaps();
243
+ const taskListName = sessionToList[sessionId];
244
+ if (taskListName) {
245
+ const dir = path.join(TASKS_DIR, taskListName);
246
+ if (existsSync(dir)) return dir;
247
+ }
248
+ // Check team-named task directory (teams store tasks under ~/.claude/tasks/<teamName>/)
249
+ if (existsSync(TEAMS_DIR)) {
250
+ try {
251
+ for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
252
+ if (!dir.isDirectory()) continue;
253
+ const cfg = loadTeamConfig(dir.name);
254
+ if (cfg?.leadSessionId === sessionId) {
255
+ const teamTaskDir = path.join(TASKS_DIR, dir.name);
256
+ if (existsSync(teamTaskDir)) return teamTaskDir;
257
+ }
258
+ }
259
+ } catch (_) {}
260
+ }
261
+ return null;
262
+ }
263
+
199
264
  function getTaskCounts(sessionPath) {
200
265
  const cached = taskCountsCache.get(sessionPath);
201
266
  if (cached) return cached;
@@ -449,7 +514,7 @@ app.get('/api/sessions', async (req, res) => {
449
514
  const entries = readdirSync(TASKS_DIR, { withFileTypes: true });
450
515
 
451
516
  for (const entry of entries) {
452
- if (entry.isDirectory()) {
517
+ if (entry.isDirectory() && isUUID(entry.name)) {
453
518
  const sessionPath = path.join(TASKS_DIR, entry.name);
454
519
  const stat = statSync(sessionPath);
455
520
  const { taskCount, completed, inProgress, pending, newestTaskMtime } = getTaskCounts(sessionPath);
@@ -497,6 +562,55 @@ app.get('/api/sessions', async (req, res) => {
497
562
  }));
498
563
  }
499
564
  }
565
+
566
+ // Process custom task lists (non-UUID directories mapped via _task-maps)
567
+ const { listToSessions } = loadAllTaskMaps();
568
+ for (const [taskListName, map] of Object.entries(listToSessions)) {
569
+ const customTaskDir = path.join(TASKS_DIR, taskListName);
570
+ if (!existsSync(customTaskDir)) continue;
571
+ const counts = getTaskCounts(customTaskDir);
572
+
573
+ for (const [sessionId, info] of Object.entries(map)) {
574
+ const existing = sessionsMap.get(sessionId);
575
+ if (existing) {
576
+ Object.assign(existing, {
577
+ taskCount: counts.taskCount,
578
+ completed: counts.completed,
579
+ inProgress: counts.inProgress,
580
+ pending: counts.pending,
581
+ tasksDir: customTaskDir,
582
+ sharedTaskList: taskListName,
583
+ });
584
+ } else {
585
+ const meta = { ...(metadata[sessionId] || {}) };
586
+ if (!meta.project && info.project) meta.project = info.project;
587
+ const logStat = getSessionLogStat(meta);
588
+ const logMtime = logStat.mtime;
589
+ const logAge = logMtime ? Date.now() - logMtime : Infinity;
590
+ const stale = logAge > AGENT_STALE_MS;
591
+ const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
592
+ const agentStatus = checkAgentStatus(agentDir, stale, logMtime, false);
593
+ let modifiedAt = info.updatedAt || new Date(0).toISOString();
594
+ if (logMtime) {
595
+ const jsonlMtime = new Date(logMtime).toISOString();
596
+ if (jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
597
+ }
598
+ sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
599
+ _logStat: logStat,
600
+ taskCount: counts.taskCount,
601
+ completed: counts.completed,
602
+ inProgress: counts.inProgress,
603
+ pending: counts.pending,
604
+ modifiedAt,
605
+ hasActiveAgents: agentStatus.hasActive,
606
+ hasRunningAgents: agentStatus.hasRunning,
607
+ hasWaitingForUser: !!agentStatus.waitingForUser,
608
+ tasksDir: customTaskDir,
609
+ sharedTaskList: taskListName,
610
+ }));
611
+ }
612
+ }
613
+ }
500
614
  }
501
615
 
502
616
  // Add sessions from metadata that don't have task directories
@@ -544,18 +658,45 @@ app.get('/api/sessions', async (req, res) => {
544
658
  } catch (e) { /* ignore */ }
545
659
  }
546
660
 
547
- // Hide leader UUID sessions that are represented by a team session
661
+ // Enrich leader sessions with team info and remove team-named duplicates
548
662
  const teamLeaderIds = new Set();
549
- for (const [sid, session] of sessionsMap) {
550
- if (session.isTeam) {
551
- const cfg = loadTeamConfig(sid);
552
- if (cfg?.leadSessionId) teamLeaderIds.add(cfg.leadSessionId);
553
- }
554
- }
555
- for (const leaderId of teamLeaderIds) {
556
- if (sessionsMap.has(leaderId)) {
557
- sessionsMap.delete(leaderId);
558
- }
663
+ if (existsSync(TEAMS_DIR)) {
664
+ try {
665
+ for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
666
+ if (!dir.isDirectory()) continue;
667
+ // Remove team-named duplicate session (e.g., "code-review") — leader is the canonical entry
668
+ if (sessionsMap.has(dir.name)) sessionsMap.delete(dir.name);
669
+ const cfg = loadTeamConfig(dir.name);
670
+ if (!cfg?.leadSessionId) continue;
671
+ const leaderId = cfg.leadSessionId;
672
+ const existing = sessionsMap.get(leaderId);
673
+ if (existing) {
674
+ existing.isTeam = true;
675
+ existing.teamName = dir.name;
676
+ existing.memberCount = cfg.members?.length || 0;
677
+ existing.name = existing.name || cfg.name || dir.name;
678
+ teamLeaderIds.add(leaderId);
679
+ // Attach team-named task directory if present
680
+ const teamTaskDir = path.join(TASKS_DIR, dir.name);
681
+ if (!existing.tasksDir && existsSync(teamTaskDir)) {
682
+ const counts = getTaskCounts(teamTaskDir);
683
+ existing.taskCount = counts.taskCount;
684
+ existing.completed = counts.completed;
685
+ existing.inProgress = counts.inProgress;
686
+ existing.pending = counts.pending;
687
+ existing.tasksDir = teamTaskDir;
688
+ existing.sharedTaskList = dir.name;
689
+ }
690
+ // Re-check agent status with isTeam=true
691
+ const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
692
+ const logStat = getSessionLogStat(metadata[leaderId] || {});
693
+ const logAge = logStat.mtime ? Date.now() - logStat.mtime : Infinity;
694
+ const agentStatus = checkAgentStatus(agentDir, logAge > AGENT_STALE_MS, logStat.mtime, true);
695
+ existing.hasActiveAgents = agentStatus.hasActive;
696
+ existing.hasRunningAgents = agentStatus.hasRunning;
697
+ }
698
+ }
699
+ } catch (_) {}
559
700
  }
560
701
 
561
702
  // Correlate plan sessions with their implementation sessions (same slug)
@@ -645,7 +786,8 @@ app.get('/api/projects', (req, res) => {
645
786
  // API: Get tasks for a session
646
787
  app.get('/api/sessions/:sessionId', async (req, res) => {
647
788
  try {
648
- const sessionPath = path.join(TASKS_DIR, req.params.sessionId);
789
+ const customDir = getCustomTaskDir(req.params.sessionId);
790
+ const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
649
791
 
650
792
  if (!existsSync(sessionPath)) {
651
793
  return res.status(404).json({ error: 'Session not found' });
@@ -673,6 +815,52 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
673
815
  }
674
816
  });
675
817
 
818
+ // API: Get combined tasks for a project (all sessions + shared task lists)
819
+ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
820
+ try {
821
+ const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
822
+ const metadata = loadSessionMetadata();
823
+ const { sessionToList } = loadAllTaskMaps();
824
+
825
+ const projectSessionIds = Object.entries(metadata)
826
+ .filter(([, m]) => m.project === projectPath)
827
+ .map(([id]) => id);
828
+
829
+ const taskDirs = new Set();
830
+ for (const sid of projectSessionIds) {
831
+ const listName = sessionToList[sid];
832
+ if (listName) {
833
+ const dir = path.join(TASKS_DIR, listName);
834
+ if (existsSync(dir)) taskDirs.add(dir);
835
+ } else {
836
+ const dir = path.join(TASKS_DIR, sid);
837
+ if (existsSync(dir)) taskDirs.add(dir);
838
+ }
839
+ }
840
+
841
+ const tasks = [];
842
+ const seenKeys = new Set();
843
+ for (const dir of taskDirs) {
844
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.json'))) {
845
+ try {
846
+ const task = JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
847
+ const key = `${dir}:${task.id}`;
848
+ if (!seenKeys.has(key)) {
849
+ seenKeys.add(key);
850
+ task._taskDir = path.basename(dir);
851
+ tasks.push(task);
852
+ }
853
+ } catch (_) {}
854
+ }
855
+ }
856
+ tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
857
+ res.json(tasks);
858
+ } catch (error) {
859
+ console.error('Error getting project tasks:', error);
860
+ res.status(500).json({ error: 'Failed to get project tasks' });
861
+ }
862
+ });
863
+
676
864
  // API: Get session plan
677
865
  app.get('/api/sessions/:sessionId/plan', async (req, res) => {
678
866
  try {
@@ -813,31 +1001,54 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
813
1001
  if (terminatedAt && agent.startedAt && terminatedAt < agent.startedAt) continue;
814
1002
  agent.status = 'stopped';
815
1003
  agent.stoppedAt = agent.stoppedAt || new Date().toISOString();
816
- const agentFile = path.join(agentDir, agent.agentId + '.json');
817
- fs.writeFile(agentFile, JSON.stringify(agent), 'utf8').catch(() => {});
1004
+ persistAgent(agentDir, agent);
818
1005
  }
819
1006
  }
820
1007
  }
821
1008
  } catch (_) {}
822
1009
  }
823
1010
 
1011
+ function persistPrompt(agent, prompt) {
1012
+ agent.prompt = prompt;
1013
+ persistAgent(agentDir, agent);
1014
+ }
1015
+
824
1016
  const agentsNeedingPrompt = agents.filter(a => !a.prompt);
825
1017
  if (agentsNeedingPrompt.length && meta.jsonlPath) {
1018
+ let byAgentId = {};
826
1019
  try {
827
1020
  const progressMap = getProgressMap(meta.jsonlPath);
828
- const byAgentId = {};
829
1021
  for (const entry of Object.values(progressMap)) {
830
1022
  if (entry.prompt && !byAgentId[entry.agentId]) byAgentId[entry.agentId] = entry.prompt;
831
1023
  }
832
- for (const agent of agentsNeedingPrompt) {
833
- const prompt = byAgentId[agent.agentId];
834
- if (prompt) {
835
- agent.prompt = prompt;
836
- const agentFile = path.join(agentDir, agent.agentId + '.json');
837
- fs.writeFile(agentFile, JSON.stringify(agent), 'utf8').catch(() => {});
838
- }
839
- }
840
1024
  } catch (_) {}
1025
+ for (const agent of agentsNeedingPrompt) {
1026
+ const prompt = byAgentId[agent.agentId]
1027
+ || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1028
+ if (prompt) persistPrompt(agent, prompt);
1029
+ }
1030
+ }
1031
+
1032
+ const agentsNeedingModel = agents.filter(a => !a.model);
1033
+ if (agentsNeedingModel.length && meta.jsonlPath) {
1034
+ for (const agent of agentsNeedingModel) {
1035
+ try {
1036
+ const jsonl = subagentJsonlPath(meta, agent.agentId);
1037
+ const content = readFileSync(jsonl, 'utf8');
1038
+ for (const line of content.split('\n')) {
1039
+ if (!line.trim()) continue;
1040
+ try {
1041
+ const obj = JSON.parse(line);
1042
+ const model = obj.model || (obj.message && obj.message.model);
1043
+ if (model) {
1044
+ agent.model = model;
1045
+ persistAgent(agentDir, agent);
1046
+ break;
1047
+ }
1048
+ } catch (_) {}
1049
+ }
1050
+ } catch (_) {}
1051
+ }
841
1052
  }
842
1053
  const teamColors = {};
843
1054
  if (teamConfig?.members) {
@@ -868,7 +1079,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
868
1079
  const agent = JSON.parse(readFileSync(agentFile, 'utf8'));
869
1080
  agent.status = 'stopped';
870
1081
  agent.stoppedAt = new Date().toISOString();
871
- writeFileSync(agentFile, JSON.stringify(agent), 'utf8');
1082
+ writeFileSync(agentFile, JSON.stringify(agent), 'utf8'); // sync — response depends on write
872
1083
  // Also remove waiting state if present
873
1084
  const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
874
1085
  if (existsSync(waitingFile)) unlinkSync(waitingFile);
@@ -980,7 +1191,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
980
1191
  const prompt = msg.agentPrompt || entry.prompt;
981
1192
  if (prompt && !agent.prompt) {
982
1193
  agent.prompt = prompt;
983
- fs.writeFile(agentFile, JSON.stringify(agent), 'utf8').catch(() => {});
1194
+ persistAgent(agentDir, agent);
984
1195
  }
985
1196
  } catch (_) {}
986
1197
  }
@@ -1066,7 +1277,8 @@ app.post('/api/tasks/:sessionId/:taskId/note', async (req, res) => {
1066
1277
  return res.status(400).json({ error: 'Note cannot be empty' });
1067
1278
  }
1068
1279
 
1069
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1280
+ const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1281
+ const taskPath = path.join(sessionDir, `${taskId}.json`);
1070
1282
 
1071
1283
  if (!existsSync(taskPath)) {
1072
1284
  return res.status(404).json({ error: 'Task not found' });
@@ -1095,7 +1307,8 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
1095
1307
  const { sessionId, taskId } = req.params;
1096
1308
  const { subject, description } = req.body;
1097
1309
 
1098
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1310
+ const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1311
+ const taskPath = path.join(sessionDir, `${taskId}.json`);
1099
1312
 
1100
1313
  if (!existsSync(taskPath)) {
1101
1314
  return res.status(404).json({ error: 'Task not found' });
@@ -1120,14 +1333,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
1120
1333
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
1121
1334
  try {
1122
1335
  const { sessionId, taskId } = req.params;
1123
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1336
+ const sessionPath = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1337
+ const taskPath = path.join(sessionPath, `${taskId}.json`);
1124
1338
 
1125
1339
  if (!existsSync(taskPath)) {
1126
1340
  return res.status(404).json({ error: 'Task not found' });
1127
1341
  }
1128
1342
 
1129
1343
  // Check if this task blocks other tasks
1130
- const sessionPath = path.join(TASKS_DIR, sessionId);
1131
1344
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
1132
1345
 
1133
1346
  for (const file of taskFiles) {
@@ -1200,21 +1413,44 @@ const watcher = chokidar.watch(TASKS_DIR, {
1200
1413
  watcher.on('all', (event, filePath) => {
1201
1414
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1202
1415
  const relativePath = path.relative(TASKS_DIR, filePath);
1203
- const sessionId = relativePath.split(path.sep)[0];
1416
+ const dirName = relativePath.split(path.sep)[0];
1204
1417
 
1205
- taskCountsCache.delete(path.join(TASKS_DIR, sessionId));
1418
+ taskCountsCache.delete(path.join(TASKS_DIR, dirName));
1206
1419
 
1207
- broadcast({
1208
- type: 'update',
1209
- event,
1210
- sessionId,
1211
- file: path.basename(filePath)
1212
- });
1420
+ if (isUUID(dirName)) {
1421
+ broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
1422
+ } else {
1423
+ broadcastToMappedSessions(dirName, event, filePath);
1424
+ }
1213
1425
  }
1214
1426
  });
1215
1427
 
1428
+ function broadcastToMappedSessions(taskListName, event, filePath) {
1429
+ const { listToSessions } = loadAllTaskMaps();
1430
+ const map = listToSessions[taskListName];
1431
+ if (!map) return;
1432
+ for (const sid of Object.keys(map)) {
1433
+ broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
1434
+ }
1435
+ }
1436
+
1216
1437
  console.log(`Watching for changes in: ${TASKS_DIR}`);
1217
1438
 
1439
+ // Watch task maps directory for session→task-list mapping changes
1440
+ const taskMapsWatcher = chokidar.watch(TASK_MAPS_DIR, {
1441
+ persistent: true,
1442
+ ignoreInitial: true,
1443
+ depth: 1
1444
+ });
1445
+ taskMapsWatcher.on('all', (event, filePath) => {
1446
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1447
+ lastTaskMapScan = 0;
1448
+ const taskListName = path.basename(filePath, '.json');
1449
+ taskCountsCache.delete(path.join(TASKS_DIR, taskListName));
1450
+ broadcastToMappedSessions(taskListName, event, filePath);
1451
+ }
1452
+ });
1453
+
1218
1454
  // Watch teams directory for config changes
1219
1455
  const teamsWatcher = chokidar.watch(TEAMS_DIR, {
1220
1456
  persistent: true,
@@ -1269,7 +1505,8 @@ plansWatcher.on('all', (event, filePath) => {
1269
1505
  const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
1270
1506
  persistent: true,
1271
1507
  ignoreInitial: true,
1272
- depth: 2
1508
+ depth: 2,
1509
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
1273
1510
  });
1274
1511
 
1275
1512
  const AGENT_FILE_CAP = 20;