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

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/lib/parsers.js CHANGED
@@ -613,6 +613,41 @@ function findTerminatedTeammates(jsonlPath) {
613
613
  return terminated;
614
614
  }
615
615
 
616
+ function extractPromptFromTranscript(jsonlPath) {
617
+ const { openSync, readSync, closeSync } = fs;
618
+ const MAX_READ = 65536;
619
+ const CHUNK = 4096;
620
+ const fd = openSync(jsonlPath, 'r');
621
+ try {
622
+ let accumulated = '';
623
+ const buf = Buffer.alloc(CHUNK);
624
+ while (accumulated.length < MAX_READ) {
625
+ const bytesRead = readSync(fd, buf, 0, CHUNK, null);
626
+ if (bytesRead === 0) break;
627
+ accumulated += buf.toString('utf8', 0, bytesRead);
628
+ const nlIdx = accumulated.indexOf('\n');
629
+ if (nlIdx === -1) continue;
630
+ const firstLine = accumulated.slice(0, nlIdx);
631
+ try {
632
+ const obj = JSON.parse(firstLine);
633
+ if (obj.type === 'user') {
634
+ const content = obj.message?.content;
635
+ if (typeof content === 'string') return content.slice(0, 500);
636
+ if (Array.isArray(content)) {
637
+ for (const b of content) {
638
+ if (b.type === 'text' && b.text) return b.text.slice(0, 500);
639
+ }
640
+ }
641
+ }
642
+ } catch (_) {}
643
+ break;
644
+ }
645
+ } finally {
646
+ closeSync(fd);
647
+ }
648
+ return null;
649
+ }
650
+
616
651
  module.exports = {
617
652
  parseTask,
618
653
  parseAgent,
@@ -624,5 +659,6 @@ module.exports = {
624
659
  readRecentMessages,
625
660
  buildAgentProgressMap,
626
661
  readCompactSummaries,
627
- findTerminatedTeammates
662
+ findTerminatedTeammates,
663
+ extractPromptFromTranscript
628
664
  };
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.2",
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
@@ -29,6 +29,8 @@ let selectedSessionKbId = null;
29
29
  let sessionJustSelected = false;
30
30
  let agentLogMode = null;
31
31
  let agentLogSSE = null;
32
+ let currentProjectPath = null;
33
+ let currentProjectSessionIds = [];
32
34
 
33
35
  function getUrlState() {
34
36
  const params = new URLSearchParams(window.location.search);
@@ -41,12 +43,14 @@ function getUrlState() {
41
43
  owner: params.get('owner'),
42
44
  search: params.get('search'),
43
45
  messages: params.get('messages') === '1',
46
+ projectView: params.get('projectView'),
44
47
  };
45
48
  }
46
49
 
47
50
  function updateUrl() {
48
51
  const params = new URLSearchParams();
49
52
  if (viewMode === 'all') params.set('view', 'all');
53
+ if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
50
54
  if (currentSessionId) params.set('session', currentSessionId);
51
55
  if (sessionFilter !== 'active') params.set('filter', sessionFilter);
52
56
  if (sessionLimit !== '20') params.set('limit', sessionLimit);
@@ -70,6 +74,8 @@ function resetState() {
70
74
  viewMode = 'all';
71
75
  if (agentLogMode) exitAgentLogMode();
72
76
  currentSessionId = null;
77
+ currentProjectPath = null;
78
+ currentProjectSessionIds = [];
73
79
  const searchInput = document.getElementById('search-input');
74
80
  if (searchInput) searchInput.value = '';
75
81
  document.getElementById('search-clear-btn')?.classList.remove('visible');
@@ -410,6 +416,7 @@ let lastCurrentTasksHash = '';
410
416
  async function fetchTasks(sessionId) {
411
417
  try {
412
418
  viewMode = 'session';
419
+ document.getElementById('message-toggle')?.style.removeProperty('display');
413
420
  const res = await fetch(`/api/sessions/${sessionId}`);
414
421
 
415
422
  let newTasks;
@@ -483,6 +490,98 @@ async function fetchAgents(sessionId) {
483
490
  }
484
491
  }
485
492
 
493
+ async function fetchProjectView(projectPath) {
494
+ viewMode = 'project';
495
+ currentProjectPath = projectPath;
496
+ currentSessionId = null;
497
+ currentMessages = [];
498
+ lastMessagesHash = '';
499
+ if (messagePanelOpen) toggleMessagePanel();
500
+ document.getElementById('message-toggle')?.style.setProperty('display', 'none');
501
+ const msgContent = document.getElementById('message-panel-content');
502
+ if (msgContent) msgContent.innerHTML = '';
503
+ const msgPinned = document.getElementById('message-panel-pinned');
504
+ if (msgPinned) msgPinned.innerHTML = '';
505
+ const projectSessions = sessions.filter((s) => s.project === projectPath);
506
+ currentProjectSessionIds = projectSessions.map((s) => s.id);
507
+ const activeSessionIds = projectSessions.filter(isSessionActive).map((s) => s.id);
508
+
509
+ const encoded = btoa(projectPath);
510
+ const [tasksResult, agentResults] = await Promise.all([
511
+ fetch(`/api/projects/${encodeURIComponent(encoded)}/tasks`)
512
+ .then((r) => r.json())
513
+ .catch((e) => {
514
+ console.error('[fetchProjectView] tasks:', e);
515
+ return [];
516
+ }),
517
+ Promise.all(
518
+ activeSessionIds.map((id) =>
519
+ fetch(`/api/sessions/${id}/agents`)
520
+ .then((r) => r.json())
521
+ .catch(() => ({ agents: [] })),
522
+ ),
523
+ ),
524
+ ]);
525
+ currentTasks = tasksResult;
526
+ const seen = new Set();
527
+ currentAgents = [];
528
+ const mergedColors = {};
529
+ let mergedWaiting = null;
530
+ for (let i = 0; i < agentResults.length; i++) {
531
+ const r = agentResults[i];
532
+ const sid = activeSessionIds[i];
533
+ const agents = r.agents || (Array.isArray(r) ? r : []);
534
+ for (const a of agents) {
535
+ if (a.agentId && !seen.has(a.agentId)) {
536
+ seen.add(a.agentId);
537
+ a._sourceSessionId = sid;
538
+ currentAgents.push(a);
539
+ }
540
+ }
541
+ if (r.teamColors) Object.assign(mergedColors, r.teamColors);
542
+ if (r.waitingForUser && !mergedWaiting) mergedWaiting = r.waitingForUser;
543
+ }
544
+ currentWaiting = mergedWaiting;
545
+ Object.assign(teamColorMap, mergedColors);
546
+
547
+ renderProjectView();
548
+ renderAgentFooter();
549
+ renderKanban();
550
+ updateUrl();
551
+ }
552
+
553
+ async function refreshProjectAgents() {
554
+ if (!currentProjectPath) return;
555
+ const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
556
+ const activeSessionIds = projectSessions.filter(isSessionActive).map((s) => s.id);
557
+ const agentResults = await Promise.all(
558
+ activeSessionIds.map((id) =>
559
+ fetch(`/api/sessions/${id}/agents`)
560
+ .then((r) => r.json())
561
+ .catch(() => ({ agents: [] })),
562
+ ),
563
+ );
564
+ const seen = new Set();
565
+ currentAgents = [];
566
+ let mergedWaiting = null;
567
+ for (let i = 0; i < agentResults.length; i++) {
568
+ const r = agentResults[i];
569
+ const sid = activeSessionIds[i];
570
+ const agents = r.agents || (Array.isArray(r) ? r : []);
571
+ for (const a of agents) {
572
+ if (a.agentId && !seen.has(a.agentId)) {
573
+ seen.add(a.agentId);
574
+ a._sourceSessionId = sid;
575
+ currentAgents.push(a);
576
+ }
577
+ }
578
+ if (r.teamColors) Object.assign(teamColorMap, r.teamColors);
579
+ if (r.waitingForUser && !mergedWaiting) mergedWaiting = r.waitingForUser;
580
+ }
581
+ currentWaiting = mergedWaiting;
582
+ renderAgentFooter();
583
+ }
584
+
486
585
  //#endregion
487
586
 
488
587
  //#region MESSAGE_PANEL
@@ -507,8 +606,10 @@ async function viewAgentLog(agentId) {
507
606
  }
508
607
  if (!agent) return;
509
608
  const shortId = agentId.length > 8 ? agentId.slice(0, 8) : agentId;
510
- agentLogMode = { agentId, sessionId: currentSessionId, agentType: agent.type || 'unknown' };
609
+ const agentSessionId = agent._sourceSessionId || currentSessionId;
610
+ agentLogMode = { agentId, sessionId: agentSessionId, agentType: agent.type || 'unknown' };
511
611
  closeAgentModal();
612
+ document.getElementById('message-toggle')?.style.removeProperty('display');
512
613
  if (!messagePanelOpen) toggleMessagePanel();
513
614
  const header = document.querySelector('.message-panel-header h3');
514
615
  if (header) {
@@ -537,6 +638,11 @@ function exitAgentLogMode() {
537
638
  agentLogSSE.close();
538
639
  agentLogSSE = null;
539
640
  }
641
+ if (viewMode === 'project') {
642
+ if (messagePanelOpen) toggleMessagePanel();
643
+ document.getElementById('message-toggle')?.style.setProperty('display', 'none');
644
+ return;
645
+ }
540
646
  const header = document.querySelector('.message-panel-header h3');
541
647
  if (header) header.textContent = 'Session Log';
542
648
  lastMessagesHash = '';
@@ -1784,6 +1890,7 @@ function renderSessions() {
1784
1890
  <div class="session-progress">
1785
1891
  <span class="session-indicators">
1786
1892
  ${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>` : ''}
1893
+ ${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
1894
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
1788
1895
  ${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
1896
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
@@ -1850,8 +1957,8 @@ function renderSessions() {
1850
1957
  <svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1851
1958
  <span class="group-name">${escapeHtml(folderName)}</span>
1852
1959
  <span class="group-count">${projectSessions.length}</span>
1853
- <span class="group-path-toggle" data-group-action="toggle-path" title="Show full path">
1854
- <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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
1960
+ <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
1961
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
1855
1962
  </span>
1856
1963
  </div>
1857
1964
  <div class="project-group-breadcrumb" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
@@ -1969,12 +2076,37 @@ function renderSession() {
1969
2076
  renderSessions();
1970
2077
  }
1971
2078
 
2079
+ function renderProjectView() {
2080
+ noSession.style.display = 'none';
2081
+ sessionView.classList.add('visible');
2082
+
2083
+ const folderName = currentProjectPath ? currentProjectPath.split(/[/\\]/).pop() : 'Project';
2084
+ sessionTitle.textContent = folderName;
2085
+
2086
+ const metaParts = [`${currentProjectSessionIds.length} sessions`, `${currentTasks.length} tasks`];
2087
+ if (currentProjectPath) metaParts.push(currentProjectPath);
2088
+ sessionMeta.textContent = metaParts.join(' · ');
2089
+
2090
+ const completed = currentTasks.filter((t) => t.status === 'completed').length;
2091
+ const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
2092
+
2093
+ progressPercent.textContent = `${percent}%`;
2094
+ progressBar.style.width = `${percent}%`;
2095
+ const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
2096
+ progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
2097
+
2098
+ updateOwnerFilter();
2099
+ renderKanban();
2100
+ renderSessions();
2101
+ }
2102
+
1972
2103
  function renderTaskCard(task) {
1973
2104
  const isBlocked = task.blockedBy && task.blockedBy.length > 0;
1974
- const taskId = viewMode === 'all' ? `${task.sessionId?.slice(0, 4)}-${task.id}` : task.id;
2105
+ const useSlug = viewMode === 'all' || viewMode === 'project';
2106
+ const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
1975
2107
  const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
1976
2108
  const statusClass = task.status.replace('_', '-');
1977
- const actualSessionId = task.sessionId || currentSessionId;
2109
+ const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
1978
2110
 
1979
2111
  return `
1980
2112
  <div
@@ -2112,7 +2244,9 @@ async function onColumnDrop(e) {
2112
2244
  return;
2113
2245
  }
2114
2246
  const { taskId, sessionId } = data;
2115
- const task = currentTasks.find((t) => t.id === taskId && (t.sessionId || currentSessionId) === sessionId);
2247
+ const task = currentTasks.find(
2248
+ (t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
2249
+ );
2116
2250
  if (!task || task.status === newStatus) return;
2117
2251
  try {
2118
2252
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
@@ -2150,7 +2284,10 @@ function getSelectedCardInfo() {
2150
2284
  for (let ci = 0; ci < COLUMNS.length; ci++) {
2151
2285
  const cards = Array.from(COLUMNS[ci].el.querySelectorAll('.task-card'));
2152
2286
  for (let i = 0; i < cards.length; i++) {
2153
- if (cards[i].dataset.taskId === selectedTaskId) {
2287
+ if (
2288
+ cards[i].dataset.taskId === selectedTaskId &&
2289
+ (!selectedSessionId || cards[i].dataset.sessionId === selectedSessionId)
2290
+ ) {
2154
2291
  return { colIndex: ci, cardIndex: i, card: cards[i] };
2155
2292
  }
2156
2293
  }
@@ -2410,7 +2547,9 @@ function getAvailableTasksOptions(currentTaskId = null) {
2410
2547
 
2411
2548
  //#region TASK_DETAIL
2412
2549
  async function showTaskDetail(taskId, sessionId = null) {
2413
- let task = currentTasks.find((t) => t.id === taskId && (!sessionId || t.sessionId === sessionId));
2550
+ let task = currentTasks.find(
2551
+ (t) => t.id === taskId && (!sessionId || t.sessionId === sessionId || t._taskDir === sessionId),
2552
+ );
2414
2553
 
2415
2554
  // If task not found in currentTasks, fetch it from the session
2416
2555
  if (!task && sessionId && sessionId !== 'undefined') {
@@ -2502,20 +2641,25 @@ async function showTaskDetail(taskId, sessionId = null) {
2502
2641
  </div>
2503
2642
  `;
2504
2643
 
2505
- // Setup button handlers
2644
+ // Setup button handlers (read-only in project view)
2506
2645
  const deleteBtn = document.getElementById('delete-task-btn');
2507
- deleteBtn.style.display = '';
2508
- deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2646
+ const isProjectView = viewMode === 'project';
2647
+ deleteBtn.style.display = isProjectView ? 'none' : '';
2648
+ if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2509
2649
 
2510
- // Setup inline editing
2511
- const titleEl = detailContent.querySelector('.detail-title');
2512
- if (titleEl) {
2513
- titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
2514
- }
2650
+ const noteSection = detailContent.querySelector('.note-section');
2651
+ if (noteSection && isProjectView) noteSection.style.display = 'none';
2652
+
2653
+ if (!isProjectView) {
2654
+ const titleEl = detailContent.querySelector('.detail-title');
2655
+ if (titleEl) {
2656
+ titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
2657
+ }
2515
2658
 
2516
- const descEl = detailContent.querySelector('.detail-desc');
2517
- if (descEl) {
2518
- descEl.onclick = () => editDescription(descEl, task, actualSessionId);
2659
+ const descEl = detailContent.querySelector('.detail-desc');
2660
+ if (descEl) {
2661
+ descEl.onclick = () => editDescription(descEl, task, actualSessionId);
2662
+ }
2519
2663
  }
2520
2664
  }
2521
2665
 
@@ -2815,6 +2959,12 @@ const _scratchpadModal = document.getElementById('scratchpad-modal');
2815
2959
  const _scratchpadTextarea = document.getElementById('scratchpad-textarea');
2816
2960
  const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
2817
2961
 
2962
+ function _scratchpadKey() {
2963
+ if (currentSessionId) return `scratchpad-${currentSessionId}`;
2964
+ if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
2965
+ return null;
2966
+ }
2967
+
2818
2968
  function toggleScratchpad() {
2819
2969
  if (_scratchpadModal.classList.contains('visible')) {
2820
2970
  closeScratchpad();
@@ -2824,8 +2974,9 @@ function toggleScratchpad() {
2824
2974
  }
2825
2975
 
2826
2976
  function showScratchpad() {
2827
- if (!currentSessionId) return;
2828
- _scratchpadTextarea.value = localStorage.getItem(`scratchpad-${currentSessionId}`) || '';
2977
+ const key = _scratchpadKey();
2978
+ if (!key) return;
2979
+ _scratchpadTextarea.value = localStorage.getItem(key) || '';
2829
2980
  _scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
2830
2981
  _scratchpadModal.classList.add('visible');
2831
2982
  _scratchpadTextarea.focus();
@@ -2841,8 +2992,9 @@ function closeScratchpad() {
2841
2992
  }
2842
2993
 
2843
2994
  function saveScratchpad() {
2844
- if (!currentSessionId) return;
2845
- localStorage.setItem(`scratchpad-${currentSessionId}`, _scratchpadTextarea.value);
2995
+ const key = _scratchpadKey();
2996
+ if (!key) return;
2997
+ localStorage.setItem(key, _scratchpadTextarea.value);
2846
2998
  }
2847
2999
 
2848
3000
  _scratchpadTextarea.addEventListener('input', () => {
@@ -3122,6 +3274,9 @@ function setupEventSource() {
3122
3274
  currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
3123
3275
  renderAllTasks();
3124
3276
  renderLiveUpdatesFromCache();
3277
+ } else if (viewMode === 'project' && currentProjectPath) {
3278
+ const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
3279
+ if (hasUpdate) fetchProjectView(currentProjectPath);
3125
3280
  } else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
3126
3281
  fetchTasks(currentSessionId);
3127
3282
  }
@@ -3144,7 +3299,9 @@ function setupEventSource() {
3144
3299
 
3145
3300
  if (data.type === 'agent-update') {
3146
3301
  fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
3147
- if (currentSessionId && data.sessionId === currentSessionId) {
3302
+ if (viewMode === 'project' && currentProjectSessionIds.includes(data.sessionId)) {
3303
+ refreshProjectAgents();
3304
+ } else if (currentSessionId && data.sessionId === currentSessionId) {
3148
3305
  fetchAgents(currentSessionId);
3149
3306
  }
3150
3307
  }
@@ -3312,6 +3469,10 @@ function renderContextDetail(raw) {
3312
3469
  //#endregion
3313
3470
 
3314
3471
  //#region UTILS
3472
+ function isSessionActive(s) {
3473
+ return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
3474
+ }
3475
+
3315
3476
  function formatDate(dateStr) {
3316
3477
  const date = new Date(dateStr);
3317
3478
  const now = new Date();
@@ -3494,6 +3655,14 @@ document.addEventListener('click', (e) => {
3494
3655
  return;
3495
3656
  }
3496
3657
 
3658
+ const projectBtn = e.target.closest('.project-view-btn');
3659
+ if (projectBtn) {
3660
+ e.stopPropagation();
3661
+ const projectPath = projectBtn.dataset.projectPath;
3662
+ if (projectPath) fetchProjectView(projectPath);
3663
+ return;
3664
+ }
3665
+
3497
3666
  const header = e.target.closest('.project-group-header');
3498
3667
  if (header) {
3499
3668
  setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
@@ -3804,6 +3973,9 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
3804
3973
  if (session.tasksDir) {
3805
3974
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
3806
3975
  }
3976
+ if (session.sharedTaskList) {
3977
+ infoRows.push(['Shared Tasks', session.sharedTaskList]);
3978
+ }
3807
3979
  if (teamConfig?.configPath) {
3808
3980
  const configDir = teamConfig.configPath.replace(/[/\\][^/\\]+$/, '');
3809
3981
  infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
@@ -4109,7 +4281,13 @@ if (urlState.search) {
4109
4281
  }
4110
4282
 
4111
4283
  fetchSessions().then(async () => {
4112
- if (urlState.session) {
4284
+ if (urlState.projectView) {
4285
+ try {
4286
+ await fetchProjectView(atob(urlState.projectView));
4287
+ } catch (_) {
4288
+ showAllTasks();
4289
+ }
4290
+ } else if (urlState.session) {
4113
4291
  await fetchTasks(urlState.session);
4114
4292
  } else {
4115
4293
  showAllTasks();
@@ -4127,7 +4305,13 @@ window.addEventListener('popstate', () => {
4127
4305
  ownerFilter = s.owner || '';
4128
4306
  searchQuery = s.search || '';
4129
4307
  loadPreferences();
4130
- if (s.session) fetchTasks(s.session);
4308
+ if (s.projectView) {
4309
+ try {
4310
+ fetchProjectView(atob(s.projectView));
4311
+ } catch (_) {
4312
+ showAllTasks();
4313
+ }
4314
+ } else if (s.session) fetchTasks(s.session);
4131
4315
  else showAllTasks();
4132
4316
  if (s.messages !== messagePanelOpen) toggleMessagePanel();
4133
4317
  });
package/public/style.css CHANGED
@@ -488,13 +488,16 @@ body::before {
488
488
  display: block;
489
489
  width: 100%;
490
490
  padding: 12px 14px;
491
- margin-bottom: 2px;
491
+ margin-bottom: 4px;
492
492
  background: transparent;
493
493
  border: 1px solid transparent;
494
494
  border-radius: 8px;
495
495
  text-align: left;
496
496
  cursor: pointer;
497
497
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
498
+ box-shadow:
499
+ 0 1px 3px rgba(0, 0, 0, 0.12),
500
+ 0 1px 2px rgba(0, 0, 0, 0.08);
498
501
  }
499
502
 
500
503
  .session-item:hover {
@@ -1578,6 +1581,13 @@ body::before {
1578
1581
  font-weight: 600;
1579
1582
  }
1580
1583
 
1584
+ .shared-tasklist-badge {
1585
+ display: inline-flex;
1586
+ align-items: center;
1587
+ color: var(--accent);
1588
+ flex-shrink: 0;
1589
+ }
1590
+
1581
1591
  .team-info-btn {
1582
1592
  width: 24px;
1583
1593
  height: 24px;
@@ -3002,6 +3012,18 @@ select.form-input option:checked {
3002
3012
  white-space: nowrap;
3003
3013
  }
3004
3014
 
3015
+ .project-view-btn {
3016
+ color: var(--text-muted);
3017
+ cursor: pointer;
3018
+ display: flex;
3019
+ align-items: center;
3020
+ transition: color 0.15s;
3021
+ }
3022
+
3023
+ .project-view-btn:hover {
3024
+ color: var(--accent);
3025
+ }
3026
+
3005
3027
  .project-group-header .group-count {
3006
3028
  font-weight: 400;
3007
3029
  color: var(--text-muted);
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');
@@ -189,6 +190,9 @@ const terminatedCache = new Map();
189
190
  const compactSummaryCache = new Map();
190
191
  const taskCountsCache = new Map();
191
192
  const contextStatusCache = new Map();
193
+ const TASK_MAPS_DIR = path.join(AGENT_ACTIVITY_DIR, '_task-maps');
194
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
195
+ function isUUID(s) { return UUID_RE.test(s); }
192
196
 
193
197
  function evictStaleCache(cache) {
194
198
  if (cache.size <= MAX_CACHE_ENTRIES) return;
@@ -196,6 +200,47 @@ function evictStaleCache(cache) {
196
200
  if (oldest !== undefined) cache.delete(oldest);
197
201
  }
198
202
 
203
+ let sessionToTaskListCache = null;
204
+ let lastTaskMapScan = 0;
205
+ const TASK_MAP_SCAN_TTL = 5000;
206
+
207
+ function loadAllTaskMaps() {
208
+ const now = Date.now();
209
+ if (sessionToTaskListCache && now - lastTaskMapScan < TASK_MAP_SCAN_TTL) return sessionToTaskListCache;
210
+
211
+ const sessionToList = {};
212
+ const listToSessions = {};
213
+ if (!existsSync(TASK_MAPS_DIR)) {
214
+ sessionToTaskListCache = { sessionToList, listToSessions };
215
+ lastTaskMapScan = now;
216
+ return sessionToTaskListCache;
217
+ }
218
+ try {
219
+ for (const file of readdirSync(TASK_MAPS_DIR).filter(f => f.endsWith('.json'))) {
220
+ const taskListName = file.replace(/\.json$/, '');
221
+ const mapPath = path.join(TASK_MAPS_DIR, file);
222
+ try {
223
+ const map = JSON.parse(readFileSync(mapPath, 'utf8'));
224
+ listToSessions[taskListName] = map;
225
+ for (const sessionId of Object.keys(map)) {
226
+ sessionToList[sessionId] = taskListName;
227
+ }
228
+ } catch (e) { /* skip invalid */ }
229
+ }
230
+ } catch (e) { /* ignore */ }
231
+ sessionToTaskListCache = { sessionToList, listToSessions };
232
+ lastTaskMapScan = now;
233
+ return sessionToTaskListCache;
234
+ }
235
+
236
+ function getCustomTaskDir(sessionId) {
237
+ const { sessionToList } = loadAllTaskMaps();
238
+ const taskListName = sessionToList[sessionId];
239
+ if (!taskListName) return null;
240
+ const dir = path.join(TASKS_DIR, taskListName);
241
+ return existsSync(dir) ? dir : null;
242
+ }
243
+
199
244
  function getTaskCounts(sessionPath) {
200
245
  const cached = taskCountsCache.get(sessionPath);
201
246
  if (cached) return cached;
@@ -449,7 +494,7 @@ app.get('/api/sessions', async (req, res) => {
449
494
  const entries = readdirSync(TASKS_DIR, { withFileTypes: true });
450
495
 
451
496
  for (const entry of entries) {
452
- if (entry.isDirectory()) {
497
+ if (entry.isDirectory() && isUUID(entry.name)) {
453
498
  const sessionPath = path.join(TASKS_DIR, entry.name);
454
499
  const stat = statSync(sessionPath);
455
500
  const { taskCount, completed, inProgress, pending, newestTaskMtime } = getTaskCounts(sessionPath);
@@ -497,6 +542,44 @@ app.get('/api/sessions', async (req, res) => {
497
542
  }));
498
543
  }
499
544
  }
545
+
546
+ // Process custom task lists (non-UUID directories mapped via _task-maps)
547
+ const { listToSessions } = loadAllTaskMaps();
548
+ for (const [taskListName, map] of Object.entries(listToSessions)) {
549
+ const customTaskDir = path.join(TASKS_DIR, taskListName);
550
+ if (!existsSync(customTaskDir)) continue;
551
+ const counts = getTaskCounts(customTaskDir);
552
+
553
+ for (const [sessionId, info] of Object.entries(map)) {
554
+ const existing = sessionsMap.get(sessionId);
555
+ if (existing) {
556
+ Object.assign(existing, {
557
+ taskCount: counts.taskCount,
558
+ completed: counts.completed,
559
+ inProgress: counts.inProgress,
560
+ pending: counts.pending,
561
+ tasksDir: customTaskDir,
562
+ sharedTaskList: taskListName,
563
+ });
564
+ if (counts.newestTaskMtime) {
565
+ const taskMtime = counts.newestTaskMtime.toISOString();
566
+ if (taskMtime > existing.modifiedAt) existing.modifiedAt = taskMtime;
567
+ }
568
+ } else {
569
+ const meta = { ...(metadata[sessionId] || {}) };
570
+ if (!meta.project && info.project) meta.project = info.project;
571
+ sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
572
+ taskCount: counts.taskCount,
573
+ completed: counts.completed,
574
+ inProgress: counts.inProgress,
575
+ pending: counts.pending,
576
+ modifiedAt: counts.newestTaskMtime ? counts.newestTaskMtime.toISOString() : (info.updatedAt || new Date(0).toISOString()),
577
+ tasksDir: customTaskDir,
578
+ sharedTaskList: taskListName,
579
+ }));
580
+ }
581
+ }
582
+ }
500
583
  }
501
584
 
502
585
  // Add sessions from metadata that don't have task directories
@@ -645,7 +728,8 @@ app.get('/api/projects', (req, res) => {
645
728
  // API: Get tasks for a session
646
729
  app.get('/api/sessions/:sessionId', async (req, res) => {
647
730
  try {
648
- const sessionPath = path.join(TASKS_DIR, req.params.sessionId);
731
+ const customDir = getCustomTaskDir(req.params.sessionId);
732
+ const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
649
733
 
650
734
  if (!existsSync(sessionPath)) {
651
735
  return res.status(404).json({ error: 'Session not found' });
@@ -673,6 +757,52 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
673
757
  }
674
758
  });
675
759
 
760
+ // API: Get combined tasks for a project (all sessions + shared task lists)
761
+ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
762
+ try {
763
+ const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
764
+ const metadata = loadSessionMetadata();
765
+ const { sessionToList } = loadAllTaskMaps();
766
+
767
+ const projectSessionIds = Object.entries(metadata)
768
+ .filter(([, m]) => m.project === projectPath)
769
+ .map(([id]) => id);
770
+
771
+ const taskDirs = new Set();
772
+ for (const sid of projectSessionIds) {
773
+ const listName = sessionToList[sid];
774
+ if (listName) {
775
+ const dir = path.join(TASKS_DIR, listName);
776
+ if (existsSync(dir)) taskDirs.add(dir);
777
+ } else {
778
+ const dir = path.join(TASKS_DIR, sid);
779
+ if (existsSync(dir)) taskDirs.add(dir);
780
+ }
781
+ }
782
+
783
+ const tasks = [];
784
+ const seenKeys = new Set();
785
+ for (const dir of taskDirs) {
786
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.json'))) {
787
+ try {
788
+ const task = JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
789
+ const key = `${dir}:${task.id}`;
790
+ if (!seenKeys.has(key)) {
791
+ seenKeys.add(key);
792
+ task._taskDir = path.basename(dir);
793
+ tasks.push(task);
794
+ }
795
+ } catch (_) {}
796
+ }
797
+ }
798
+ tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
799
+ res.json(tasks);
800
+ } catch (error) {
801
+ console.error('Error getting project tasks:', error);
802
+ res.status(500).json({ error: 'Failed to get project tasks' });
803
+ }
804
+ });
805
+
676
806
  // API: Get session plan
677
807
  app.get('/api/sessions/:sessionId/plan', async (req, res) => {
678
808
  try {
@@ -821,23 +951,26 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
821
951
  } catch (_) {}
822
952
  }
823
953
 
954
+ function persistPrompt(agent, prompt) {
955
+ agent.prompt = prompt;
956
+ const agentFile = path.join(agentDir, agent.agentId + '.json');
957
+ fs.writeFile(agentFile, JSON.stringify(agent), 'utf8').catch(() => {});
958
+ }
959
+
824
960
  const agentsNeedingPrompt = agents.filter(a => !a.prompt);
825
961
  if (agentsNeedingPrompt.length && meta.jsonlPath) {
962
+ let byAgentId = {};
826
963
  try {
827
964
  const progressMap = getProgressMap(meta.jsonlPath);
828
- const byAgentId = {};
829
965
  for (const entry of Object.values(progressMap)) {
830
966
  if (entry.prompt && !byAgentId[entry.agentId]) byAgentId[entry.agentId] = entry.prompt;
831
967
  }
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
968
  } catch (_) {}
969
+ for (const agent of agentsNeedingPrompt) {
970
+ const prompt = byAgentId[agent.agentId]
971
+ || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
972
+ if (prompt) persistPrompt(agent, prompt);
973
+ }
841
974
  }
842
975
  const teamColors = {};
843
976
  if (teamConfig?.members) {
@@ -1066,7 +1199,8 @@ app.post('/api/tasks/:sessionId/:taskId/note', async (req, res) => {
1066
1199
  return res.status(400).json({ error: 'Note cannot be empty' });
1067
1200
  }
1068
1201
 
1069
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1202
+ const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1203
+ const taskPath = path.join(sessionDir, `${taskId}.json`);
1070
1204
 
1071
1205
  if (!existsSync(taskPath)) {
1072
1206
  return res.status(404).json({ error: 'Task not found' });
@@ -1095,7 +1229,8 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
1095
1229
  const { sessionId, taskId } = req.params;
1096
1230
  const { subject, description } = req.body;
1097
1231
 
1098
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1232
+ const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1233
+ const taskPath = path.join(sessionDir, `${taskId}.json`);
1099
1234
 
1100
1235
  if (!existsSync(taskPath)) {
1101
1236
  return res.status(404).json({ error: 'Task not found' });
@@ -1120,14 +1255,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
1120
1255
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
1121
1256
  try {
1122
1257
  const { sessionId, taskId } = req.params;
1123
- const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
1258
+ const sessionPath = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
1259
+ const taskPath = path.join(sessionPath, `${taskId}.json`);
1124
1260
 
1125
1261
  if (!existsSync(taskPath)) {
1126
1262
  return res.status(404).json({ error: 'Task not found' });
1127
1263
  }
1128
1264
 
1129
1265
  // Check if this task blocks other tasks
1130
- const sessionPath = path.join(TASKS_DIR, sessionId);
1131
1266
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
1132
1267
 
1133
1268
  for (const file of taskFiles) {
@@ -1200,21 +1335,44 @@ const watcher = chokidar.watch(TASKS_DIR, {
1200
1335
  watcher.on('all', (event, filePath) => {
1201
1336
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1202
1337
  const relativePath = path.relative(TASKS_DIR, filePath);
1203
- const sessionId = relativePath.split(path.sep)[0];
1338
+ const dirName = relativePath.split(path.sep)[0];
1204
1339
 
1205
- taskCountsCache.delete(path.join(TASKS_DIR, sessionId));
1340
+ taskCountsCache.delete(path.join(TASKS_DIR, dirName));
1206
1341
 
1207
- broadcast({
1208
- type: 'update',
1209
- event,
1210
- sessionId,
1211
- file: path.basename(filePath)
1212
- });
1342
+ if (isUUID(dirName)) {
1343
+ broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
1344
+ } else {
1345
+ broadcastToMappedSessions(dirName, event, filePath);
1346
+ }
1213
1347
  }
1214
1348
  });
1215
1349
 
1350
+ function broadcastToMappedSessions(taskListName, event, filePath) {
1351
+ const { listToSessions } = loadAllTaskMaps();
1352
+ const map = listToSessions[taskListName];
1353
+ if (!map) return;
1354
+ for (const sid of Object.keys(map)) {
1355
+ broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
1356
+ }
1357
+ }
1358
+
1216
1359
  console.log(`Watching for changes in: ${TASKS_DIR}`);
1217
1360
 
1361
+ // Watch task maps directory for session→task-list mapping changes
1362
+ const taskMapsWatcher = chokidar.watch(TASK_MAPS_DIR, {
1363
+ persistent: true,
1364
+ ignoreInitial: true,
1365
+ depth: 1
1366
+ });
1367
+ taskMapsWatcher.on('all', (event, filePath) => {
1368
+ if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1369
+ lastTaskMapScan = 0;
1370
+ const taskListName = path.basename(filePath, '.json');
1371
+ taskCountsCache.delete(path.join(TASKS_DIR, taskListName));
1372
+ broadcastToMappedSessions(taskListName, event, filePath);
1373
+ }
1374
+ });
1375
+
1218
1376
  // Watch teams directory for config changes
1219
1377
  const teamsWatcher = chokidar.watch(TEAMS_DIR, {
1220
1378
  persistent: true,