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

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.
@@ -16,6 +16,24 @@ eval "$(echo "$INPUT" | jq -r '
16
16
 
17
17
  [ -z "$SESSION_ID" ] && exit 0
18
18
 
19
+ # Map session to custom task list on session start
20
+ if [ "$EVENT" = "SessionStart" ]; then
21
+ TASK_LIST_ID="${CLAUDE_CODE_TASK_LIST_ID:-}"
22
+ if [ -n "$TASK_LIST_ID" ]; then
23
+ CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
24
+ MAPS_DIR="$HOME/.claude/agent-activity/_task-maps"
25
+ mkdir -p "$MAPS_DIR"
26
+ MAP_FILE="$MAPS_DIR/$TASK_LIST_ID.json"
27
+ TMP_FILE="$MAP_FILE.$$"
28
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
29
+ EXISTING="{}"
30
+ [ -f "$MAP_FILE" ] && EXISTING=$(cat "$MAP_FILE")
31
+ echo "$EXISTING" | jq -c --arg sid "$SESSION_ID" --arg cwd "$CWD" --arg ts "$TS" \
32
+ '.[$sid] = {project: $cwd, updatedAt: $ts}' > "$TMP_FILE" && mv "$TMP_FILE" "$MAP_FILE"
33
+ fi
34
+ exit 0
35
+ fi
36
+
19
37
  # PostToolUse / non-waiting PreToolUse: clear waiting state
20
38
  if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ]; }; then
21
39
  WFILE="$HOME/.claude/agent-activity/$SESSION_ID/_waiting.json"
package/install.js CHANGED
@@ -17,6 +17,7 @@ const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
17
17
 
18
18
  const HOOK_COMMAND = '~/.claude/hooks/agent-spy.sh';
