claude-code-kanban 3.3.0 → 3.4.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.4.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
@@ -478,9 +478,10 @@ async function fetchTasks(sessionId) {
478
478
  for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
479
479
  for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
480
480
  sessionJustSelected = true;
481
+ resetAgentState();
481
482
  updateUrl();
482
483
  renderSession();
483
- await fetchAgents(sessionId);
484
+ fetchAgents(sessionId);
484
485
  if (!agentLogMode) fetchMessages(sessionId);
485
486
  } catch (error) {
486
487
  console.error('Failed to fetch tasks:', error);
@@ -497,13 +498,18 @@ const _AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for
497
498
  const WAITING_TTL_MS = 30 * 60 * 1000;
498
499
  const AGENT_LOG_MAX = 8;
499
500
 
501
+ function resetAgentState() {
502
+ currentAgents = [];
503
+ currentWaiting = null;
504
+ lastAgentsHash = '';
505
+ renderAgentFooter();
506
+ }
507
+
500
508
  async function fetchAgents(sessionId) {
501
509
  try {
502
510
  const res = await fetch(`/api/sessions/${sessionId}/agents`);
503
511
  if (!res.ok) {
504
- currentAgents = [];
505
- currentWaiting = null;
506
- renderAgentFooter();
512
+ resetAgentState();
507
513
  return;
508
514
  }
509
515
  const data = await res.json();
@@ -632,6 +638,17 @@ function toggleMessagePanel() {
632
638
  updateUrl();
633
639
  }
634
640
 
641
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
642
+ async function openSessionWithBookmarks(sessionId) {
643
+ if (!messagePanelOpen) {
644
+ const panel = document.getElementById('message-panel');
645
+ messagePanelOpen = true;
646
+ panel.classList.add('visible');
647
+ document.getElementById('message-toggle')?.classList.add('active');
648
+ }
649
+ await fetchTasks(sessionId);
650
+ }
651
+
635
652
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
636
653
  async function viewAgentLog(agentId) {
637
654
  let agent = findAgentById(agentId);
@@ -1336,6 +1353,10 @@ const MARKETPLACE_SVG =
1336
1353
  '<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
1354
  const MEMORY_SVG =
1338
1355
  '<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>';
1356
+ const LINK_SVG_PATHS =
1357
+ '<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"/>';
1358
+ const linkSvg = (size) =>
1359
+ `<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
1360
 
1340
1361
  //#endregion
1341
1362
 
@@ -2064,10 +2085,7 @@ async function showAllTasks() {
2064
2085
  if (agentLogMode) exitAgentLogMode();
2065
2086
  currentSessionId = null;
2066
2087
  ownerFilter = '';
2067
- currentAgents = [];
2068
- currentWaiting = null;
2069
- lastAgentsHash = '';
2070
- renderAgentFooter();
2088
+ resetAgentState();
2071
2089
  const res = await fetch('/api/tasks/all');
2072
2090
  allTasksCache = await res.json();
2073
2091
  let tasks = allTasksCache;
@@ -2241,6 +2259,8 @@ function renderSessions() {
2241
2259
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
2242
2260
  const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
2243
2261
  const showCtx = !!session.contextStatus;
2262
+ const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2263
+ const bookmarksCount = loadPins(session.id).length;
2244
2264
  return `
2245
2265
  <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
2266
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
@@ -2251,9 +2271,11 @@ function renderSessions() {
2251
2271
  <div class="session-progress">
2252
2272
  <span class="session-indicators">
2253
2273
  ${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>` : ''}
2274
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2255
2275
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2256
2276
  ${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>` : ''}
2277
+ ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2278
+ ${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
2279
  ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2258
2280
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2259
2281
  ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
@@ -3531,6 +3553,7 @@ function _renderStorageTab() {
3531
3553
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
3532
3554
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
3533
3555
  else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
3556
+ else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
3534
3557
  }
3535
3558
 
3536
3559
  function _renderStorageSessions() {
@@ -3753,6 +3776,87 @@ function _storageDeleteScratchpad(key) {
3753
3776
  _updateStorageTotal();
3754
3777
  }
3755
3778
 
3779
+ function _renderStorageLinkedDocs() {
3780
+ const entries = [];
3781
+ for (let i = 0; i < localStorage.length; i++) {
3782
+ const key = localStorage.key(i);
3783
+ if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
3784
+ try {
3785
+ const arr = JSON.parse(localStorage.getItem(key)) || [];
3786
+ if (Array.isArray(arr) && arr.length) {
3787
+ entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
3788
+ }
3789
+ } catch {}
3790
+ }
3791
+ if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
3792
+
3793
+ const byId = new Map(entries.map((e) => [e.sessionId, e]));
3794
+ const { groups, orphans } = _groupByProject(entries.map((e) => e.sessionId));
3795
+
3796
+ function renderDocRow(sessionId, p) {
3797
+ const name = p.split(/[\\/]/).pop();
3798
+ const sid = _escapeForJsAttr(sessionId);
3799
+ const jsPath = _escapeForJsAttr(p);
3800
+ return `<div class="storage-item" style="padding-left:24px;">
3801
+ <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
3802
+ <div class="storage-item-actions">
3803
+ <button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
3804
+ <button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
3805
+ </div>
3806
+ </div>`;
3807
+ }
3808
+
3809
+ function renderSessionItem({ id, session }) {
3810
+ const entry = byId.get(id);
3811
+ if (!entry) return '';
3812
+ const eid = escapeHtml(id);
3813
+ const count = entry.paths.length;
3814
+ const header = `<div class="storage-group-header">
3815
+ <span>${_sessionLabel(session, id)} <span class="storage-item-badge">${count} doc${count > 1 ? 's' : ''}</span></span>
3816
+ <div class="storage-item-actions">
3817
+ <button class="danger" onclick="_storageClearLinkedDocs('${eid}')">Clear All</button>
3818
+ </div>
3819
+ </div>`;
3820
+ const rows = entry.paths.map((p) => renderDocRow(id, p)).join('');
3821
+ return header + rows;
3822
+ }
3823
+
3824
+ let html = '';
3825
+ for (const [project, items] of groups) {
3826
+ const count = items.length;
3827
+ html += _renderProjectGroup(
3828
+ escapeHtml(_projectLabel(project)),
3829
+ `${count} session${count > 1 ? 's' : ''}`,
3830
+ items.map(renderSessionItem).join(''),
3831
+ );
3832
+ }
3833
+ if (orphans.length) {
3834
+ html += _renderOrphanGroup(orphans.length, orphans.map(renderSessionItem).join(''));
3835
+ }
3836
+ return html;
3837
+ }
3838
+
3839
+ function _storagePreviewLinkedDoc(path) {
3840
+ closeStorageManager();
3841
+ openPreviewByPath(path);
3842
+ }
3843
+
3844
+ function _storageUnlinkDoc(sessionId, path) {
3845
+ removeSessionPreviewPath(sessionId, path);
3846
+ if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
3847
+ renderSessions();
3848
+ _renderStorageTab();
3849
+ _updateStorageTotal();
3850
+ }
3851
+
3852
+ function _storageClearLinkedDocs(sessionId) {
3853
+ localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
3854
+ if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
3855
+ renderSessions();
3856
+ _renderStorageTab();
3857
+ _updateStorageTotal();
3858
+ }
3859
+
3756
3860
  function _findOrphanedKeys() {
3757
3861
  const known = _getKnownSessionIds();
3758
3862
  if (!known.size) return [];
@@ -3765,6 +3869,8 @@ function _findOrphanedKeys() {
3765
3869
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
3766
3870
  } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
3767
3871
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
3872
+ } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
3873
+ if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
3768
3874
  }
3769
3875
  }
3770
3876
  return orphaned;
@@ -4136,6 +4242,7 @@ function togglePreviewSessionLink() {
4136
4242
  if (_infoModalSessionId === currentSessionId) {
4137
4243
  refreshInfoModalLinkedDocs();
4138
4244
  }
4245
+ renderSessions();
4139
4246
  }
4140
4247
 
4141
4248
  function refreshInfoModalLinkedDocs() {
@@ -4212,11 +4319,11 @@ function handleSessionOpenEvent(data) {
4212
4319
  fetchTasks(id);
4213
4320
  }
4214
4321
 
4215
- function handlePreviewOpenEvent(data) {
4322
+ async function handlePreviewOpenEvent(data) {
4216
4323
  const { path: filePath, content, sessionId } = data;
4217
4324
  if (sessionId && sessionId !== currentSessionId) {
4218
4325
  if (sessions.find((s) => s.id === sessionId)) {
4219
- fetchTasks(sessionId);
4326
+ await fetchTasks(sessionId);
4220
4327
  } else {
4221
4328
  showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
4222
4329
  }
@@ -4234,7 +4341,11 @@ function renderLinkedDocsHtml(sessionId) {
4234
4341
  })
4235
4342
  .join(', ');
4236
4343
  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>
4344
+ <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;">
4345
+ ${linkSvg(12)}
4346
+ <span>Linked documents</span>
4347
+ <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>
4348
+ </div>
4238
4349
  <div>${items}</div>
4239
4350
  </div>`;
4240
4351
  }
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
@@ -2498,6 +2498,28 @@ body::before {
2498
2498
  cursor: default;
2499
2499
  }
2500
2500
 
2501
+ .linked-docs-badge,
2502
+ .bookmarks-badge {
2503
+ display: inline-flex;
2504
+ align-items: center;
2505
+ gap: 2px;
2506
+ font-size: 10px;
2507
+ padding: 2px 6px;
2508
+ background: var(--bg-elevated);
2509
+ border: 1px solid var(--border);
2510
+ border-radius: 10px;
2511
+ color: var(--text-secondary);
2512
+ cursor: pointer;
2513
+ flex-shrink: 0;
2514
+ line-height: 1;
2515
+ }
2516
+
2517
+ .linked-docs-badge:hover,
2518
+ .bookmarks-badge:hover {
2519
+ border-color: var(--accent);
2520
+ color: var(--text-primary);
2521
+ }
2522
+
2501
2523
  /* #endregion */
2502
2524
 
2503
2525
  /* #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) {