claude-code-kanban 1.12.0 → 1.13.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +366 -116
  3. package/server.js +129 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
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/index.html CHANGED
@@ -13,6 +13,9 @@
13
13
  </style>
14
14
  <script defer src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
15
15
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
16
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css" id="hljs-theme-dark">
17
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css" id="hljs-theme-light" disabled>
18
+ <script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
16
19
  <style>
17
20
  :root {
18
21
  --bg-deep: #101114;
@@ -877,8 +880,8 @@
877
880
  }
878
881
 
879
882
  .detail-close {
880
- width: 28px;
881
- height: 28px;
883
+ width: 34px;
884
+ height: 34px;
882
885
  display: flex;
883
886
  align-items: center;
884
887
  justify-content: center;
@@ -896,8 +899,8 @@
896
899
  }
897
900
 
898
901
  .detail-close svg {
899
- width: 16px;
900
- height: 16px;
902
+ width: 20px;
903
+ height: 20px;
901
904
  }
902
905
 
903
906
  .detail-content {
@@ -1011,12 +1014,23 @@
1011
1014
  }
1012
1015
 
1013
1016
  .detail-desc pre {
1017
+ border-radius: 6px;
1018
+ overflow: hidden;
1019
+ margin: 12px 0;
1020
+ font-size: 12px;
1021
+ }
1022
+
1023
+ .detail-desc pre code.hljs {
1024
+ padding: 12px;
1025
+ border-radius: 6px;
1026
+ }
1027
+
1028
+ .detail-desc pre code {
1014
1029
  background: var(--bg-elevated);
1015
1030
  padding: 12px;
1016
1031
  border-radius: 6px;
1032
+ display: block;
1017
1033
  overflow-x: auto;
1018
- margin: 12px 0;
1019
- font-size: 12px;
1020
1034
  }
1021
1035
 
1022
1036
  .detail-desc code {
@@ -1026,11 +1040,6 @@
1026
1040
  font-size: 0.9em;
1027
1041
  }
1028
1042
 
1029
- .detail-desc pre code {
1030
- background: transparent;
1031
- padding: 0;
1032
- }
1033
-
1034
1043
  .detail-desc hr {
1035
1044
  border: none;
1036
1045
  border-top: 1px solid var(--border);
@@ -1366,6 +1375,19 @@
1366
1375
  display: flex;
1367
1376
  }
1368
1377
 
1378
+ .plan-modal-overlay {
1379
+ z-index: 10001;
1380
+ background: rgba(0, 0, 0, 0.6);
1381
+ }
1382
+
1383
+ .modal.plan-modal {
1384
+ width: 60vw;
1385
+ max-width: 60vw;
1386
+ max-height: 90vh;
1387
+ display: flex;
1388
+ flex-direction: column;
1389
+ }
1390
+
1369
1391
  .modal {
1370
1392
  background: var(--bg-surface);
1371
1393
  border: 1px solid var(--border);
@@ -1533,6 +1555,11 @@
1533
1555
  border-color: var(--text-muted);
1534
1556
  }
1535
1557
 
1558
+ .btn:focus-visible {
1559
+ outline: 2px solid var(--accent);
1560
+ outline-offset: 2px;
1561
+ }
1562
+
1536
1563
  /* Skip navigation */
