claude-code-kanban 3.5.1 → 3.6.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/cli.js CHANGED
@@ -42,6 +42,16 @@ const COMMANDS = {
42
42
  },
43
43
  run: runSessionViewCli,
44
44
  },
45
+ pin: {
46
+ summary: 'Pin (or unpin) a session in the sidebar of connected browser tabs',
47
+ usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
48
+ flags: {
49
+ '<id>': 'Full session id, or a unique prefix',
50
+ '--sticky': 'Set sticky state (always shown, top of list)',
51
+ '--unpin': 'Clear pin/sticky state',
52
+ },
53
+ run: runSessionPinCli,
54
+ },
45
55
  peek: {
46
56
  summary: 'Show the last N messages from a session',
47
57
  usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
@@ -361,6 +371,31 @@ async function runSessionOpenCli(args) {
361
371
  } catch (e) { reportCliError(e); return 1; }
362
372
  }
363
373
 
374
+ async function runSessionPinCli(args) {
375
+ const idArg = args.find(a => !a.startsWith('--'));
376
+ if (!idArg) {
377
+ printLeafHelp('session pin', COMMANDS.session.verbs.pin);
378
+ return 1;
379
+ }
380
+ const state = args.includes('--unpin') ? 'none' : args.includes('--sticky') ? 'sticky' : 'pinned';
381
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
382
+ if (!resolved) return 1;
383
+ try {
384
+ const res = await cliFetch('/api/session/pin', {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({ id: resolved.id, state })
388
+ });
389
+ if (!res.ok) {
390
+ console.error(`Pin failed (${res.status}): ${await res.text()}`);
391
+ return 1;
392
+ }
393
+ const label = state === 'none' ? 'unpinned' : state;
394
+ console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
395
+ return 0;
396
+ } catch (e) { reportCliError(e); return 1; }
397
+ }
398
+
364
399
  async function runSessionViewCli(args) {
365
400
  const idArg = args.find(a => !a.startsWith('--'));
366
401
  if (!idArg) {
package/lib/parsers.js CHANGED
@@ -182,10 +182,22 @@ function scrapeScalarFromBlob(blob, re) {
182
182
  try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
183
183
  }
184
184
 
185
+ const sessionInfoCache = new Map();
186
+ const SESSION_INFO_CACHE_MAX = 2000;
187
+
185
188
  function readSessionInfoFromJsonl(jsonlPath) {
186
189
  const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
187
190
  let stat;
188
191
  let fd;
192
+ try {
193
+ stat = statSync(jsonlPath);
194
+ const cached = sessionInfoCache.get(jsonlPath);
195
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
196
+ return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
197
+ }
198
+ } catch (_) {
199
+ return result;
200
+ }
189
201
  // State shared across head-chunk parse, leftover flush, and tail parse.
190
202
  let lastCwdFromHead = null;
191
203
  const applyLine = (line) => {
@@ -200,7 +212,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
200
212
  } catch (e) {}
201
213
  };
202
214
  try {
203
- stat = statSync(jsonlPath);
204
215
  fd = fs.openSync(jsonlPath, 'r');
205
216
  const CHUNK_SIZE = 16384;
206
217
  const TAIL_SIZE = 16384;
@@ -264,6 +275,14 @@ function readSessionInfoFromJsonl(jsonlPath) {
264
275
  if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
265
276
  }
266
277
 
278
+ if (stat) {
279
+ const { customTitle: _ct, ...rest } = result;
280
+ sessionInfoCache.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, result: rest });
281
+ if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
282
+ const firstKey = sessionInfoCache.keys().next().value;
283
+ sessionInfoCache.delete(firstKey);
284
+ }
285
+ }
267
286
  result.customTitle = readCustomTitle(jsonlPath, stat);
268
287
  return result;
269
288
  }