19
19
  const HOOK_EVENTS = [
20
+ { event: 'SessionStart' },
20
21
  { event: 'SubagentStart' },
21
22
  { event: 'SubagentStop' },
22
23
  { event: 'TeammateIdle' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-rc.1",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1784,6 +1784,7 @@ function renderSessions() {
1784
1784
  <div class="session-progress">
1785
1785
  <span class="session-indicators">
1786
1786
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
1787
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>` : ''}
1787
1788
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
1788
1789
  ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
1789
1790
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
@@ -3804,6 +3805,9 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3804
3805
  if (session.tasksDir) {
3805
3806
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
3806
3807
  }
3808
+ if (session.sharedTaskList) {
3809
+ infoRows.push(['Shared Tasks', session.sharedTaskList]);
3810
+ }
3807
3811
  if (teamConfig?.configPath) {
3808
3812
  const configDir = teamConfig.configPath.replace(/[/\\][^/\\]+$/, '');
3809
3813
  infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
package/public/style.css CHANGED
@@ -1578,6 +1578,13 @@ body::before {
1578
1578
  font-weight: 600;
1579
1579
  }
1580
1580
 
1581
+ .shared-tasklist-badge {
1582
+ display: inline-flex;
1583
+ align-items: center;
1584
+ color: var(--accent);
1585
+ flex-shrink: 0;
1586
+ }
1587
+
1581
1588
  .team-info-btn {
1582
1589
  width: 24px;
1583
1590
  height: 24px;
package/server.js CHANGED
@@ -189,6 +189,9 @@ const terminatedCache = new Map();
189
189
  const compactSummaryCache = new Map();
190
190
  const taskCountsCache = new Map();
191
191
  const contextStatusCache = new Map();
192
+ const TASK_MAPS_DIR = path.join(AGENT_ACTIVITY_DIR, '_task-maps');
193
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
194
+ function isUUID(s) { return UUID_RE.test(s); }
192
195
 
193
196
  function evictStaleCache(cache) {
194
197
  if (cache.size <= MAX_CACHE_ENTRIES) return;
@@ -196,6 +199,47 @@ function evictStaleCache(cache) {
196
199
  if (oldest !== undefined) cache.delete(oldest);
197
200
  }
198
201
 
202
+ let sessionToTaskListCache = null;
203
+ let lastTaskMapScan = 0;
204
+ const TASK_MAP_SCAN_TTL = 5000;
205
+
206
+ function loadAllTaskMaps() {
207
+ const now = Date.now();
208
+ if (sessionToTaskListCache && now - lastTaskMapScan < TASK_MAP_SCAN_TTL) return sessionToTaskListCache;
209
+
210
+ const sessionToList = {};
211
+ const listToSessions = {};
212
+ if (!existsSync(TASK_MAPS_DIR)) {
213
+ sessionToTaskListCache = { sessionToList, listToSessions };
214
+ lastTaskMapScan = now;
215
+ return sessionToTaskListCache;
216
+ }
217
+ try {
218
+ for (const file of readdirSync(TASK_MAPS_DIR).filter(f => f.endsWith('.json'))) {
219
+ const taskListName = file.replace(/\.json$/, '');
220
+ const mapPath = path.join(TASK_MAPS_DIR, file);
221
+ try {
222
+ const map = JSON.parse(readFileSync(mapPath, 'utf8'));
223
+ listToSessions[taskListName] = map;
224
+ for (const sessionId of Object.keys(map)) {
225
+ sessionToList[sessionId] = taskListName;
226
+ }
227
+ } catch (e) { /* skip invalid */ }
228
+ }
229
+ } catch (e) { /* ignore */ }
230
+ sessionToTaskListCache = { sessionToList, listToSessions };
231
+ lastTaskMapScan = now;
232
+ return sessionToTaskListCache;
233
+ }
234
+
235
+ function getCustomTaskDir(sessionId) {
236
+ const { sessionToList } = loadAllTaskMaps();
237
+ const taskListName = sessionToList[sessionId];
238
+ if (!taskListName) return null;
239
+ const dir = path.join(TASKS_DIR, taskListName);
240
+ return existsSync(dir) ? dir : null;
241
+ }
242
+
199
243
  function getTaskCounts(sessionPath) {
200
244
  const cached = taskCountsCache.get(sessionPath);
201
245
  if (cached) return cached;
@@ -449,7 +493,7 @@ app.get('/api/sessions', async (req, res) => {
449
493
  const entries = readdirSync(TASKS_DIR, { withFileTypes: true });
450
494
 
451
495
  for (const entry of entries) {
452
- if (entry.isDirectory()) {
496
+ if (entry.isDirectory() && isUUID(entry.name)) {
453
497
  const sessionPath = path.join(TASKS_DIR, entry.name);
454
498
  const stat = statSync(sessionPath);
455
499
  const { taskCount, completed, inProgress, pending, newestTaskMtime } = getTaskCounts(sessionPath);
@@ -497,6 +541,44 @@ app.get('/api/sessions', async (req, res) => {
497
541
  }));
498
542
  }
499
543
  }
544
+
545
+ // Process custom task lists (non-UUID directories mapped via _task-maps)
546
+ const { listToSessions } = loadAllTaskMaps();
547
+ for (const [taskListName, map] of Object.entries(listToSessions)) {
548
+ const customTaskDir = path.join(TASKS_DIR, taskListName);
549
+ if (!existsSync(customTaskDir)) continue;
550
+ const counts = getTaskCounts(customTaskDir);
551
+
552
+ for (const [sessionId, info] of Object.entries(map)) {
553
+ const existing = sessionsMap.get(sessionId);
554
+ if (existing) {
555
+ Object.assign(existing, {
556
+ taskCount: counts.taskCount,
557
+ completed: counts.completed,
558
+ inProgress: counts.inProgress,
559
+ pending: counts.pending,
560
+ tasksDir: customTaskDir,
561
+ sharedTaskList: taskListName,
562
+ });
563
+ if (counts.newestTaskMtime) {
564
+ const taskMtime = counts.newestTaskMtime.toISOString();
565
+ if (taskMtime > existing.modifiedAt) existing.modifiedAt = taskMtime;
566
+ }
567
+ } else {
568
+ const meta = { ...(metadata[sessionId] || {}) };
569
+ if (!meta.project && info.project) meta.project = info.project;
570
+ sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
571
+ taskCount: counts.taskCount,
572
+ completed: counts.completed,
573
+ inProgress: counts.inProgress,
574
+ pending: counts.pending,
575
+ modifiedAt: counts.newestTaskMtime ? counts.newestTaskMtime.toISOString() : (info.updatedAt || new Date(0).toISOString()),
576
+ tasksDir: customTaskDir,
577
+ sharedTaskList: taskListName,
578
+ }));
579
+ }
580
+ }
581
+ }
500
582
  }
501
583
 
502
584
  // Add sessions from metadata that don't have task directories
@@ -645,7 +727,8 @@ app.get('/api/projects', (req, res) => {
645
727
  // API: Get tasks for a session
646
728
  app.get('/api/sessions/:sessionId', async (req, res) => {
647
729
  try {
648
- const sessionPath = path.join(TASKS_DIR, req.params.sessionId);
730
+ const customDir = getCustomTaskDir(req.params.sessionId);
731
+ const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
649
732
 
650
733
  if (!existsSync(sessionPath)) {
651
734
  return res.status(404).json({ error: 'Session not found' });
@@ -1200,21 +1283,44 @@ const watcher = chokidar.watch(TASKS_DIR, {
1200
1283
  watcher.on('all', (event, filePath) => {
1201
1284
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1202
1285
  const relativePath = path.relative(TASKS_DIR, filePath);
1203
- const sessionId = relativePath.split(path.sep)[0];
1286
+ const dirName = relativePath.split(path.sep)[0];
1204
1287
 
1205
- taskCountsCache.delete(path.join(TASKS_DIR, sessionId));
1288
+ taskCountsCache.delete(path.join(TASKS_DIR, dirName));
1206
1289
 
1207
- broadcast({
1208
- type: 'update',
1209
- event,
1210
- sessionId,
1211
- file: path.basename(filePath)
1212
- });
1290
+ if (isUUID(dirName)) {
1291
+ broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
1292
+ } else {
1293
+ broadcastToMappedSessions(dirName, event, filePath);
1294
+ }
1213
1295
  }
1214
1296
  });
1215
1297
 
1298
+ function broadcastToMappedSessions(taskListName, event, filePath) {
1299
+ const { listToSessions } = loadAllTaskMaps();
1300
+ const map = listToSessions[taskListName];
1301
+ if (!map) return;
1302
+ for (const sid of Object.keys(map)) {
1303
+ broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
1304
+ }
1305
+ }
1306
+
1216
1307
  console.log(`Watching for changes in: ${TASKS_DIR}`);
1217
1308
 
1309
+ // Watch task maps directory for session→task-list mapping changes
1310
+ const taskMapsWatcher = chokidar.watch(TASK_MAPS_DIR, {
1311
+ persistent: true,
1312
+ ignoreInitial: true,
1313
+ depth: 1
1314
+ });
1315
+ taskMapsWatcher.on('all', (event, filePath) => {
1316
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1317
+ lastTaskMapScan = 0;
1318
+ const taskListName = path.basename(filePath, '.json');
1319
+ taskCountsCache.delete(path.join(TASKS_DIR, taskListName));
1320
+ broadcastToMappedSessions(taskListName, event, filePath);
1321
+ }
1322
+ });
1323
+
1218
1324
  // Watch teams directory for config changes
1219
1325
  const teamsWatcher = chokidar.watch(TEAMS_DIR, {
1220
1326
  persistent: true,