1537
1564
  .skip-link {
1538
1565
  position: absolute;
@@ -1848,11 +1875,18 @@
1848
1875
  <aside id="detail-panel" class="detail-panel">
1849
1876
  <header class="detail-header">
1850
1877
  <h3>Task Details</h3>
1851
- <button id="close-detail" class="detail-close" aria-label="Close detail panel">
1852
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1853
- <path d="M6 18L18 6M6 6l12 12"/>
1854
- </svg>
1855
- </button>
1878
+ <div style="display: flex; gap: 8px; align-items: center;">
1879
+ <button id="delete-task-btn" class="icon-btn" title="Delete task (D)" aria-label="Delete task" style="color: #ef4444; border-color: #ef4444; display: none;">
1880
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1881
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
1882
+ </svg>
1883
+ </button>
1884
+ <button id="close-detail" class="detail-close" aria-label="Close detail panel">
1885
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1886
+ <path d="M6 18L18 6M6 6l12 12"/>
1887
+ </svg>
1888
+ </button>
1889
+ </div>
1856
1890
  </header>
1857
1891
  <div id="detail-content" class="detail-content"></div>
1858
1892
  </aside>
@@ -1866,7 +1900,8 @@
1866
1900
  let viewMode = 'session';
1867
1901
  let sessionFilter = 'active';
1868
1902
  let sessionLimit = '20';
1869
- let filterProject = null; // null = all projects, or project path to filter
1903
+ let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
1904
+ let recentProjects = new Set();
1870
1905
  let searchQuery = ''; // Search query for fuzzy search
1871
1906
  let allTasksCache = []; // Cache all tasks for search
1872
1907
  let bulkDeleteSessionId = null; // Track session for bulk delete
@@ -1895,7 +1930,7 @@
1895
1930
  if (currentSessionId) params.set('session', currentSessionId);
1896
1931
  if (sessionFilter !== 'active') params.set('filter', sessionFilter);
1897
1932
  if (sessionLimit !== '20') params.set('limit', sessionLimit);
1898
- if (filterProject) params.set('project', filterProject);
1933
+ if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
1899
1934
  if (ownerFilter) params.set('owner', ownerFilter);
1900
1935
  if (searchQuery) params.set('search', searchQuery);
1901
1936
  const qs = params.toString();
@@ -1907,7 +1942,7 @@
1907
1942
  history.replaceState(null, '', window.location.pathname);
1908
1943
  sessionFilter = 'active';
1909
1944
  sessionLimit = '20';
1910
- filterProject = null;
1945
+ filterProject = '__recent__';
1911
1946
  ownerFilter = '';
1912
1947
  searchQuery = '';
1913
1948
  viewMode = 'all';
@@ -2192,7 +2227,7 @@
2192
2227
  const allTasks = await res.json();
2193
2228
  let activeTasks = allTasks.filter(t => t.status === 'in_progress');
2194
2229
  if (filterProject) {
2195
- activeTasks = activeTasks.filter(t => t.project === filterProject);
2230
+ activeTasks = activeTasks.filter(t => matchesProjectFilter(t.project));
2196
2231
  }
2197
2232
  renderLiveUpdates(activeTasks);
2198
2233
  } catch (error) {
@@ -2270,7 +2305,7 @@
2270
2305
  const res = await fetch('/api/tasks/all');
2271
2306
  let tasks = await res.json();
2272
2307
  if (filterProject) {
2273
- tasks = tasks.filter(t => t.project === filterProject);
2308
+ tasks = tasks.filter(t => matchesProjectFilter(t.project));
2274
2309
  }
2275
2310
  currentTasks = tasks;
2276
2311
  updateUrl();
@@ -2290,9 +2325,10 @@
2290
2325
  const completed = currentTasks.filter(t => t.status === 'completed').length;
2291
2326
  const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
2292
2327
 
2293
- const projectName = filterProject ? filterProject.split('/').pop() : null;
2294
- sessionTitle.textContent = filterProject ? `Tasks: ${projectName}` : 'All Tasks';
2295
- sessionMeta.textContent = filterProject
2328
+ const isFiltered = filterProject && filterProject !== '__recent__';
2329
+ const projectName = isFiltered ? filterProject.split(/[/\\]/).pop() : null;
2330
+ sessionTitle.textContent = isFiltered ? `Tasks: ${projectName}` : (filterProject === '__recent__' ? 'Recent Tasks' : 'All Tasks');
2331
+ sessionMeta.textContent = isFiltered
2296
2332
  ? `${totalTasks} tasks in this project`
2297
2333
  : `${totalTasks} tasks across ${sessions.length} sessions`;
2298
2334
  progressPercent.textContent = `${percent}%`;
@@ -2310,7 +2346,7 @@
2310
2346
  filteredSessions = filteredSessions.filter(s => s.pending > 0 || s.inProgress > 0);
2311
2347
  }
2312
2348
  if (filterProject) {
2313
- filteredSessions = filteredSessions.filter(s => s.project === filterProject);
2349
+ filteredSessions = filteredSessions.filter(s => matchesProjectFilter(s.project));
2314
2350
  }
2315
2351
 
2316
2352
  // Apply search filter
@@ -2389,14 +2425,14 @@
2389
2425
  const memberCount = session.memberCount || 0;
2390
2426
 
2391
2427
  return `
2392
- <button onclick="fetchTasks('${session.id}')" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
2428
+ <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
2393
2429
  <div class="session-name">${escapeHtml(primaryName)}</div>
2394
2430
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2395
2431
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
2396
2432
  <div class="session-progress">
2397
2433
  <span class="session-indicators">
2398
2434
  ${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>` : ''}
2399
- ${isTeam ? `<span class="team-info-btn" onclick="event.stopPropagation(); showTeamModalForSession('${session.id}')" title="View team info">ℹ</span>` : ''}
2435
+ ${(isTeam || session.project) ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2400
2436
  ${hasInProgress ? '<span class="pulse"></span>' : ''}
2401
2437
  </span>
2402
2438
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
@@ -2530,7 +2566,7 @@
2530
2566
  const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`)
2531
2567
  || document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
2532
2568
  if (card) {
2533
- card.classList.add('selected');
2569
+ if (focusZone === 'board') card.classList.add('selected');
2534
2570
  } else {
2535
2571
  selectedTaskId = null;
2536
2572
  selectedSessionId = null;
@@ -2660,13 +2696,20 @@
2660
2696
  selectSessionByIndex(selectedSessionIdx);
2661
2697
  }
2662
2698
  } else {
2699
+ // Session changed while in sidebar — reset stale selection
2700
+ if (selectedSessionId && selectedSessionId !== currentSessionId) {
2701
+ selectedTaskId = null;
2702
+ selectedSessionId = null;
2703
+ }
2663
2704
  if (selectedTaskId) {
2664
- const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`)
2665
- || document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
2705
+ const card = document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`);
2666
2706
  if (card) card.classList.add('selected');
2667
2707
  } else {
2668
2708
  navigateVertical(1);
2669
2709
  }
2710
+ if (selectedTaskId && detailPanel.classList.contains('visible')) {
2711
+ showTaskDetail(selectedTaskId, selectedSessionId);
2712
+ }
2670
2713
  }
2671
2714
  }
2672
2715
 
@@ -2738,19 +2781,8 @@
2738
2781
 
2739
2782
  detailContent.innerHTML = `
2740
2783
  <div class="detail-section">
