claude-code-kanban 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -582,8 +582,9 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
582
582
  };
583
583
  }
584
584
 
585
- function buildAgentProgressMap(jsonlPath) {
585
+ function buildSessionDigest(jsonlPath) {
586
586
  const map = {};
587
+ const terminated = new Map();
587
588
  try {
588
589
  const content = readFileSync(jsonlPath, 'utf8');
589
590
  const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
@@ -596,6 +597,34 @@ function buildAgentProgressMap(jsonlPath) {
596
597
  const nameByToolUseId = {};
597
598
  const descByToolUseId = {};
598
599
  for (const line of content.split('\n')) {
600
+ // Terminated-teammate detection: check first since cheap substring guards
601
+ if (line.includes('teammate-message') &&
602
+ (line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
603
+ try {
604
+ const obj = JSON.parse(line);
605
+ if (obj.type === 'user') {
606
+ const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
607
+ if (text) {
608
+ const ts = obj.timestamp || null;
609
+ for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
610
+ try {
611
+ const tid = tmMatch[1];
612
+ const body = tmMatch[2].trim();
613
+ const protocol = JSON.parse(body);
614
+ if (protocol.type === 'teammate_terminated') {
615
+ const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
616
+ if (name !== 'system') terminated.set(name, ts);
617
+ } else if (protocol.type === 'shutdown_response' && protocol.approve) {
618
+ const name = protocol.from || tid;
619
+ if (name !== 'system') terminated.set(name, ts);
620
+ }
621
+ } catch (_) {}
622
+ }
623
+ }
624
+ }
625
+ } catch (_) {}
626
+ }
627
+
599
628
  if (line.includes('"agent_progress"')) {
600
629
  const agentMatch = re.exec(line);
601
630
  const parentMatch = parentRe.exec(line);
@@ -657,7 +686,11 @@ function buildAgentProgressMap(jsonlPath) {
657
686
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
658
687
  }
659
688
  } catch (_) {}
660
- return map;
689
+ return { progressMap: map, terminated };
690
+ }
691
+
692
+ function buildAgentProgressMap(jsonlPath) {
693
+ return buildSessionDigest(jsonlPath).progressMap;
661
694
  }
662
695
 
663
696
  function readCompactSummaries(jsonlPath) {
@@ -699,36 +732,7 @@ function readCompactSummaries(jsonlPath) {
699
732
  }
700
733
 
701
734
  function findTerminatedTeammates(jsonlPath) {
702
- const terminated = new Map();
703
- try {
704
- const content = readFileSync(jsonlPath, 'utf8');
705
- for (const line of content.split('\n')) {
706
- if (!line.includes('teammate-message')) continue;
707
- if (!line.includes('teammate_terminated') && !line.includes('shutdown_response')) continue;
708
- try {
709
- const obj = JSON.parse(line);
710
- if (obj.type !== 'user') continue;
711
- const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
712
- if (!text) continue;
713
- const ts = obj.timestamp || null;
714
- for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
715
- try {
716
- const tid = tmMatch[1];
717
- const body = tmMatch[2].trim();
718
- const protocol = JSON.parse(body);
719
- if (protocol.type === 'teammate_terminated') {
720
- const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
721
- if (name !== 'system') terminated.set(name, ts);
722
- } else if (protocol.type === 'shutdown_response' && protocol.approve) {
723
- const name = protocol.from || tid;
724
- if (name !== 'system') terminated.set(name, ts);
725
- }
726
- } catch (_) {}
727
- }
728
- } catch (_) {}
729
- }
730
- } catch (_) {}
731
- return terminated;
735
+ return buildSessionDigest(jsonlPath).terminated;
732
736
  }
733
737
 
734
738
  function extractPromptFromTranscript(jsonlPath) {
@@ -766,6 +770,36 @@ function extractPromptFromTranscript(jsonlPath) {
766
770
  return null;
767
771
  }
768
772
 
773
+ function extractModelFromTranscript(jsonlPath) {
774
+ const { openSync, readSync, closeSync } = fs;
775
+ const MAX_READ = 65536;
776
+ const CHUNK = 4096;
777
+ const fd = openSync(jsonlPath, 'r');
778
+ try {
779
+ let accumulated = '';
780
+ const buf = Buffer.alloc(CHUNK);
781
+ while (accumulated.length < MAX_READ) {
782
+ const bytesRead = readSync(fd, buf, 0, CHUNK, null);
783
+ if (bytesRead === 0) break;
784
+ accumulated += buf.toString('utf8', 0, bytesRead);
785
+ let nlIdx;
786
+ while ((nlIdx = accumulated.indexOf('\n')) !== -1) {
787
+ const line = accumulated.slice(0, nlIdx);
788
+ accumulated = accumulated.slice(nlIdx + 1);
789
+ if (!line.trim()) continue;
790
+ try {
791
+ const obj = JSON.parse(line);
792
+ const model = obj.model || (obj.message && obj.message.model);
793
+ if (model) return model;
794
+ } catch (_) {}
795
+ }
796
+ }
797
+ } finally {
798
+ closeSync(fd);
799
+ }
800
+ return null;
801
+ }
802
+
769
803
  module.exports = {
770
804
  parseTask,
771
805
  parseAgent,
@@ -777,7 +811,9 @@ module.exports = {
777
811
  readRecentMessages,
778
812
  readMessagesPage,
779
813
  buildAgentProgressMap,
814
+ buildSessionDigest,
780
815
  readCompactSummaries,
781
816
  findTerminatedTeammates,
782
- extractPromptFromTranscript
817
+ extractPromptFromTranscript,
818
+ extractModelFromTranscript
783
819
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.3.0",
3
+ "version": "3.5.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/app.js CHANGED
@@ -78,11 +78,34 @@ function updateUrl() {
78
78
  const qs = params.toString();
79
79
  const url = qs ? `?${qs}` : window.location.pathname;
80
80
  history.replaceState(null, '', url);
81
+ persistLastView();
82
+ }
83
+
84
+ const LAST_VIEW_KEY = 'lastView';
85
+ function persistLastView() {
86
+ try {
87
+ const data = {
88
+ view: viewMode,
89
+ session: currentSessionId,
90
+ projectPath: viewMode === 'project' ? currentProjectPath : null,
91
+ };
92
+ localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(data));
93
+ } catch (_) {}
94
+ }
95
+ function loadLastView() {
96
+ try {
97
+ return JSON.parse(localStorage.getItem(LAST_VIEW_KEY)) || null;
98
+ } catch (_) {
99
+ return null;
100
+ }
81
101
  }
82
102
 
83
103
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
84
104
  function resetState() {
85
105
  history.replaceState(null, '', window.location.pathname);
106
+ try {
107
+ localStorage.removeItem(LAST_VIEW_KEY);
108
+ } catch (_) {}
86
109
  sessionFilter = 'active';
87
110
  sessionLimit = '20';
88
111
  filterProject = '__recent__';
@@ -478,9 +501,10 @@ async function fetchTasks(sessionId) {
478
501
  for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
479
502
  for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
480
503
  sessionJustSelected = true;
504
+ resetAgentState();
481
505
  updateUrl();
482
506
  renderSession();
483
- await fetchAgents(sessionId);
507
+ fetchAgents(sessionId);
484
508
  if (!agentLogMode) fetchMessages(sessionId);
485
509
  } catch (error) {
486
510
  console.error('Failed to fetch tasks:', error);
@@ -497,13 +521,18 @@ const _AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for
497
521
  const WAITING_TTL_MS = 30 * 60 * 1000;
498
522
  const AGENT_LOG_MAX = 8;
499
523
 
524
+ function resetAgentState() {
525
+ currentAgents = [];
526
+ currentWaiting = null;
527
+ lastAgentsHash = '';
528
+ renderAgentFooter();
529
+ }
530
+
500
531
  async function fetchAgents(sessionId) {
501
532
  try {
502
533
  const res = await fetch(`/api/sessions/${sessionId}/agents`);
503
534
  if (!res.ok) {
504
- currentAgents = [];
505
- currentWaiting = null;
506
- renderAgentFooter();
535
+ resetAgentState();
507
536
  return;
508
537
  }
509
538
  const data = await res.json();
@@ -632,6 +661,17 @@ function toggleMessagePanel() {
632
661
  updateUrl();
633
662
  }
634
663
 
664
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
665
+ async function openSessionWithBookmarks(sessionId) {
666
+ if (!messagePanelOpen) {
667
+ const panel = document.getElementById('message-panel');
668
+ messagePanelOpen = true;
669
+ panel.classList.add('visible');
670
+ document.getElementById('message-toggle')?.classList.add('active');
671
+ }
672
+ await fetchTasks(sessionId);
673
+ }
674
+
635
675
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
636
676
  async function viewAgentLog(agentId) {
637
677
  let agent = findAgentById(agentId);
@@ -1336,6 +1376,10 @@ const MARKETPLACE_SVG =
1336
1376
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>';
1337
1377
  const MEMORY_SVG =
1338
1378
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>';
1379
+ const LINK_SVG_PATHS =
1380
+ '<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"/>';
1381
+ const linkSvg = (size) =>
1382
+ `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LINK_SVG_PATHS}</svg>`;
1339
1383
 
1340
1384
  //#endregion
1341
1385
 
@@ -1978,34 +2022,35 @@ function showAgentModal(agentId) {
1978
2022
  const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
1979
2023
  title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
1980
2024
 
1981
- const rows = [
1982
- ['Status', agent.status],
1983
- ['Agent ID', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.agentId)}</code>`],
1984
- ['Duration', formatDuration(elapsed)],
1985
- ];
2025
+ const shortModel = agent.model ? agent.model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : null;
2026
+ const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
2027
+ const chip = (label, value, opts = {}) => {
2028
+ const cls = opts.cls ? ` ${opts.cls}` : '';
2029
+ const style = opts.style ? ` style="${opts.style}"` : '';
2030
+ const title = opts.title ? ` title="${escapeHtml(opts.title)}"` : '';
2031
+ const labelHtml = label ? `<span class="agent-chip-label">${label}</span>` : '';
2032
+ return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
2033
+ };
2034
+
2035
+ const chips = [];
2036
+ if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
2037
+ chips.push(chip('', escapeHtml(agent.status), { cls: `agent-chip-status agent-chip-${agent.status}` }));
2038
+ chips.push(chip('⏱', formatDuration(elapsed)));
2039
+ if (shortModel) chips.push(chip('model', escapeHtml(shortModel), { cls: 'agent-chip-mono' }));
1986
2040
  if (agent.agentName) {
1987
- const ownerColor = getOwnerColor(agent.agentName);
1988
- rows.push([
1989
- 'Owner',
1990
- `<span class="task-owner-badge" style="background:${ownerColor.bg};color:${ownerColor.color}">${escapeHtml(agent.agentName)}</span>`,
1991
- ]);
2041
+ const c = getOwnerColor(agent.agentName);
2042
+ chips.push(
2043
+ chip('owner', escapeHtml(agent.agentName), {
2044
+ style: `background:${c.bg};color:${c.color};border-color:transparent;`,
2045
+ }),
2046
+ );
1992
2047
  }
1993
- if (agent.model)
1994
- rows.push(['Model', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.model)}</code>`]);
1995
- if (started) rows.push(['Started', started.toLocaleTimeString()]);
1996
- if (stopped) rows.push(['Stopped', stopped.toLocaleTimeString()]);
2048
+ if (started) chips.push(chip('started', started.toLocaleTimeString()));
2049
+ if (stopped) chips.push(chip('stopped', stopped.toLocaleTimeString()));
1997
2050
 
1998
2051
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
1999
2052
 
2000
- let html =
2001
- `<table style="width:100%;border-collapse:collapse;">` +
2002
- rows
2003
- .map(
2004
- ([k, v]) =>
2005
- `<tr><td style="padding:6px 12px 6px 0;color:var(--text-tertiary);white-space:nowrap;vertical-align:top;">${k}</td><td style="padding:6px 0;color:var(--text-primary);">${v}</td></tr>`,
2006
- )
2007
- .join('') +
2008
- `</table>`;
2053
+ let html = `<div class="agent-chips">${chips.join('')}</div>`;
2009
2054
 
2010
2055
  const promptText = stripTeammateWrapper(agentMsg?.agentPrompt || agent.prompt || null);
2011
2056
  const responseText = agent.lastMessage ? stripAnsi(agent.lastMessage.trim()) : null;
@@ -2064,10 +2109,7 @@ async function showAllTasks() {
2064
2109
  if (agentLogMode) exitAgentLogMode();
2065
2110
  currentSessionId = null;
2066
2111
  ownerFilter = '';
2067
- currentAgents = [];
2068
- currentWaiting = null;
2069
- lastAgentsHash = '';
2070
- renderAgentFooter();
2112
+ resetAgentState();
2071
2113
  const res = await fetch('/api/tasks/all');
2072
2114
  allTasksCache = await res.json();
2073
2115
  let tasks = allTasksCache;
@@ -2241,6 +2283,8 @@ function renderSessions() {
2241
2283
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
2242
2284
  const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
2243
2285
  const showCtx = !!session.contextStatus;
2286
+ const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2287
+ const bookmarksCount = loadPins(session.id).length;
2244
2288
  return `
2245
2289
  <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${!session.hasRecentLog && !session.inProgress && !session.hasWaitingForUser ? 'stale' : ''} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
2246
2290
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
@@ -2251,9 +2295,11 @@ function renderSessions() {
2251
2295
  <div class="session-progress">
2252
2296
  <span class="session-indicators">
2253
2297
  ${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>` : ''}
2254
- ${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>` : ''}
2298
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2255
2299
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2256
2300
  ${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>` : ''}
2301
+ ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2302
+ ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
2257
2303
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2258
2304
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2259
2305
  ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
@@ -3531,6 +3577,7 @@ function _renderStorageTab() {
3531
3577
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
3532
3578
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
3533
3579
  else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
3580
+ else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
3534
3581
  }
3535
3582
 
3536
3583
  function _renderStorageSessions() {
@@ -3753,6 +3800,87 @@ function _storageDeleteScratchpad(key) {
3753
3800
  _updateStorageTotal();
3754
3801
  }
3755
3802
 
3803
+ function _renderStorageLinkedDocs() {
3804
+ const entries = [];
3805
+ for (let i = 0; i < localStorage.length; i++) {
3806
+ const key = localStorage.key(i);
3807
+ if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
3808
+ try {
3809
+ const arr = JSON.parse(localStorage.getItem(key)) || [];
3810
+ if (Array.isArray(arr) && arr.length) {
3811
+ entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
3812
+ }
3813
+ } catch {}
3814
+ }
3815
+ if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
3816
+
3817
+ const byId = new Map(entries.map((e) => [e.sessionId, e]));
3818
+ const { groups, orphans } = _groupByProject(entries.map((e) => e.sessionId));
3819
+
3820
+ function renderDocRow(sessionId, p) {
3821
+ const name = p.split(/[\\/]/).pop();
3822
+ const sid = _escapeForJsAttr(sessionId);
3823
+ const jsPath = _escapeForJsAttr(p);
3824
+ return `<div class="storage-item" style="padding-left:24px;">
3825
+ <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
3826
+ <div class="storage-item-actions">
3827
+ <button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
3828
+ <button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
3829
+ </div>
3830
+ </div>`;
3831
+ }
3832
+
3833
+ function renderSessionItem({ id, session }) {
3834
+ const entry = byId.get(id);
3835
+ if (!entry) return '';
3836
+ const eid = escapeHtml(id);
3837
+ const count = entry.paths.length;
3838
+ const header = `<div class="storage-group-header">
3839
+ <span>${_sessionLabel(session, id)} <span class="storage-item-badge">${count} doc${count > 1 ? 's' : ''}</span></span>
3840
+ <div class="storage-item-actions">
3841
+ <button class="danger" onclick="_storageClearLinkedDocs('${eid}')">Clear All</button>
3842
+ </div>
3843
+ </div>`;
3844
+ const rows = entry.paths.map((p) => renderDocRow(id, p)).join('');
3845
+ return header + rows;
3846
+ }
3847
+
3848
+ let html = '';
3849
+ for (const [project, items] of groups) {
3850
+ const count = items.length;
3851
+ html += _renderProjectGroup(
3852
+ escapeHtml(_projectLabel(project)),
3853
+ `${count} session${count > 1 ? 's' : ''}`,
3854
+ items.map(renderSessionItem).join(''),
3855
+ );
3856
+ }
3857
+ if (orphans.length) {
3858
+ html += _renderOrphanGroup(orphans.length, orphans.map(renderSessionItem).join(''));
3859
+ }
3860
+ return html;
3861
+ }
3862
+
3863
+ function _storagePreviewLinkedDoc(path) {
3864
+ closeStorageManager();
3865
+ openPreviewByPath(path);
3866
+ }
3867
+
3868
+ function _storageUnlinkDoc(sessionId, path) {
3869
+ removeSessionPreviewPath(sessionId, path);
3870
+ if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
3871
+ renderSessions();
3872
+ _renderStorageTab();
3873
+ _updateStorageTotal();
3874
+ }
3875
+
3876
+ function _storageClearLinkedDocs(sessionId) {
3877
+ localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
3878
+ if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
3879
+ renderSessions();
3880
+ _renderStorageTab();
3881
+ _updateStorageTotal();
3882
+ }
3883
+
3756
3884
  function _findOrphanedKeys() {
3757
3885
  const known = _getKnownSessionIds();
3758
3886
  if (!known.size) return [];
@@ -3765,6 +3893,8 @@ function _findOrphanedKeys() {
3765
3893
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
3766
3894
  } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
3767
3895
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
3896
+ } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
3897
+ if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
3768
3898
  }
3769
3899
  }
3770
3900
  return orphaned;
@@ -4136,6 +4266,7 @@ function togglePreviewSessionLink() {
4136
4266
  if (_infoModalSessionId === currentSessionId) {
4137
4267
  refreshInfoModalLinkedDocs();
4138
4268
  }
4269
+ renderSessions();
4139
4270
  }
4140
4271
 
4141
4272
  function refreshInfoModalLinkedDocs() {
@@ -4212,11 +4343,11 @@ function handleSessionOpenEvent(data) {
4212
4343
  fetchTasks(id);
4213
4344
  }
4214
4345
 
4215
- function handlePreviewOpenEvent(data) {
4346
+ async function handlePreviewOpenEvent(data) {
4216
4347
  const { path: filePath, content, sessionId } = data;
4217
4348
  if (sessionId && sessionId !== currentSessionId) {
4218
4349
  if (sessions.find((s) => s.id === sessionId)) {
4219
- fetchTasks(sessionId);
4350
+ await fetchTasks(sessionId);
4220
4351
  } else {
4221
4352
  showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
4222
4353
  }
@@ -4234,7 +4365,11 @@ function renderLinkedDocsHtml(sessionId) {
4234
4365
  })
4235
4366
  .join(', ');
4236
4367
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
4237
- <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Linked documents</div>
4368
+ <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
4369
+ ${linkSvg(12)}
4370
+ <span>Linked documents</span>
4371
+ <span style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</span>
4372
+ </div>
4238
4373
  <div>${items}</div>
4239
4374
  </div>`;
4240
4375
  }
@@ -5642,8 +5777,21 @@ Promise.all([
5642
5777
  }
5643
5778
  } else if (urlState.session) {
5644
5779
  await fetchTasks(urlState.session);
5645
- } else {
5780
+ } else if (urlState.view === 'all') {
5646
5781
  showAllTasks();
5782
+ } else {
5783
+ const last = loadLastView();
5784
+ if (last?.view === 'project' && last.projectPath && sessions.some((s) => s.project === last.projectPath)) {
5785
+ try {
5786
+ await fetchProjectView(last.projectPath);
5787
+ } catch (_) {
5788
+ showAllTasks();
5789
+ }
5790
+ } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
5791
+ await fetchTasks(last.session);
5792
+ } else {
5793
+ showAllTasks();
5794
+ }
5647
5795
  }
5648
5796
  if (urlState.messages && currentSessionId) {
5649
5797
  toggleMessagePanel();
package/public/index.html CHANGED
@@ -544,7 +544,7 @@
544
544
  </div>
545
545
  <div id="team-modal-body" class="modal-body"></div>
546
546
  <div class="modal-footer">
547
- <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId)">Dismiss</button>
547
+ <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Dismiss</button>
548
548
  <button class="btn btn-primary" onclick="closeTeamModal()">Close</button>
549
549
  </div>
550
550
  </div>
@@ -671,6 +671,7 @@
671
671
  <div class="storage-tabs">
672
672
  <button class="storage-tab active" data-tab="sessions" onclick="switchStorageTab('sessions')">Sessions</button>
673
673
  <button class="storage-tab" data-tab="scratchpads" onclick="switchStorageTab('scratchpads')">Scratchpads</button>
674
+ <button class="storage-tab" data-tab="linked-docs" onclick="switchStorageTab('linked-docs')">Linked Docs</button>
674
675
  </div>
675
676
  <div class="modal-body" id="storage-modal-body" style="overflow-y:auto;min-height:200px;max-height:60vh;padding-top:16px;padding-right:8px;"></div>
676
677
  <div class="modal-footer">
package/public/style.css CHANGED
@@ -1707,6 +1707,61 @@ body::before {
1707
1707
  flex-shrink: 0;
1708
1708
  }
1709
1709
 
1710
+ .agent-chips {
1711
+ display: flex;
1712
+ flex-wrap: wrap;
1713
+ gap: 6px;
1714
+ margin-bottom: 10px;
1715
+ }
1716
+ .agent-chip {
1717
+ display: inline-flex;
1718
+ align-items: center;
1719
+ gap: 4px;
1720
+ padding: 3px 9px;
1721
+ font-size: 11px;
1722
+ font-weight: 500;
1723
+ border-radius: 999px;
1724
+ border: 1px solid var(--border-color, rgba(127, 127, 127, 0.25));
1725
+ background: var(--bg-secondary, rgba(127, 127, 127, 0.08));
1726
+ color: var(--text-secondary);
1727
+ white-space: nowrap;
1728
+ line-height: 1.4;
1729
+ }
1730
+ .agent-chip-label {
1731
+ color: var(--text-tertiary);
1732
+ text-transform: uppercase;
1733
+ font-size: 9.5px;
1734
+ font-weight: 600;
1735
+ letter-spacing: 0.5px;
1736
+ opacity: 0.75;
1737
+ }
1738
+ .agent-chip-val {
1739
+ color: inherit;
1740
+ }
1741
+ .agent-chip-mono .agent-chip-val {
1742
+ font-family: var(--font-mono, monospace);
1743
+ font-size: 10.5px;
1744
+ letter-spacing: 0.2px;
1745
+ }
1746
+ .agent-chip-status {
1747
+ text-transform: capitalize;
1748
+ }
1749
+ .agent-chip-running {
1750
+ background: rgba(34, 197, 94, 0.15);
1751
+ color: rgb(34, 160, 80);
1752
+ border-color: rgba(34, 197, 94, 0.3);
1753
+ }
1754
+ .agent-chip-stopped {
1755
+ background: rgba(127, 127, 127, 0.15);
1756
+ color: var(--text-tertiary);
1757
+ border-color: rgba(127, 127, 127, 0.3);
1758
+ }
1759
+ .agent-chip-error,
1760
+ .agent-chip-failed {
1761
+ background: rgba(239, 68, 68, 0.15);
1762
+ color: rgb(220, 60, 60);
1763
+ border-color: rgba(239, 68, 68, 0.3);
1764
+ }
1710
1765
  .team-modal-desc {
1711
1766
  font-size: 12px;
1712
1767
  color: var(--text-secondary);
@@ -2498,6 +2553,28 @@ body::before {
2498
2553
  cursor: default;
2499
2554
  }
2500
2555
 
2556
+ .linked-docs-badge,
2557
+ .bookmarks-badge {
2558
+ display: inline-flex;
2559
+ align-items: center;
2560
+ gap: 2px;
2561
+ font-size: 10px;
2562
+ padding: 2px 6px;
2563
+ background: var(--bg-elevated);
2564
+ border: 1px solid var(--border);
2565
+ border-radius: 10px;
2566
+ color: var(--text-secondary);
2567
+ cursor: pointer;
2568
+ flex-shrink: 0;
2569
+ line-height: 1;
2570
+ }
2571
+
2572
+ .linked-docs-badge:hover,
2573
+ .bookmarks-badge:hover {
2574
+ border-color: var(--accent);
2575
+ color: var(--text-primary);
2576
+ }
2577
+
2501
2578
  /* #endregion */
2502
2579
 
2503
2580
  /* #region PERMISSION_PENDING */
package/server.js CHANGED
@@ -15,9 +15,11 @@ const {
15
15
  readMessagesPage: _readMessagesPageUncached,
16
16
  readSessionInfoFromJsonl,
17
17
  buildAgentProgressMap,
18
+ buildSessionDigest,
18
19
  readCompactSummaries,
19
20
  findTerminatedTeammates,
20
- extractPromptFromTranscript
21
+ extractPromptFromTranscript,
22
+ extractModelFromTranscript
21
23
  } = require('./lib/parsers');
22
24
 
23
25
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -80,7 +82,10 @@ const WAITING_RESOLVE_GRACE_MS = 15000;
80
82
 
81
83
  function persistAgent(dir, agent) {
82
84
  const file = path.join(dir, agent.agentId + '.json');
83
- fs.writeFile(file, JSON.stringify(agent), 'utf8').catch(() => {});
85
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
86
+ fs.writeFile(tmp, JSON.stringify(agent), 'utf8')
87
+ .then(() => fs.rename(tmp, file))
88
+ .catch(() => { fs.unlink(tmp).catch(() => {}); });
84
89
  }
85
90
 
86
91
  function checkWaitingForUser(agentDir, logMtime) {
@@ -210,8 +215,6 @@ app.use(express.static(path.join(__dirname, 'public')));
210
215
  const messageCache = new Map();
211
216
  const MESSAGE_CACHE_TTL = 5000;
212
217
  const MAX_CACHE_ENTRIES = 200;
213
- const progressMapCache = new Map();
214
- const terminatedCache = new Map();
215
218
  const compactSummaryCache = new Map();
216
219
  const taskCountsCache = new Map();
217
220
  const contextStatusCache = new Map();
@@ -325,12 +328,17 @@ function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
325
328
  } catch (_) { return fallback; }
326
329
  }
327
330
 
331
+ const sessionDigestCache = new Map();
332
+ function getSessionDigest(jsonlPath) {
333
+ return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
334
+ }
335
+
328
336
  function getProgressMap(jsonlPath) {
329
- return cachedByMtime(progressMapCache, jsonlPath, jsonlPath, () => buildAgentProgressMap(jsonlPath), {});
337
+ return getSessionDigest(jsonlPath).progressMap;
330
338
  }
331
339
 
332
340
  function getTerminatedTeammates(jsonlPath) {
333
- return cachedByMtime(terminatedCache, jsonlPath, jsonlPath, () => findTerminatedTeammates(jsonlPath), new Set());
341
+ return getSessionDigest(jsonlPath).terminated;
334
342
  }
335
343
 
336
344
  function readRecentMessages(jsonlPath, limit = 10) {
@@ -965,10 +973,14 @@ app.post('/api/open-folder', (req, res) => {
965
973
  }
966
974
  });
967
975
 
968
- // API: Open content in editor as temp file
976
+ // API: Open file in editor — either an existing path ({ file }) or content as a temp file ({ content, title })
969
977
  app.post('/api/open-in-editor', (req, res) => {
970
978
  try {
971
- const { content, title } = req.body;
979
+ const { content, title, file } = req.body;
980
+ if (file) {
981
+ openInEditor(file);
982
+ return res.json({ success: true, path: file });
983
+ }
972
984
  if (!content) return res.status(400).json({ error: 'No content provided' });
973
985
 
974
986
  const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
@@ -1056,14 +1068,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1056
1068
  } catch (_) {}
1057
1069
  }
1058
1070
 
1059
- function persistPrompt(agent, prompt) {
1060
- agent.prompt = prompt;
1061
- persistAgent(agentDir, agent);
1062
- }
1071
+ const dirty = new Set();
1063
1072
 
1064
- const agentsNeedingPrompt = agents.filter(a => !a.prompt);
1065
- const agentsNeedingName = agents.filter(a => !a.agentName);
1066
- const agentsNeedingDesc = agents.filter(a => !a.description);
1073
+ const agentsNeedingPrompt = agents.filter(a => !a.prompt && !a.promptUnavailable);
1074
+ const agentsNeedingName = agents.filter(a => !a.agentName && !a.agentNameUnavailable);
1075
+ const agentsNeedingDesc = agents.filter(a => !a.description && !a.descriptionUnavailable);
1067
1076
  if ((agentsNeedingPrompt.length || agentsNeedingName.length || agentsNeedingDesc.length) && meta.jsonlPath) {
1068
1077
  let byAgentId = {};
1069
1078
  let nameByAgentId = {};
@@ -1079,37 +1088,34 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1079
1088
  for (const agent of agentsNeedingPrompt) {
1080
1089
  const prompt = byAgentId[agent.agentId]
1081
1090
  || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1082
- if (prompt) persistPrompt(agent, prompt);
1091
+ if (prompt) agent.prompt = prompt;
1092
+ else agent.promptUnavailable = true;
1093
+ dirty.add(agent);
1083
1094
  }
1084
1095
  for (const agent of agentsNeedingName) {
1085
1096
  if (nameByAgentId[agent.agentId]) agent.agentName = nameByAgentId[agent.agentId];
1097
+ else agent.agentNameUnavailable = true;
1098
+ dirty.add(agent);
1086
1099
  }
1087
1100
  for (const agent of agentsNeedingDesc) {
1088
1101
  if (descByAgentId[agent.agentId]) agent.description = descByAgentId[agent.agentId];
1102
+ else agent.descriptionUnavailable = true;
1103
+ dirty.add(agent);
1089
1104
  }
1090
1105
  }
1091
1106
 
1092
- const agentsNeedingModel = agents.filter(a => !a.model);
1107
+ const agentsNeedingModel = agents.filter(a => !a.model && !a.modelUnavailable);
1093
1108
  if (agentsNeedingModel.length && meta.jsonlPath) {
1094
1109
  for (const agent of agentsNeedingModel) {
1095
- try {
1096
- const jsonl = subagentJsonlPath(meta, agent.agentId);
1097
- const content = readFileSync(jsonl, 'utf8');
1098
- for (const line of content.split('\n')) {
1099
- if (!line.trim()) continue;
1100
- try {
1101
- const obj = JSON.parse(line);
1102
- const model = obj.model || (obj.message && obj.message.model);
1103
- if (model) {
1104
- agent.model = model;
1105
- persistAgent(agentDir, agent);
1106
- break;
1107
- }
1108
- } catch (_) {}
1109
- }
1110
- } catch (_) {}
1110
+ let model = null;
1111
+ try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1112
+ if (model) agent.model = model;
1113
+ else agent.modelUnavailable = true;
1114
+ dirty.add(agent);
1111
1115
  }
1112
1116
  }
1117
+
1118
+ for (const agent of dirty) persistAgent(agentDir, agent);
1113
1119
  const teamColors = {};
1114
1120
  if (teamConfig?.members) {
1115
1121
  for (const m of teamConfig.members) {