@@ -695,6 +714,27 @@ function buildAgentProgressMap(jsonlPath) {
695
714
 
696
715
  function readCompactSummaries(jsonlPath) {
697
716
  const results = [];
717
+ // Inline format: newer Claude Code stores the summary directly in the parent
718
+ // session JSONL as a user message with isCompactSummary: true (no subagent file).
719
+ try {
720
+ const content = readFileSync(jsonlPath, 'utf8');
721
+ for (const line of content.split('\n')) {
722
+ if (!line.trim() || !line.includes('isCompactSummary')) continue;
723
+ try {
724
+ const obj = JSON.parse(line);
725
+ if (!obj.isCompactSummary) continue;
726
+ const c = obj.message?.content;
727
+ let text = typeof c === 'string'
728
+ ? c
729
+ : Array.isArray(c) ? c.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n') : '';
730
+ if (!text) continue;
731
+ // Strip the "This session is being continued..." preamble if present.
732
+ text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
733
+ if (text) results.push({ timestamp: obj.timestamp, summary: text });
734
+ } catch (_) {}
735
+ }
736
+ } catch (_) {}
737
+ // Legacy format: summary lives in subagents/agent-acompact-*.jsonl.
698
738
  try {
699
739
  const subagentsDir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, '.jsonl'), 'subagents');
700
740
  const files = readdirSync(subagentsDir).filter(f => f.startsWith('agent-acompact-') && f.endsWith('.jsonl'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.5.1",
3
+ "version": "3.6.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": {
@@ -51,6 +51,7 @@
51
51
  "public/**/*"
52
52
  ],
53
53
  "devDependencies": {
54
+ "@biomejs/biome": "2.4.14",
54
55
  "ajv": "^8.18.0",
55
56
  "ajv-formats": "^3.0.1",
56
57
  "husky": "^9.1.7"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kanban
3
- description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
3
+ description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "pin/unpin a session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
4
4
  compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
5
5
  ---
6
6
 
@@ -22,6 +22,20 @@ claude-code-kanban session open ${CLAUDE_SESSION_ID}
22
22
 
23
23
  Trigger phrases: "show this session in kanban", "focus current session", "open in kanban".
24
24
 
25
+ ## Pin the current session in kanban
26
+
27
+ Pins the active Claude session in the sidebar so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared via `--unpin`.
28
+
29
+ ```bash
30
+ claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
31
+ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
32
+ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
33
+ ```
34
+
35
+ State applies to every connected browser tab (broadcast via SSE) and persists in each tab's localStorage. With no tabs open the command is a no-op.
36
+
37
+ Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
38
+
25
39
  ## Preview a file in kanban
26
40
 
27
41
  Opens a markdown file in the preview modal:
package/public/app.js CHANGED
@@ -1335,6 +1335,19 @@ function toggleSessionSticky(sessionId) {
1335
1335
  renderSessions();
1336
1336
  }
1337
1337
 
1338
+ function handleSessionPinEvent({ id, state }) {
1339
+ if (!id) return;
1340
+ pinnedSessionIds.delete(id);
1341
+ stickySessionIds.delete(id);
1342
+ if (state === 'pinned') pinnedSessionIds.add(id);
1343
+ if (state === 'sticky') {
1344
+ pinnedSessionIds.add(id);
1345
+ stickySessionIds.add(id);
1346
+ }
1347
+ savePinnedSessions();
1348
+ renderSessions();
1349
+ }
1350
+
1338
1351
  function getSessionPinState(sessionId) {
1339
1352
  if (stickySessionIds.has(sessionId)) return 'sticky';
1340
1353
  if (pinnedSessionIds.has(sessionId)) return 'pinned';
@@ -1610,9 +1623,9 @@ function getToolDetail(tool, params, detail) {
1610
1623
  const parts = [];
1611
1624
  if (params.offset) parts.push(`L${params.offset}`);
1612
1625
  if (params.limit) parts.push(`+${params.limit}`);
1613
- if (parts.length) extra = ` <span style="color:var(--text-muted);opacity:.7">${parts.join(' ')}</span>`;
1626
+ if (parts.length) extra = ` <span style="color:var(--text-muted)">${parts.join(' ')}</span>`;
1614
1627
  }
1615
- return ` <span style="color:var(--text-muted)">${escapeHtml(detail)}</span>${extra}`;
1628
+ return ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>${extra}`;
1616
1629
  }
1617
1630
  function renderTaskResult(toolResult) {
1618
1631
  if (!toolResult) return '';
@@ -2264,7 +2277,10 @@ function renderSessions() {
2264
2277
  const hasInProgress = session.inProgress > 0;
2265
2278
  const isLive =
2266
2279
  hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
2267
- const sessionName = session.name || session.id;
2280
+ const rawName = session.name || session.id;
2281
+ const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
2282
+ ? rawName.slice(0, 8)
2283
+ : rawName;
2268
2284
  const useGrouped = sessionFilter === 'active' && session.project;
2269
2285
  const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
2270
2286
  const secondaryName = useGrouped ? null : session.project ? sessionName : null;
@@ -2286,8 +2302,9 @@ function renderSessions() {
2286
2302
  const showCtx = !!session.contextStatus;
2287
2303
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2288
2304
  const bookmarksCount = loadPins(session.id).length;
2305
+ const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2289
2306
  return `
2290
- <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}">
2307
+ <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
2291
2308
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
2292
2309
  <div class="session-name">${escapeHtml(primaryName)}</div>
2293
2310
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
@@ -3944,6 +3961,19 @@ function matchKey(e, ...keys) {
3944
3961
  return keys.some((k) => e.key === k || e.code === k);
3945
3962
  }
3946
3963
 
3964
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
3965
+ const MODAL_CLOSERS = {
3966
+ 'preview-modal': () => closePreviewModal(),
3967
+ 'msg-detail-modal': () => {
3968
+ closeMsgDetailModal();
3969
+ msgDetailFollowLatest = false;
3970
+ },
3971
+ 'plan-modal': () => closePlanModal(),
3972
+ 'team-modal': () => closeTeamModal(),
3973
+ 'agent-modal': () => closeAgentModal(),
3974
+ 'help-modal': () => closeHelpModal(),
3975
+ };
3976
+
3947
3977
  document.addEventListener('keydown', (e) => {
3948
3978
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
3949
3979
  return;
@@ -3956,9 +3986,13 @@ document.addEventListener('keydown', (e) => {
3956
3986
  closeScratchpad();
3957
3987
  return;
3958
3988
  }
3959
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
3960
- document.querySelectorAll('.modal-overlay.visible').forEach((m) => m.classList.remove('visible'));
3961
- msgDetailFollowLatest = false;
3989
+ // Close only the topmost so a child Esc doesn't also dismiss its parent.
3990
+ const visible = [...document.querySelectorAll('.modal-overlay.visible')];
3991
+ const topId = MODAL_ESC_PRIORITY.find((id) => visible.some((m) => m.id === id)) || visible[visible.length - 1].id;
3992
+ const close = MODAL_CLOSERS[topId];
3993
+ if (close) close();
3994
+ else document.getElementById(topId).classList.remove('visible');
3995
+ e.stopImmediatePropagation();
3962
3996
  } else if (
3963
3997
  e.code === 'KeyM' &&
3964
3998
  e.shiftKey &&
@@ -4153,6 +4187,7 @@ document.addEventListener('keydown', (e) => {
4153
4187
  lastTasksHash = '';
4154
4188
  const refreshes = [fetchSessions()];
4155
4189
  if (currentSessionId) refreshes.push(fetchTasks(currentSessionId));
4190
+ refreshRateLimits();
4156
4191
  Promise.all(refreshes)
4157
4192
  .then(() => showToast('Data refreshed', 'success'))
4158
4193
  .finally(() => {
@@ -4227,8 +4262,24 @@ function openPreviewModal(filePath, content) {
4227
4262
  currentPreviewPath = filePath;
4228
4263
  document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
4229
4264
  const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
4230
- document.getElementById('preview-modal-body').innerHTML =
4231
- (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4265
+ const bodyEl = document.getElementById('preview-modal-body');
4266
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4267
+ if (!bodyEl.dataset.relLinkBound) {
4268
+ bodyEl.addEventListener('click', (e) => {
4269
+ const a = e.target.closest('a[href]');
4270
+ if (!a) return;
4271
+ const href = a.getAttribute('href');
4272
+ if (!href || href.startsWith('#')) return;
4273
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
4274
+ const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
4275
+ if (isAbsoluteUrl) return;
4276
+ if (!/\.(md|markdown)(#.*)?$/i.test(href)) return;
4277
+ e.preventDefault();
4278
+ const cleanHref = href.replace(/#.*$/, '');
4279
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
4280
+ });
4281
+ bodyEl.dataset.relLinkBound = '1';
4282
+ }
4232
4283
  document.getElementById('preview-modal-meta').textContent = filePath;
4233
4284
  document.getElementById('preview-modal').classList.add('visible');
4234
4285
  updatePreviewLinkBtn();
@@ -4299,7 +4350,6 @@ function refreshInfoModalLinkedDocs() {
4299
4350
  bindLinkedDocsHandlers(node, _infoModalSessionId);
4300
4351
  }
4301
4352
 
4302
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4303
4353
  function closePreviewModal() {
4304
4354
  resetModalFullscreen('preview-modal');
4305
4355
  currentPreviewPath = null;
@@ -4311,10 +4361,12 @@ function openPreviewInEditor() {
4311
4361
  postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
4312
4362
  }
4313
4363
 
4314
- async function openPreviewByPath(filePath) {
4364
+ async function openPreviewByPath(filePath, base) {
4315
4365
  if (!filePath) return;
4316
4366
  try {
4317
- const r = await fetch(`/api/preview?path=${encodeURIComponent(filePath)}`);
4367
+ const qs = new URLSearchParams({ path: filePath });
4368
+ if (base) qs.set('base', base);
4369
+ const r = await fetch(`/api/preview?${qs}`);
4318
4370
  if (!r.ok) {
4319
4371
  showToast('Preview file unavailable');
4320
4372
  return;
@@ -4358,22 +4410,33 @@ async function handlePreviewOpenEvent(data) {
4358
4410
  openPreviewModal(filePath, content);
4359
4411
  }
4360
4412
 
4413
+ function getSessionBaseDir(sessionId) {
4414
+ const s = sessions.find((x) => x.id === sessionId);
4415
+ return s?.cwd || s?.project || '';
4416
+ }
4417
+
4361
4418
  function renderLinkedDocsHtml(sessionId) {
4362
4419
  const paths = getSessionPreviewPaths(sessionId);
4363
4420
  if (!paths.length) return '';
4421
+ const baseDir = getSessionBaseDir(sessionId);
4364
4422
  const items = paths
4365
4423
  .map((p, i) => {
4366
4424
  const name = p.split(/[\\/]/).pop();
4367
- return `<a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}" style="color:var(--accent-text);text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;">${escapeHtml(name)}</a>`;
4425
+ const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
4426
+ const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
4427
+ return `<li class="linked-doc-item">
4428
+ <a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}">${escapeHtml(name)}</a>
4429
+ ${pathSpan}
4430
+ </li>`;
4368
4431
  })
4369
- .join(', ');
4432
+ .join('');
4370
4433
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
4371
4434
  <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;">
4372
4435
  ${linkSvg(12)}
4373
4436
  <span>Linked documents</span>
4374
4437
  <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>
4375
4438
  </div>
4376
- <div>${items}</div>
4439
+ <ul class="linked-doc-list">${items}</ul>
4377
4440
  </div>`;
4378
4441
  }
4379
4442
 
@@ -4382,10 +4445,11 @@ function bindLinkedDocsHandlers(container, sessionId) {
4382
4445
  const links = container.querySelectorAll('.linked-doc-link');
4383
4446
  if (!links.length) return;
4384
4447
  const paths = getSessionPreviewPaths(sessionId);
4448
+ const base = getSessionBaseDir(sessionId);
4385
4449
  for (const link of links) {
4386
4450
  link.addEventListener('click', (e) => {
4387
4451
  e.preventDefault();
4388
- openPreviewByPath(paths[+link.dataset.idx]);
4452
+ openPreviewByPath(paths[+link.dataset.idx], base);
4389
4453
  });
4390
4454
  }
4391
4455
  }
@@ -4515,6 +4579,10 @@ function setupEventSource() {
4515
4579
  handleSessionOpenEvent(data);
4516
4580
  }
4517
4581
 
4582
+ if (data.type === 'session:pin') {
4583
+ handleSessionPinEvent(data);
4584
+ }
4585
+
4518
4586
  if (data.type === 'team-update') {
4519
4587
  const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
4520
4588
  if (teamSession) {
@@ -4730,6 +4798,18 @@ function escapeHtml(text) {
4730
4798
  return div.innerHTML;
4731
4799
  }
4732
4800
 
4801
+ function toRelativeIfUnder(filePath, baseDir) {
4802
+ if (!filePath || !baseDir) return null;
4803
+ const fp = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
4804
+ const bd = baseDir.replace(/\\/g, '/').replace(/\/+$/, '');
4805
+ const isWin = /^[a-zA-Z]:\//.test(fp) || /^[a-zA-Z]:\//.test(bd);
4806
+ const a = isWin ? fp.toLowerCase() : fp;
4807
+ const b = isWin ? bd.toLowerCase() : bd;
4808
+ if (a === b) return '.';
4809
+ if (!a.startsWith(`${b}/`)) return null;
4810
+ return fp.slice(bd.length + 1);
4811
+ }
4812
+
4733
4813
  function renderMarkdown(text) {
4734
4814
  if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
4735
4815
  return DOMPurify.sanitize(marked.parse(text));
@@ -5725,7 +5805,11 @@ function refreshRateLimits() {
5725
5805
  fetch('/api/context-status')
5726
5806
  .then((r) => r.json())
5727
5807
  .then((all) => {
5728
- const rl = Object.values(all || {}).find((e) => e?.rate_limits)?.rate_limits || null;
5808
+ let freshest = null;
5809
+ for (const e of Object.values(all || {})) {
5810
+ if (e?.rate_limits && (!freshest || (e._updatedAt || 0) > (freshest._updatedAt || 0))) freshest = e;
5811
+ }
5812
+ const rl = freshest?.rate_limits || null;
5729
5813
  const fh = rl?.five_hour?.used_percentage ?? null;
5730
5814
  const sd = rl?.seven_day?.used_percentage ?? null;
5731
5815
  const key = `${fh}|${sd}`;
@@ -5845,6 +5929,25 @@ document.addEventListener('keydown', (e) => {
5845
5929
  }
5846
5930
  });
5847
5931
 
5932
+ document.addEventListener('click', (e) => {
5933
+ if (!window.__HUB__?.enabled) return;
5934
+ const a = e.target.closest?.('a[href]');
5935
+ if (!a) return;
5936
+ const href = a.getAttribute('href');
5937
+ if (!href) return;
5938
+ let url;
5939
+ try {
5940
+ url = new URL(href, window.location.href);
5941
+ } catch (_) {
5942
+ return;
5943
+ }
5944
+ if (url.origin === window.location.origin) return;
5945
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
5946
+ e.preventDefault();
5947
+ e.stopPropagation();
5948
+ window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
5949
+ });
5950
+
5848
5951
  window.hubNavigate = function hubNavigate(app, url) {
5849
5952
  if (!window.__HUB__?.enabled) return;
5850
5953
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
package/public/style.css CHANGED
@@ -549,14 +549,13 @@ body::before {
549
549
 
550
550
  .session-branch {
551
551
  font-size: 10px;
552
- color: var(--text-muted);
552
+ color: var(--text-secondary);
553
553
  margin-top: 2px;
554
554
  display: block;
555
555
  font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
556
556
  white-space: nowrap;
557
557
  overflow: hidden;
558
558
  text-overflow: ellipsis;
559
- opacity: 0.7;
560
559
  }
561
560
 
562
561
  .session-plan {
@@ -1877,7 +1876,7 @@ body::before {
1877
1876
  width: 16px;
1878
1877
  height: 16px;
1879
1878
  margin-top: 2px;
1880
- opacity: 0.6;
1879
+ opacity: 0.9;
1881
1880
  }
1882
1881
  .msg-body {
1883
1882
  flex: 1;
@@ -2575,6 +2574,40 @@ body::before {
2575
2574
  color: var(--text-primary);
2576
2575
  }
2577
2576
 
2577
+ .linked-doc-list {
2578
+ list-style: none;
2579
+ padding: 0;
2580
+ margin: 0;
2581
+ display: flex;
2582
+ flex-direction: column;
2583
+ gap: 4px;
2584
+ }
2585
+
2586
+ .linked-doc-item {
2587
+ display: flex;
2588
+ align-items: baseline;
2589
+ gap: 8px;
2590
+ min-width: 0;
2591
+ }
2592
+
2593
+ .linked-doc-link {
2594
+ color: var(--accent-text);
2595
+ text-decoration: underline;
2596
+ text-decoration-style: dotted;
2597
+ text-underline-offset: 3px;
2598
+ flex-shrink: 0;
2599
+ }
2600
+
2601
+ .linked-doc-path {
2602
+ color: var(--text-muted);
2603
+ font-size: 11px;
2604
+ opacity: 0.7;
2605
+ overflow: hidden;
2606
+ text-overflow: ellipsis;
2607
+ white-space: nowrap;
2608
+ min-width: 0;
2609
+ }
2610
+
2578
2611
  /* #endregion */
2579
2612
 
2580
2613
  /* #region PERMISSION_PENDING */
@@ -3678,7 +3711,39 @@ pre.mermaid svg {
3678
3711
  .session-item.kb-selected::before {
3679
3712
  width: 0;
3680
3713
  }
3714
+ .session-item.stale .session-time,
3715
+ .session-item.warm .session-time {
3716
+ display: inline-flex;
3717
+ align-items: center;
3718
+ gap: 6px;
3719
+ }
3720
+ .session-item.stale .session-time::before,
3721
+ .session-item.warm .session-time::before {
3722
+ content: '';
3723
+ width: 6px;
3724
+ height: 6px;
3725
+ border-radius: 50%;
3726
+ flex-shrink: 0;
3727
+ }
3728
+ .session-item.warm .session-time {
3729
+ color: var(--success);
3730
+ }
3731
+ .session-item.warm .session-time::before {
3732
+ background: var(--success);
3733
+ }
3681
3734
  .session-item.stale {
3682
- opacity: 0.6;
3735
+ opacity: 0.85;
3736
+ }
3737
+ .session-item.stale:hover,
3738
+ .session-item.stale.active,
3739
+ .session-item.stale.kb-selected {
3740
+ opacity: 1;
3741
+ }
3742
+ .session-item.stale .session-time {
3743
+ color: var(--text-muted);
3744
+ }
3745
+ .session-item.stale .session-time::before {
3746
+ background: transparent;
3747
+ border: 1px solid var(--text-muted);
3683
3748
  }
3684
3749
  /* #endregion */
package/server.js CHANGED
@@ -119,11 +119,18 @@ function isAgentFresh(agent) {
119
119
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
120
120
  }
121
121
 
122
+ const sessionLogStatCache = new Map();
122
123
  function getSessionLogStat(meta) {
123
124
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
124
125
  try {
125
126
  const st = statSync(meta.jsonlPath);
126
- return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
127
+ const cached = sessionLogStatCache.get(meta.jsonlPath);
128
+ if (cached && cached.mtime === st.mtimeMs) return cached;
129
+ const content = readFileSync(meta.jsonlPath, 'utf8');
130
+ const hasMessages = content.includes('"type":"assistant"');
131
+ const data = { mtime: st.mtimeMs, hasMessages };
132
+ sessionLogStatCache.set(meta.jsonlPath, data);
133
+ return data;
127
134
  } catch (e) { return { mtime: null, hasMessages: false }; }
128
135
  }
129
136
 
@@ -1475,15 +1482,26 @@ async function readMarkdownFile(absPath) {
1475
1482
  }
1476
1483
  }
1477
1484
 
1478
- function resolvePreviewPath(filePath) {
1485
+ function resolvePreviewPath(filePath, base) {
1479
1486
  if (!filePath || typeof filePath !== 'string') return null;
1480
- return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
1487
+ if (path.isAbsolute(filePath)) return filePath;
1488
+ if (base && typeof base === 'string' && path.isAbsolute(base)) {
1489
+ let baseDir = base;
1490
+ try {
1491
+ if (statSync(base).isFile()) baseDir = path.dirname(base);
1492
+ } catch {
1493
+ // base doesn't exist — fall back to dirname if it looks like a file
1494
+ if (path.extname(base)) baseDir = path.dirname(base);
1495
+ }
1496
+ return path.resolve(baseDir, filePath);
1497
+ }
1498
+ return path.resolve(filePath);
1481
1499
  }
1482
1500
 
1483
1501
  app.post('/api/preview', async (req, res) => {
1484
1502
  try {
1485
- const { path: filePath, sessionId } = req.body || {};
1486
- const abs = resolvePreviewPath(filePath);
1503
+ const { path: filePath, sessionId, base } = req.body || {};
1504
+ const abs = resolvePreviewPath(filePath, base);
1487
1505
  if (!abs) return res.status(400).json({ error: 'path is required' });
1488
1506
  const content = await readMarkdownFile(abs);
1489
1507
  broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
@@ -1531,9 +1549,24 @@ app.post('/api/session/open', async (req, res) => {
1531
1549
  }
1532
1550
  });
1533
1551
 
1552
+ app.post('/api/session/pin', async (req, res) => {
1553
+ try {
1554
+ const { id, state } = req.body || {};
1555
+ if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
1556
+ if (!['none', 'pinned', 'sticky'].includes(state)) {
1557
+ return res.status(400).json({ error: 'state must be none|pinned|sticky' });
1558
+ }
1559
+ broadcast({ type: 'session:pin', id, state });
1560
+ res.json({ success: true, id, state });
1561
+ } catch (error) {
1562
+ console.error('Error in /api/session/pin:', error);
1563
+ res.status(500).json({ error: error.message || 'Failed' });
1564
+ }
1565
+ });
1566
+
1534
1567
  app.get('/api/preview', async (req, res) => {
1535
1568
  try {
1536
- const abs = resolvePreviewPath(req.query.path);
1569
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
1537
1570
  if (!abs) return res.status(400).json({ error: 'path is required' });
1538
1571
  const content = await readMarkdownFile(abs);
1539
1572
  res.json({ path: abs, content });
@@ -1746,6 +1779,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
1746
1779
  if (event === 'add' || event === 'change') {
1747
1780
  try {
1748
1781
  const data = JSON.parse(readFileSync(filePath, 'utf8'));
1782
+ try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
1749
1783
  contextStatusCache.set(sessionId, data);
1750
1784
  evictStaleCache(contextStatusCache);
1751
1785
  } catch (e) { /* ignore malformed */ }