2741
- <div style="display: flex; justify-content: space-between; align-items: start;">
2742
- <div style="flex: 1;">
2743
- <div class="detail-label">Task #${task.id}</div>
2744
- <h2 class="detail-title">${escapeHtml(task.subject)}</h2>
2745
- </div>
2746
- <div style="display: flex; gap: 8px;">
2747
- <button id="delete-task-btn" class="icon-btn" title="Delete task (D)" aria-label="Delete task" style="color: #ef4444; border-color: #ef4444;">
2748
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2749
- <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
2750
- </svg>
2751
- </button>
2752
- </div>
2753
- </div>
2784
+ <div class="detail-label">Task #${task.id}</div>
2785
+ <h2 class="detail-title">${escapeHtml(task.subject)}</h2>
2754
2786
  </div>
2755
2787
 
2756
2788
  <div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
@@ -2800,7 +2832,9 @@
2800
2832
  `;
2801
2833
 
2802
2834
  // Setup button handlers
2803
- document.getElementById('delete-task-btn').onclick = () => deleteTask(task.id, actualSessionId);
2835
+ const deleteBtn = document.getElementById('delete-task-btn');
2836
+ deleteBtn.style.display = '';
2837
+ deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
2804
2838
  }
2805
2839
 
2806
2840
  async function addNote(event, taskId, sessionId) {
@@ -2834,10 +2868,12 @@
2834
2868
 
2835
2869
  function closeDetailPanel() {
2836
2870
  detailPanel.classList.remove('visible');
2871
+ document.getElementById('delete-task-btn').style.display = 'none';
2837
2872
  }
2838
2873
 
2839
2874
  let deleteTaskId = null;
2840
2875
  let deleteSessionId = null;
2876
+ let deleteModalKeyHandler = null;
2841
2877
 
2842
2878
  function showBlockedTaskModal(task) {
2843
2879
  const messageDiv = document.getElementById('blocked-task-message');
@@ -2890,15 +2926,31 @@
2890
2926
  const modal = document.getElementById('delete-confirm-modal');
2891
2927
  modal.classList.add('visible');
2892
2928
 
2893
- // Handle ESC key
2894
- const keyHandler = (e) => {
2929
+ const buttons = [
2930
+ document.getElementById('delete-cancel-btn'),
2931
+ document.getElementById('delete-confirm-btn')
2932
+ ];
2933
+ let focusIdx = 1;
2934
+ buttons[focusIdx].focus();
2935
+
2936
+ deleteModalKeyHandler = (e) => {
2895
2937
  if (e.key === 'Escape') {
2896
2938
  e.preventDefault();
2897
2939
  closeDeleteConfirmModal();
2898
- document.removeEventListener('keydown', keyHandler);
2940
+ } else if (e.key === 'ArrowLeft' || e.key === 'h') {
2941
+ e.preventDefault();
2942
+ focusIdx = 0;
2943
+ buttons[focusIdx].focus();
2944
+ } else if (e.key === 'ArrowRight' || e.key === 'l') {
2945
+ e.preventDefault();
2946
+ focusIdx = 1;
2947
+ buttons[focusIdx].focus();
2948
+ } else if (e.key === 'Enter') {
2949
+ e.preventDefault();
2950
+ buttons[focusIdx].click();
2899
2951
  }
2900
2952
  };
2901
- document.addEventListener('keydown', keyHandler);
2953
+ document.addEventListener('keydown', deleteModalKeyHandler);
2902
2954
  }
2903
2955
 
2904
2956
  function closeDeleteConfirmModal() {
@@ -2906,6 +2958,10 @@
2906
2958
  modal.classList.remove('visible');
2907
2959
  deleteTaskId = null;
2908
2960
  deleteSessionId = null;
2961
+ if (deleteModalKeyHandler) {
2962
+ document.removeEventListener('keydown', deleteModalKeyHandler);
2963
+ deleteModalKeyHandler = null;
2964
+ }
2909
2965
  }
2910
2966
 
2911
2967
  async function confirmDelete() {
@@ -3008,6 +3064,20 @@
3008
3064
  setFocusZone('board');
3009
3065
  return;
3010
3066
  }
3067
+ if (e.key === 'p' || e.key === 'P') {
3068
+ e.preventDefault();
3069
+ const highlighted = sessionsList.querySelector('.session-item.kb-selected');
3070
+ const sid = highlighted?.dataset.sessionId || currentSessionId;
3071
+ if (sid) openPlanForSession(sid);
3072
+ return;
3073
+ }
3074
+ if (e.key === 'i' || e.key === 'I') {
3075
+ e.preventDefault();
3076
+ const highlighted = sessionsList.querySelector('.session-item.kb-selected');
3077
+ const sid = highlighted?.dataset.sessionId || currentSessionId;
3078
+ if (sid) showSessionInfoModal(sid);
3079
+ return;
3080
+ }
3011
3081
  if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
3012
3082
  e.preventDefault();
3013
3083
  showHelpModal();
@@ -3050,18 +3120,23 @@
3050
3120
  closeDetailPanel();
3051
3121
  }
3052
3122
 
3053
- if (detailPanel.classList.contains('visible')) {
3054
- const labelElement = document.querySelector('.detail-label');
3055
- if (!labelElement) return;
3056
- const taskId = labelElement.textContent.match(/\d+/)?.[0];
3057
- if (!taskId) return;
3058
- const task = currentTasks.find(t => t.id === taskId);
3059
- if (!task) return;
3060
- const sessionId = task.sessionId || currentSessionId;
3061
- if (e.key === 'd' || e.key === 'D') {
3062
- e.preventDefault();
3063
- deleteTask(taskId, sessionId);
3064
- }
3123
+ if (e.key === 'p' || e.key === 'P') {
3124
+ e.preventDefault();
3125
+ const sid = selectedSessionId || currentSessionId;
3126
+ if (sid) openPlanForSession(sid);
3127
+ return;
3128
+ }
3129
+
3130
+ if (e.key === 'i' || e.key === 'I') {
3131
+ e.preventDefault();
3132
+ const sid = selectedSessionId || currentSessionId;
3133
+ if (sid) showSessionInfoModal(sid);
3134
+ return;
3135
+ }
3136
+
3137
+ if ((e.key === 'd' || e.key === 'D') && selectedTaskId) {
3138
+ e.preventDefault();
3139
+ deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
3065
3140
  }
3066
3141
 
3067
3142
  if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
@@ -3178,6 +3253,12 @@
3178
3253
  fetchSessions();
3179
3254
  }
3180
3255
 
3256
+ function matchesProjectFilter(project) {
3257
+ if (!filterProject) return true;
3258
+ if (filterProject === '__recent__') return recentProjects.has(project);
3259
+ return project === filterProject;
3260
+ }
3261
+
3181
3262
  function filterByProject(project) {
3182
3263
  filterProject = project || null;
3183
3264
  updateUrl();
@@ -3186,15 +3267,30 @@
3186
3267
  showAllTasks();
3187
3268
  }
3188
3269
 
3189
- function updateProjectDropdown() {
3270
+ async function updateProjectDropdown() {
3190
3271
  const dropdown = document.getElementById('project-filter');
3191
- const projects = [...new Set(sessions.map(s => s.project).filter(Boolean))].sort();
3272
+ let projects;
3273
+ try {
3274
+ const res = await fetch('/api/projects');
3275
+ projects = await res.json();
3276
+ } catch (e) {
3277
+ projects = [...new Set(sessions.map(s => s.project).filter(Boolean))].sort()
3278
+ .map(p => ({ path: p, modifiedAt: null }));
3279
+ }
3192
3280
 
3193
- dropdown.innerHTML = '<option value="">All Projects</option>' +
3281
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
3282
+ recentProjects = new Set(
3283
+ projects.filter(p => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map(p => p.path)
3284
+ );
3285
+
3286
+ const recentSelected = filterProject === '__recent__' ? ' selected' : '';
3287
+ dropdown.innerHTML =
3288
+ '<option value="">All Projects</option>' +
3289
+ `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
3194
3290
  projects.map(p => {
3195
- const name = p.split('/').pop();
3196
- const selected = p === filterProject ? ' selected' : '';
3197
- return `<option value="${p}"${selected} title="${escapeHtml(p)}">${escapeHtml(name)}</option>`;
3291
+ const name = p.path.split(/[/\\]/).pop();
3292
+ const selected = p.path === filterProject ? ' selected' : '';
3293
+ return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
3198
3294
  }).join('');
3199
3295
  }
3200
3296
 
@@ -3210,6 +3306,15 @@
3210
3306
  localStorage.setItem('theme', 'light');
3211
3307
  }
3212
3308
  updateThemeIcon();
3309
+ syncHljsTheme();
3310
+ }
3311
+
3312
+ function syncHljsTheme() {
3313
+ const isLight = document.body.classList.contains('light');
3314
+ const dark = document.getElementById('hljs-theme-dark');
3315
+ const light = document.getElementById('hljs-theme-light');
3316
+ if (dark) dark.disabled = isLight;
3317
+ if (light) light.disabled = !isLight;
3213
3318
  }
3214
3319
 
3215
3320
  function updateThemeIcon() {
@@ -3231,6 +3336,7 @@
3231
3336
  }
3232
3337
  // If no saved preference, system prefers-color-scheme CSS handles it
3233
3338
  updateThemeIcon();
3339
+ syncHljsTheme();
3234
3340
  }
3235
3341
 
3236
3342
  function toggleSidebar() {
@@ -3239,6 +3345,7 @@
3239
3345
  localStorage.setItem('sidebar-collapsed', collapsed);
3240
3346
  if (collapsed) {
3241
3347
  sidebar.style.width = '';
3348
+ if (focusZone === 'sidebar') setFocusZone('board');
3242
3349
  } else {
3243
3350
  const w = getComputedStyle(sidebar).getPropertyValue('--sidebar-width');
3244
3351
  if (w) sidebar.style.width = w;
@@ -3294,68 +3401,127 @@
3294
3401
  document.getElementById('session-limit').value = sessionLimit;
3295
3402
  }
3296
3403
 
3297
- async function showTeamModalForSession(sessionId) {
3404
+ async function showSessionInfoModal(sessionId) {
3298
3405
  const session = sessions.find(s => s.id === sessionId);
3299
- if (!session || !session.isTeam) return;
3300
- try {
3301
- const res = await fetch(`/api/teams/${sessionId}`);
3302
- if (!res.ok) return;
3303
- const teamConfig = await res.json();
3304
- showTeamModal(teamConfig, currentSessionId === sessionId ? currentTasks : []);
3305
- } catch (e) {
3306
- console.error('Failed to fetch team config:', e);
3406
+ if (!session) return;
3407
+
3408
+ const promises = [];
3409
+
3410
+ // Fetch team config
3411
+ let teamConfig = null;
3412
+ if (session.isTeam) {
3413
+ promises.push(
3414
+ fetch(`/api/teams/${sessionId}`).then(r => r.ok ? r.json() : null).catch(() => null)
3415
+ .then(data => { teamConfig = data; })
3416
+ );
3307
3417
  }
3418
+
3419
+ // Fetch plan
3420
+ let planContent = null;
3421
+ promises.push(
3422
+ fetch(`/api/sessions/${sessionId}/plan`).then(r => r.ok ? r.json() : null).catch(() => null)
3423
+ .then(data => { planContent = data?.content || null; })
3424
+ );
3425
+
3426
+ await Promise.all(promises);
3427
+
3428
+ const tasks = currentSessionId === sessionId ? currentTasks : [];
3429
+ _planSessionId = sessionId;
3430
+ showInfoModal(session, teamConfig, tasks, planContent);
3308
3431
  }
3309
3432
 
3310
- function showTeamModal(teamConfig, tasks) {
3433
+ let _pendingPlanContent = null;
3434
+
3435
+ function showInfoModal(session, teamConfig, tasks, planContent) {
3311
3436
  const modal = document.getElementById('team-modal');
3312
3437
  const titleEl = document.getElementById('team-modal-title');
3313
3438
  const bodyEl = document.getElementById('team-modal-body');
3314
3439
 
3315
- titleEl.textContent = `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`;
3440
+ titleEl.textContent = teamConfig
3441
+ ? `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`
3442
+ : (session.name || session.slug || session.id);
3316
3443
 
3317
- const ownerCounts = {};
3318
- tasks.forEach(t => {
3319
- if (t.owner) {
3320
- ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
3444
+ let html = '';
3445
+
3446
+ // Session & project details as compact key-value rows
3447
+ const infoRows = [];
3448
+ infoRows.push(['Session', `<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; user-select: all;">${escapeHtml(session.id)}</span>`]);
3449
+ if (session.modifiedAt) {
3450
+ const d = new Date(session.modifiedAt);
3451
+ infoRows.push(['Last Modified', `${formatDate(session.modifiedAt)} <span style="font-size: 10px; color: var(--text-tertiary);">(${d.toLocaleString()})</span>`]);
3452
+ }
3453
+ if (session.project) {
3454
+ const projectName = session.project.split(/[/\\]/).pop();
3455
+ infoRows.push(['Project', `${escapeHtml(projectName)}<br><span style="font-size: 10px; color: var(--text-tertiary);">${escapeHtml(session.project)}</span>`]);
3456
+ if (session.gitBranch) {
3457
+ infoRows.push(['Branch', escapeHtml(session.gitBranch)]);
3458
+ }
3459
+ if (session.description) {
3460
+ infoRows.push(['Description', escapeHtml(session.description)]);
3321
3461
  }
3462
+ }
3463
+ html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline;">`;
3464
+ infoRows.forEach(([label, value]) => {
3465
+ html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span><span>${value}</span>`;
3322
3466
  });
3467
+ html += `</div>`;
3468
+
3469
+ if (planContent) {
3470
+ _pendingPlanContent = planContent;
3471
+ const titleMatch = planContent.match(/^#\s+(.+)$/m);
3472
+ const planTitle = titleMatch ? titleMatch[1].trim() : null;
3473
+ html += `<div onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
3474
+ <span style="font-size: 14px;">📋</span>
3475
+ <div style="flex: 1; min-width: 0;">
3476
+ <div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
3477
+ ${planTitle ? `<div style="font-size: 13px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(planTitle)}</div>` : ''}
3478
+ </div>
3479
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" style="width: 16px; height: 16px; flex-shrink: 0;"><path d="M9 18l6-6-6-6"/></svg>
3480
+ </div>`;
3481
+ }
3323
3482
 
3324
- const members = teamConfig.members || [];
3325
- const description = teamConfig.description || '';
3326
- const lead = members.find(m => m.agentType === 'team-lead' || m.name === 'team-lead');
3483
+ // Team info section
3484
+ if (teamConfig) {
3485
+ const ownerCounts = {};
3486
+ tasks.forEach(t => {
3487
+ if (t.owner) ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
3488
+ });
3327
3489
 
3328
- let html = '';
3329
- if (description) {
3330
- html += `<div class="team-modal-desc">"${escapeHtml(description)}"</div>`;
3331
- }
3490
+ const members = teamConfig.members || [];
3491
+ const description = teamConfig.description || '';
3492
+ const lead = members.find(m => m.agentType === 'team-lead' || m.name === 'team-lead');
3332
3493
 
3333
- html += `<div style="font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px;">Members (${members.length})</div>`;
3494
+ if (description) {
3495
+ html += `<div class="team-modal-desc">"${escapeHtml(description)}"</div>`;
3496
+ }
3334
3497
 
3335
- members.forEach(member => {
3336
- const taskCount = ownerCounts[member.name] || 0;
3337
- html += `
3338
- <div class="team-member-card">
3339
- <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3340
- <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3341
- ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3342
- <div class="member-tasks">Tasks: ${taskCount} assigned</div>
3343
- </div>
3344
- `;
3345
- });
3498
+ html += `<div style="font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px;">Members (${members.length})</div>`;
3346
3499
 
3347
- const metaParts = [];
3348
- if (teamConfig.created_at) {
3349
- metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
3350
- }
3351
- if (lead) {
3352
- metaParts.push(`Lead: ${lead.name}`);
3353
- }
3354
- if (teamConfig.working_dir) {
3355
- metaParts.push(`Working dir: ${teamConfig.working_dir}`);
3356
- }
3357
- if (metaParts.length > 0) {
3358
- html += `<div class="team-modal-meta">${metaParts.map(p => escapeHtml(p)).join('<br>')}</div>`;
3500
+ members.forEach(member => {
3501
+ const taskCount = ownerCounts[member.name] || 0;
3502
+ html += `
3503
+ <div class="team-member-card">
3504
+ <div class="member-name">🟢 ${escapeHtml(member.name)}</div>
3505
+ <div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
3506
+ ${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
3507
+ <div class="member-tasks">Tasks: ${taskCount} assigned</div>
3508
+ </div>
3509
+ `;
3510
+ });
3511
+
3512
+ const metaParts = [];
3513
+ if (teamConfig.created_at) {
3514
+ metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
3515
+ }
3516
+ if (lead) {
3517
+ metaParts.push(`Lead: ${lead.name}`);
3518
+ }
3519
+ if (teamConfig.working_dir) {
3520
+ metaParts.push(`Working dir: ${teamConfig.working_dir}`);
3521
+ }
3522
+ if (metaParts.length > 0) {
3523
+ html += `<div class="team-modal-meta">${metaParts.map(p => escapeHtml(p)).join('<br>')}</div>`;
3524
+ }
3359
3525
  }
3360
3526
 
3361
3527
  bodyEl.innerHTML = html;
@@ -3363,6 +3529,7 @@
3363
3529
 
3364
3530
  const keyHandler = (e) => {
3365
3531
  if (e.key === 'Escape') {
3532
+ if (document.getElementById('plan-modal').classList.contains('visible')) return;
3366
3533
  e.preventDefault();
3367
3534
  closeTeamModal();
3368
3535
  document.removeEventListener('keydown', keyHandler);
@@ -3375,6 +3542,45 @@
3375
3542
  document.getElementById('team-modal').classList.remove('visible');
3376
3543
  }
3377
3544
 
3545
+ let _planSessionId = null;
3546
+
3547
+ function openPlanForSession(sid) {
3548
+ fetch(`/api/sessions/${sid}/plan`).then(r => r.ok ? r.json() : null).catch(() => null)
3549
+ .then(data => {
3550
+ if (data?.content) {
3551
+ _pendingPlanContent = data.content;
3552
+ _planSessionId = sid;
3553
+ openPlanModal();
3554
+ }
3555
+ });
3556
+ }
3557
+
3558
+ function openPlanModal() {
3559
+ if (!_pendingPlanContent) return;
3560
+ const body = document.getElementById('plan-modal-body');
3561
+ body.innerHTML = DOMPurify.sanitize(marked.parse(_pendingPlanContent));
3562
+ document.getElementById('plan-modal').classList.add('visible');
3563
+
3564
+ const keyHandler = (e) => {
3565
+ if (e.key === 'Escape') {
3566
+ e.preventDefault();
3567
+ e.stopPropagation();
3568
+ closePlanModal();
3569
+ document.removeEventListener('keydown', keyHandler, true);
3570
+ }
3571
+ };
3572
+ document.addEventListener('keydown', keyHandler, true);
3573
+ }
3574
+
3575
+ function closePlanModal() {
3576
+ document.getElementById('plan-modal').classList.remove('visible');
3577
+ }
3578
+
3579
+ function openPlanInEditor() {
3580
+ if (!_planSessionId) return;
3581
+ fetch(`/api/sessions/${_planSessionId}/plan/open`, { method: 'POST' }).catch(() => {});
3582
+ }
3583
+
3378
3584
  function updateOwnerFilter() {
3379
3585
  const bar = document.getElementById('owner-filter-bar');
3380
3586
  const select = document.getElementById('owner-filter');
@@ -3416,6 +3622,23 @@
3416
3622
 
3417
3623
  // Init
3418
3624
  loadTheme();
3625
+
3626
+ document.addEventListener('DOMContentLoaded', () => {
3627
+ if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
3628
+ const renderer = new marked.Renderer();
3629
+ renderer.code = function({ text, lang }) {
3630
+ let highlighted;
3631
+ if (lang && hljs.getLanguage(lang)) {
3632
+ highlighted = hljs.highlight(text, { language: lang }).value;
3633
+ } else {
3634
+ highlighted = hljs.highlightAuto(text).value;
3635
+ }
3636
+ return `<pre><code class="hljs language-${escapeHtml(lang || '')}">${highlighted}</code></pre>`;
3637
+ };
3638
+ marked.use({ renderer });
3639
+ }
3640
+ });
3641
+
3419
3642
  loadSidebarState();
3420
3643
  initSidebarResize();
3421
3644
  fetch('/api/version').then(r => r.json()).then(d => {
@@ -3425,7 +3648,7 @@
3425
3648
  const urlState = getUrlState();
3426
3649
  sessionFilter = urlState.filter || 'active';
3427
3650
  sessionLimit = urlState.limit || '20';
3428
- filterProject = urlState.project || null;
3651
+ filterProject = urlState.project || '__recent__';
3429
3652
  ownerFilter = urlState.owner || '';
3430
3653
  searchQuery = urlState.search || '';
3431
3654
 
@@ -3449,7 +3672,7 @@
3449
3672
  const s = getUrlState();
3450
3673
  sessionFilter = s.filter || 'active';
3451
3674
  sessionLimit = s.limit || '20';
3452
- filterProject = s.project || null;
3675
+ filterProject = s.project || '__recent__';
3453
3676
  ownerFilter = s.owner || '';
3454
3677
  searchQuery = s.search || '';
3455
3678
  loadPreferences();
@@ -3520,6 +3743,14 @@
3520
3743
  <div>
3521
3744
  <h4 style="margin: 0 0 8px 0; color: var(--text-primary); font-size: 14px; font-weight: 600;">Task Actions</h4>
3522
3745
  <table style="width: 100%; font-size: 13px;">
3746
+ <tr>
3747
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">P</kbd></td>
3748
+ <td style="padding: 4px 0; color: var(--text-primary);">Open session plan</td>
3749
+ </tr>
3750
+ <tr>
3751
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">I</kbd></td>
3752
+ <td style="padding: 4px 0; color: var(--text-primary);">Open session info</td>
3753
+ </tr>
3523
3754
  <tr>
3524
3755
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
3525
3756
  <td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
@@ -3549,8 +3780,8 @@
3549
3780
  <p id="delete-confirm-message" style="margin: 0; color: var(--text-primary);"></p>
3550
3781
  </div>
3551
3782
  <div class="modal-footer">
3552
- <button class="btn btn-secondary" onclick="closeDeleteConfirmModal()">Cancel</button>
3553
- <button class="btn btn-primary" onclick="confirmDelete()" style="background: #ef4444; border-color: #ef4444;">Delete</button>
3783
+ <button id="delete-cancel-btn" class="btn btn-secondary" onclick="closeDeleteConfirmModal()">Cancel</button>
3784
+ <button id="delete-confirm-btn" class="btn btn-primary" onclick="confirmDelete()" style="background: #ef4444; border-color: #ef4444;">Delete</button>
3554
3785
  </div>
3555
3786
  </div>
3556
3787
  </div>
@@ -3616,6 +3847,25 @@
3616
3847
  </div>
3617
3848
  </div>
3618
3849
 
3850
+ <!-- Plan Modal (stacked on top of info modal) -->
3851
+ <div id="plan-modal" class="modal-overlay plan-modal-overlay" onclick="closePlanModal()">
3852
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
3853
+ <div class="modal-header">
3854
+ <h3 id="plan-modal-title" class="modal-title">Plan</h3>
3855
+ <button class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px; margin-left: auto; margin-right: 12px;" onclick="openPlanInEditor()">Open in Editor</button>
3856
+ <button class="modal-close" aria-label="Close dialog" onclick="closePlanModal()">
3857
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
3858
+ <path d="M18 6L6 18M6 6l12 12"/>
3859
+ </svg>
3860
+ </button>
3861
+ </div>
3862
+ <div id="plan-modal-body" class="modal-body detail-desc" style="overflow-y: auto; flex: 1;"></div>
3863
+ <div class="modal-footer">
3864
+ <button class="btn btn-primary" onclick="closePlanModal()">Close</button>
3865
+ </div>
3866
+ </div>
3867
+ </div>
3868
+
3619
3869
  <!-- Blocked Task Warning Modal -->
3620
3870
  <div id="blocked-task-modal" class="modal-overlay" onclick="closeBlockedTaskModal()">
3621
3871
  <div class="modal" onclick="event.stopPropagation()" style="max-width: 450px;">
package/server.js CHANGED
@@ -31,6 +31,7 @@ const CLAUDE_DIR = getClaudeDir();
31
31
  const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
32
32
  const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
33
33
  const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
34
+ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
34
35
 
35
36
  function isTeamSession(sessionId) {
36
37
  return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
@@ -143,20 +144,35 @@ function loadSessionMetadata() {
143
144
 
144
145
  // Find all .jsonl files (session logs)
145
146
  const files = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
147
+ const sessionIds = [];
146
148
 
149
+ // First pass: read all JSONL files
150
+ let resolvedProjectPath = null;
147
151
  for (const file of files) {
148
152
  const sessionId = file.replace('.jsonl', '');
149
153
  const jsonlPath = path.join(projectPath, file);
150
-
151
- // Read customTitle, slug, and actual project path from JSONL
152
154
  const sessionInfo = readSessionInfoFromJsonl(jsonlPath);
153
155
 
156
+ if (sessionInfo.projectPath && !resolvedProjectPath) {
157
+ resolvedProjectPath = sessionInfo.projectPath;
158
+ }
159
+
154
160
  metadata[sessionId] = {
155
161
  customTitle: sessionInfo.customTitle,
156
162
  slug: sessionInfo.slug,
157
163
  project: sessionInfo.projectPath || null,
158
164
  jsonlPath: jsonlPath
159
165
  };
166
+ sessionIds.push(sessionId);
167
+ }
168
+
169
+ // Second pass: fill in missing project paths from siblings
170
+ if (resolvedProjectPath) {
171
+ for (const sid of sessionIds) {
172
+ if (!metadata[sid].project) {
173
+ metadata[sid].project = resolvedProjectPath;
174
+ }
175
+ }
160
176
  }
161
177
 
162
178
  // Also check sessions-index.json for custom names (if /rename was used)
@@ -167,8 +183,15 @@ function loadSessionMetadata() {
167
183
  const entries = indexData.entries || [];
168
184
 
169
185
  for (const entry of entries) {
170
- if (entry.sessionId && metadata[entry.sessionId]) {
171
- // Add other useful fields
186
+ if (entry.sessionId) {
187
+ if (!metadata[entry.sessionId]) {
188
+ metadata[entry.sessionId] = {
189
+ customTitle: null,
190
+ slug: null,
191
+ project: entry.projectPath || null,
192
+ jsonlPath: null
193
+ };
194
+ }
172
195
  metadata[entry.sessionId].description = entry.description || null;
173
196
  metadata[entry.sessionId].gitBranch = entry.gitBranch || null;
174
197
  metadata[entry.sessionId].created = entry.created || null;
@@ -183,6 +206,25 @@ function loadSessionMetadata() {
183
206
  console.error('Error loading session metadata:', e);
184
207
  }
185
208
 
209
+ // For team sessions with no JSONL match, try team config for project path
210
+ if (existsSync(TASKS_DIR)) {
211
+ const taskDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
212
+ .filter(d => d.isDirectory());
213
+ for (const dir of taskDirs) {
214
+ if (!metadata[dir.name]) {
215
+ const teamConfig = loadTeamConfig(dir.name);
216
+ if (teamConfig) {
217
+ metadata[dir.name] = {
218
+ customTitle: null,
219
+ slug: null,
220
+ project: teamConfig.working_dir || null,
221
+ jsonlPath: null
222
+ };
223
+ }
224
+ }
225
+ }
226
+ }
227
+
186
228
  sessionMetadataCache = metadata;
187
229
  lastMetadataRefresh = now;
188
230
  return metadata;
@@ -275,6 +317,32 @@ app.get('/api/sessions', async (req, res) => {
275
317
  }
276
318
  }
277
319
 
320
+ // Add sessions from metadata that don't have task directories
321
+ for (const [sessionId, meta] of Object.entries(metadata)) {
322
+ if (!sessionsMap.has(sessionId)) {
323
+ let modifiedAt = meta.created || null;
324
+ if (!modifiedAt && meta.jsonlPath) {
325
+ try { modifiedAt = statSync(meta.jsonlPath).mtime.toISOString(); } catch (e) {}
326
+ }
327
+ sessionsMap.set(sessionId, {
328
+ id: sessionId,
329
+ name: getSessionDisplayName(sessionId, meta),
330
+ slug: meta.slug || null,
331
+ project: meta.project || null,
332
+ description: meta.description || null,
333
+ gitBranch: meta.gitBranch || null,
334
+ taskCount: 0,
335
+ completed: 0,
336
+ inProgress: 0,
337
+ pending: 0,
338
+ createdAt: meta.created || null,
339
+ modifiedAt: modifiedAt || new Date(0).toISOString(),
340
+ isTeam: false,
341
+ memberCount: 0
342
+ });
343
+ }
344
+ }
345
+
278
346
  // Convert map to array and sort by most recently modified
279
347
  let sessions = Array.from(sessionsMap.values());
280
348
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -291,6 +359,24 @@ app.get('/api/sessions', async (req, res) => {
291
359
  }
292
360
  });
293
361
 
362
+ // API: Get distinct project paths with last-modified timestamps
363
+ app.get('/api/projects', (req, res) => {
364
+ res.setHeader('Cache-Control', 'no-store');
365
+ const metadata = loadSessionMetadata();
366
+ const projectMap = {};
367
+ for (const meta of Object.values(metadata)) {
368
+ if (!meta.project) continue;
369
+ const mtime = meta.jsonlPath ? (() => { try { return statSync(meta.jsonlPath).mtime; } catch (e) { return null; } })() : null;
370
+ if (!projectMap[meta.project] || (mtime && mtime > projectMap[meta.project])) {
371
+ projectMap[meta.project] = mtime;
372
+ }
373
+ }
374
+ const projects = Object.entries(projectMap)
375
+ .map(([path, mtime]) => ({ path, modifiedAt: mtime ? mtime.toISOString() : null }))
376
+ .sort((a, b) => a.path.localeCompare(b.path));
377
+ res.json(projects);
378
+ });
379
+
294
380
  // API: Get tasks for a session
295
381
  app.get('/api/sessions/:sessionId', async (req, res) => {
296
382
  try {
@@ -322,6 +408,45 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
322
408
  }
323
409
  });
324
410
 
411
+ // API: Get session plan
412
+ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
413
+ try {
414
+ const metadata = loadSessionMetadata();
415
+ const meta = metadata[req.params.sessionId];
416
+ const slug = meta?.slug;
417
+ if (!slug) return res.status(404).json({ error: 'No plan found' });
418
+
419
+ const planPath = path.join(PLANS_DIR, `${slug}.md`);
420
+ if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
421
+
422
+ const content = await fs.readFile(planPath, 'utf8');
423
+ res.json({ content, slug });
424
+ } catch (error) {
425
+ console.error('Error reading plan:', error);
426
+ res.status(500).json({ error: 'Failed to read plan' });
427
+ }
428
+ });
429
+
430
+ // API: Open session plan in VS Code
431
+ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
432
+ try {
433
+ const metadata = loadSessionMetadata();
434
+ const meta = metadata[req.params.sessionId];
435
+ const slug = meta?.slug;
436
+ if (!slug) return res.status(404).json({ error: 'No plan found' });
437
+
438
+ const planPath = path.join(PLANS_DIR, `${slug}.md`);
439
+ if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
440
+
441
+ const editor = process.env.EDITOR || 'code';
442
+ require('child_process').exec(`${editor} "${planPath}"`);
443
+ res.json({ success: true });
444
+ } catch (error) {
445
+ console.error('Error opening plan in VS Code:', error);
446
+ res.status(500).json({ error: 'Failed to open plan' });
447
+ }
448
+ });
449
+
325
450
  // API: Get team config
326
451
  app.get('/api/teams/:name', (req, res) => {
327
452
  const config = loadTeamConfig(req.params.name);