claude-code-kanban 3.7.0 → 3.9.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
@@ -14,13 +14,14 @@ const COMMANDS = {
14
14
  summary: 'List or open Claude Code sessions',
15
15
  verbs: {
16
16
  list: {
17
- summary: 'List sessions',
18
- usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
17
+ summary: 'List sessions (pinned/sticky always included)',
18
+ usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
19
19
  flags: {
20
20
  '--active': 'Only sessions with recent activity (sidebar-style filter)',
21
21
  '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
22
22
  '--project <name>': 'Filter by project name (substring match)',
23
23
  '--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
24
+ '--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
24
25
  '--json': 'Output JSON instead of a table',
25
26
  },
26
27
  run: runSessionListCli,
@@ -52,6 +53,15 @@ const COMMANDS = {
52
53
  },
53
54
  run: runSessionPinCli,
54
55
  },
56
+ pins: {
57
+ summary: 'List sessions pinned/stickied via the dashboard or CLI',
58
+ usage: 'claude-code-kanban session pins [--sticky] [--json]',
59
+ flags: {
60
+ '--sticky': 'Only sessions in sticky state',
61
+ '--json': 'Output JSON instead of a table',
62
+ },
63
+ run: runSessionPinsCli,
64
+ },
55
65
  peek: {
56
66
  summary: 'Show the last N messages from a session',
57
67
  usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
@@ -239,13 +249,23 @@ function parseLimit(args, { fallback, allowAll = false }) {
239
249
  return { ok: true, limit: n };
240
250
  }
241
251
 
242
- async function fetchSessionsList(limit) {
252
+ async function fetchSessionsList(limit, pinnedIds = []) {
243
253
  const q = limit === null ? 'all' : String(limit);
244
- const res = await cliFetch(`/api/sessions?limit=${q}`);
254
+ const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
255
+ const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}`);
245
256
  if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
246
257
  return res.json();
247
258
  }
248
259
 
260
+ async function fetchPinsMap() {
261
+ try {
262
+ const res = await cliFetch('/api/session/pins');
263
+ if (!res.ok) return {};
264
+ const { pins = {} } = await res.json();
265
+ return pins;
266
+ } catch { return {}; }
267
+ }
268
+
249
269
  async function resolveSessionByIdOrPrefix(idArg) {
250
270
  let res;
251
271
  try {
@@ -273,6 +293,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
273
293
 
274
294
  async function runSessionListCli(args) {
275
295
  const activeOnly = args.includes('--active');
296
+ const noPins = args.includes('--no-pins');
276
297
  const projectFilter = getArgValue(args, 'project');
277
298
  const daysArg = getArgValue(args, 'days');
278
299
  const days = daysArg !== null ? parseFloat(daysArg) : null;
@@ -284,27 +305,40 @@ async function runSessionListCli(args) {
284
305
  if (!parsed.ok) { console.error(parsed.error); return 1; }
285
306
  const limit = parsed.limit;
286
307
  const asJson = args.includes('--json');
308
+ const pinsMap = noPins ? {} : await fetchPinsMap();
309
+ const pinnedIds = Object.keys(pinsMap);
287
310
  const hasClientFilter = activeOnly || days !== null || projectFilter;
288
311
  let list;
289
312
  try {
290
- list = await fetchSessionsList(hasClientFilter ? null : limit);
313
+ list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds);
291
314
  } catch (e) {
292
315
  reportCliError(e);
293
316
  return 1;
294
317
  }
295
- if (activeOnly) list = list.filter(isSessionActive);
318
+ const pinOf = id => pinsMap[id] || null;
319
+ if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
296
320
  if (days !== null) {
297
321
  const cutoff = Date.now() - days * 86_400_000;
298
- list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
322
+ list = list.filter(s => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
299
323
  }
300
324
  if (projectFilter) {
301
325
  const needle = projectFilter.toLowerCase();
302
326
  list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
303
327
  }
304
- const totalMatched = list.length;
305
- if (limit !== null && list.length > limit) list = list.slice(0, limit);
328
+ const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
329
+ list.sort((a, b) => {
330
+ const r = pinRank(a.id) - pinRank(b.id);
331
+ if (r !== 0) return r;
332
+ return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
333
+ });
334
+ if (limit !== null && list.length > limit) {
335
+ const top = list.slice(0, limit);
336
+ const topIds = new Set(top.map(s => s.id));
337
+ const extraPinned = list.filter(s => pinOf(s.id) && !topIds.has(s.id));
338
+ list = [...top, ...extraPinned];
339
+ }
306
340
  if (asJson) {
307
- console.log(JSON.stringify(list, null, 2));
341
+ console.log(JSON.stringify(list.map(s => ({ ...s, pinState: pinOf(s.id) })), null, 2));
308
342
  return 0;
309
343
  }
310
344
  if (!list.length) {
@@ -313,6 +347,7 @@ async function runSessionListCli(args) {
313
347
  }
314
348
  const rows = list.map(s => ({
315
349
  id: s.id.slice(0, 8),
350
+ pin: pinOf(s.id) || '',
316
351
  status: sessionStatus(s),
317
352
  age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
318
353
  tasks: `${s.completed}/${s.taskCount}`,
@@ -321,17 +356,15 @@ async function runSessionListCli(args) {
321
356
  }));
322
357
  const w = {
323
358
  id: 8,
359
+ pin: Math.max(3, ...rows.map(r => r.pin.length)),
324
360
  status: Math.max(6, ...rows.map(r => r.status.length)),
325
361
  age: Math.max(3, ...rows.map(r => r.age.length)),
326
362
  tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
327
363
  project: Math.max(7, ...rows.map(r => r.project.length)),
328
364
  };
329
- console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
365
+ console.log(`${'ID'.padEnd(w.id)} ${'PIN'.padEnd(w.pin)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
330
366
  for (const r of rows) {
331
- console.log(`${r.id.padEnd(w.id)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
332
- }
333
- if (limit !== null && totalMatched > limit) {
334
- console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
367
+ console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
335
368
  }
336
369
  return 0;
337
370
  }
@@ -396,6 +429,53 @@ async function runSessionPinCli(args) {
396
429
  } catch (e) { reportCliError(e); return 1; }
397
430
  }
398
431
 
432
+ async function runSessionPinsCli(args) {
433
+ const stickyOnly = args.includes('--sticky');
434
+ const asJson = args.includes('--json');
435
+ const pinsMap = await fetchPinsMap();
436
+ const items = Object.entries(pinsMap)
437
+ .filter(([, state]) => !stickyOnly || state === 'sticky')
438
+ .map(([id, state]) => ({ id, state }));
439
+ if (!items.length) {
440
+ if (asJson) console.log('[]'); else console.log('No pinned sessions.');
441
+ return 0;
442
+ }
443
+ let sessions;
444
+ try {
445
+ sessions = await fetchSessionsList(items.length, items.map(p => p.id));
446
+ } catch (e) { reportCliError(e); return 1; }
447
+ const byId = new Map(sessions.map(s => [s.id, s]));
448
+ let rows = items
449
+ .map(p => {
450
+ const s = byId.get(p.id) || {};
451
+ return {
452
+ id: p.id,
453
+ state: p.state,
454
+ status: s.id ? sessionStatus(s) : '-',
455
+ age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
456
+ project: path.basename(s.project || ''),
457
+ title: s.customTitle || s.name || s.slug || '',
458
+ };
459
+ })
460
+ .sort((a, b) => (a.state === b.state ? 0 : a.state === 'sticky' ? -1 : 1));
461
+ if (asJson) {
462
+ console.log(JSON.stringify(rows, null, 2));
463
+ return 0;
464
+ }
465
+ const w = {
466
+ id: 8,
467
+ state: Math.max(5, ...rows.map(r => r.state.length)),
468
+ status: Math.max(6, ...rows.map(r => r.status.length)),
469
+ age: Math.max(3, ...rows.map(r => r.age.length)),
470
+ project: Math.max(7, ...rows.map(r => r.project.length)),
471
+ };
472
+ console.log(`${'ID'.padEnd(w.id)} ${'STATE'.padEnd(w.state)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'PROJECT'.padEnd(w.project)} TITLE`);
473
+ for (const r of rows) {
474
+ console.log(`${r.id.slice(0, 8).padEnd(w.id)} ${r.state.padEnd(w.state)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.project.padEnd(w.project)} ${r.title}`);
475
+ }
476
+ return 0;
477
+ }
478
+
399
479
  async function runSessionViewCli(args) {
400
480
  const idArg = args.find(a => !a.startsWith('--'));
401
481
  if (!idArg) {
package/lib/parsers.js CHANGED
@@ -552,14 +552,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
552
552
  readSize *= 4;
553
553
  }
554
554
 
555
- // Attach tool results to their corresponding tool_use messages
555
+ // Attach tool results to their corresponding tool_use messages.
556
+ // For perf, we never ship the full text in the messages payload — when
557
+ // truncated, the client lazy-fetches via /api/sessions/:id/tool-result/:toolUseId.
556
558
  for (const msg of messages) {
557
559
  if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
558
560
  const full = toolResults.get(msg.toolUseId);
559
561
  const truncated = full.length > TOOL_RESULT_MAX;
560
562
  msg.toolResult = truncated ? full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : full;
561
563
  msg.toolResultTruncated = truncated;
562
- if (truncated) msg.toolResultFull = full;
563
564
  }
564
565
  }
565
566
 
@@ -578,6 +579,34 @@ function readRecentMessages(jsonlPath, limit = 10) {
578
579
  }
579
580
  }
580
581
 
582
+ function readFullToolResult(jsonlPath, toolUseId) {
583
+ if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
584
+ try {
585
+ const content = readFileSync(jsonlPath, 'utf8');
586
+ const lines = content.split('\n');
587
+ for (const line of lines) {
588
+ if (!line || line.indexOf(toolUseId) === -1) continue;
589
+ try {
590
+ const obj = JSON.parse(line);
591
+ if (obj?.message?.content && Array.isArray(obj.message.content)) {
592
+ for (const block of obj.message.content) {
593
+ if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
594
+ if (typeof block.content === 'string') return block.content;
595
+ if (Array.isArray(block.content)) {
596
+ return block.content
597
+ .filter((c) => c.type === 'text' && c.text)
598
+ .map((c) => c.text)
599
+ .join('\n');
600
+ }
601
+ }
602
+ }
603
+ }
604
+ } catch (_) {}
605
+ }
606
+ } catch (_) {}
607
+ return null;
608
+ }
609
+
581
610
  function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
582
611
  const fetchLimit = limit + 1;
583
612
  const applyFilter = beforeTimestamp
@@ -850,6 +879,7 @@ module.exports = {
850
879
  readSessionInfoFromJsonl,
851
880
  readRecentMessages,
852
881
  readMessagesPage,
882
+ readFullToolResult,
853
883
  buildAgentProgressMap,
854
884
  buildSessionDigest,
855
885
  readCompactSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.0",
3
+ "version": "3.9.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": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -32,10 +32,18 @@ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
32
32
  claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
33
33
  ```
34
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
35
  Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
38
36
 
37
+ ## List pinned sessions
38
+
39
+ ```bash
40
+ claude-code-kanban session pins # all pinned/sticky sessions
41
+ claude-code-kanban session pins --sticky # sticky only
42
+ claude-code-kanban session pins --json # JSON output
43
+ ```
44
+
45
+ Trigger phrases: "show pinned sessions", "what's pinned", "list pins".
46
+
39
47
  ## Preview a file in kanban
40
48
 
41
49
  Opens a markdown file in the preview modal:
package/public/app.js CHANGED
@@ -496,6 +496,7 @@ async function fetchTasks(sessionId) {
496
496
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
497
497
  revealedStorageSessionId = null;
498
498
  }
499
+ if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
499
500
  currentSessionId = sessionId;
500
501
  currentPins = loadPins(sessionId);
501
502
  ownerFilter = '';
@@ -1206,6 +1207,9 @@ function togglePin(msgIndex) {
1206
1207
  text: m.text || null,
1207
1208
  fullText: m.fullText || null,
1208
1209
  tool: m.tool || null,
1210
+ toolUseId: m.toolUseId || null,
1211
+ toolResult: m.toolResult || null,
1212
+ toolResultTruncated: m.toolResultTruncated || false,
1209
1213
  detail: m.detail || null,
1210
1214
  fullDetail: m.fullDetail || null,
1211
1215
  description: m.description || null,
@@ -1294,6 +1298,8 @@ function togglePinnedCollapse() {
1294
1298
  //#region PINNING
1295
1299
  let pinnedSessionIds = new Set();
1296
1300
  let stickySessionIds = new Set();
1301
+ // Pinning the currently-selected session keeps it in place until deselected (less UI movement).
1302
+ const deferredPinPlacement = new Set();
1297
1303
 
1298
1304
  function loadPinnedSessions() {
1299
1305
  try {
@@ -1316,35 +1322,57 @@ function savePinnedSessions() {
1316
1322
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1317
1323
  }
1318
1324
 
1319
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1325
+ // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
1326
+ function offloadSessionPin(sessionId) {
1327
+ const state = getSessionPinState(sessionId);
1328
+ fetch('/api/session/pin', {
1329
+ method: 'POST',
1330
+ headers: { 'Content-Type': 'application/json' },
1331
+ body: JSON.stringify({ id: sessionId, state }),
1332
+ }).catch(() => {});
1333
+ }
1334
+
1320
1335
  function toggleSessionPin(sessionId) {
1321
1336
  if (pinnedSessionIds.has(sessionId)) {
1322
1337
  pinnedSessionIds.delete(sessionId);
1323
1338
  stickySessionIds.delete(sessionId);
1339
+ deferredPinPlacement.delete(sessionId);
1324
1340
  } else {
1325
1341
  pinnedSessionIds.add(sessionId);
1342
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1326
1343
  }
1327
1344
  savePinnedSessions();
1345
+ offloadSessionPin(sessionId);
1328
1346
  renderSessions();
1329
1347
  }
1330
1348
 
1331
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1332
1349
  function toggleSessionSticky(sessionId) {
1333
1350
  if (stickySessionIds.has(sessionId)) {
1334
1351
  stickySessionIds.delete(sessionId);
1335
1352
  pinnedSessionIds.delete(sessionId);
1353
+ deferredPinPlacement.delete(sessionId);
1336
1354
  } else {
1337
1355
  pinnedSessionIds.add(sessionId);
1338
1356
  stickySessionIds.add(sessionId);
1357
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1339
1358
  }
1340
1359
  savePinnedSessions();
1360
+ offloadSessionPin(sessionId);
1341
1361
  renderSessions();
1342
1362
  }
1343
1363
 
1364
+ function isPlacedPinned(id) {
1365
+ return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
1366
+ }
1367
+ function isPlacedSticky(id) {
1368
+ return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
1369
+ }
1370
+
1344
1371
  function handleSessionPinEvent({ id, state }) {
1345
1372
  if (!id) return;
1346
1373
  pinnedSessionIds.delete(id);
1347
1374
  stickySessionIds.delete(id);
1375
+ deferredPinPlacement.delete(id);
1348
1376
  if (state === 'pinned') pinnedSessionIds.add(id);
1349
1377
  if (state === 'sticky') {
1350
1378
  pinnedSessionIds.add(id);
@@ -1372,11 +1400,16 @@ function _renderPinToDetail(pin) {
1372
1400
  document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1373
1401
  const fullText = pin.fullDetail || pin.detail || '';
1374
1402
  const pinParamsHtml = renderToolParamsHtml(pin.params);
1375
- const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
1403
+ const pinResultHtml = renderToolResultHtml(
1404
+ pin.toolResult,
1405
+ pin.toolResultTruncated,
1406
+ pin.toolResultFull,
1407
+ pin.toolUseId,
1408
+ );
1376
1409
  const pinDetailEscaped = escapeHtml(fullText);
1377
1410
  const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1378
1411
  body.innerHTML =
1379
- (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1412
+ (fullText ? `<pre class="${TINTED_PRE_CLASS}">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1380
1413
  pinParamsHtml +
1381
1414
  pinResultHtml;
1382
1415
  } else if (pin.type === 'agent') {
@@ -1437,7 +1470,7 @@ function showMsgDetail(idx) {
1437
1470
  const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
1438
1471
  const toolResultHtml = hideResult
1439
1472
  ? ''
1440
- : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
1473
+ : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull, m.toolUseId);
1441
1474
  const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
1442
1475
  let mainHtml;
1443
1476
  if (sendProto) {
@@ -1451,7 +1484,7 @@ function showMsgDetail(idx) {
1451
1484
  } else if (fullText) {
1452
1485
  const detailEscaped = escapeHtml(fullText);
1453
1486
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
1454
- mainHtml = `${descHtml}<pre class="msg-detail-pre">${detailRendered}</pre>`;
1487
+ mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
1455
1488
  } else {
1456
1489
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1457
1490
  }
@@ -1698,11 +1731,11 @@ function renderToolParamsHtml(params) {
1698
1731
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
1699
1732
  if (params.old_string) {
1700
1733
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
1701
- <pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
1734
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
1702
1735
  }
1703
1736
  if (params.new_string) {
1704
1737
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
1705
- <pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
1738
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
1706
1739
  }
1707
1740
  html += `</div>`;
1708
1741
  }
@@ -1717,13 +1750,14 @@ function renderToolParamsHtml(params) {
1717
1750
  const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
1718
1751
  fontSize: '0.75rem',
1719
1752
  maxHeight: '500px',
1753
+ tinted: true,
1720
1754
  });
1721
1755
  writeMoreBtn = ` ${toggle.btn}`;
1722
1756
  fullBlock = toggle.full;
1723
1757
  }
1724
1758
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
1725
1759
  <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
1726
- <pre class="msg-detail-pre" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
1760
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
1727
1761
  ${fullBlock}
1728
1762
  </div>`;
1729
1763
  }
@@ -1757,26 +1791,31 @@ function highlightBash(escaped) {
1757
1791
  .replace(/((?:^|\s)(?:&amp;&amp;|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
1758
1792
  }
1759
1793
 
1794
+ const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
1760
1795
  let _expandIdCounter = 0;
1761
- function _toggleExpand(btn) {
1762
- const f = document.getElementById(btn.dataset.expandId);
1763
- const t = btn.parentElement.nextElementSibling;
1764
- const expand = f.style.display === 'none';
1765
- f.style.display = expand ? 'block' : 'none';
1766
- t.style.display = expand ? 'none' : 'block';
1796
+ function _applyExpandToggle(btn, fullEl) {
1797
+ const truncEl = btn.parentElement.nextElementSibling;
1798
+ const expand = fullEl.style.display === 'none';
1799
+ fullEl.style.display = expand ? 'block' : 'none';
1800
+ if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
1767
1801
  btn.textContent = expand ? 'Show less' : 'Show more';
1768
1802
  const panel = btn.closest('.message-panel');
1769
1803
  if (panel) panel.classList.toggle('msg-expanded-wide', expand);
1770
1804
  const modal = btn.closest('.modal');
1771
1805
  if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
1772
1806
  }
1807
+ function _toggleExpand(btn) {
1808
+ const f = document.getElementById(btn.dataset.expandId);
1809
+ if (f) _applyExpandToggle(btn, f);
1810
+ }
1773
1811
  function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
1774
1812
  const id = `expand-${++_expandIdCounter}`;
1775
1813
  const fontSize = opts.fontSize || '0.8rem';
1776
1814
  const maxHeight = opts.maxHeight || '';
1777
- const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:${fontSize};text-decoration:underline;margin-left:6px">Show more</button>`;
1815
+ const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
1816
+ const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
1778
1817
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
1779
- const full = `<pre id="${id}" class="msg-detail-pre" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
1818
+ const full = `<pre id="${id}" class="${cls}" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
1780
1819
  return { btn, full };
1781
1820
  }
1782
1821
 
@@ -1796,7 +1835,7 @@ function autoSizeModal(modal, body) {
1796
1835
  if (desired > current) modal.style.maxWidth = `${desired}px`;
1797
1836
  }
1798
1837
 
1799
- function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1838
+ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
1800
1839
  if (!toolResult) return '';
1801
1840
  const stripped = stripLineNumbers(toolResult);
1802
1841
  const escaped = escapeHtml(stripped);
@@ -1806,6 +1845,10 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1806
1845
  const toggle = makeExpandToggle(escaped, escapeHtml(stripLineNumbers(fullResult)));
1807
1846
  truncLabel = toggle.btn;
1808
1847
  fullBlock = toggle.full;
1848
+ } else if (isTruncated && toolUseId) {
1849
+ const id = `expand-${++_expandIdCounter}`;
1850
+ truncLabel = `<button data-expand-id="${id}" data-tool-use-id="${escapeHtml(toolUseId)}" onclick="_toggleToolResultExpand(this)" class="expand-toggle-btn" style="font-size:0.8rem">Show more</button>`;
1851
+ fullBlock = `<pre id="${id}" class="msg-detail-pre" style="overflow:auto;display:none"></pre>`;
1809
1852
  } else if (isTruncated) {
1810
1853
  truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
1811
1854
  }
@@ -1816,12 +1859,42 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1816
1859
  </div>`;
1817
1860
  }
1818
1861
 
1862
+ async function _toggleToolResultExpand(btn) {
1863
+ const f = document.getElementById(btn.dataset.expandId);
1864
+ if (!f) return;
1865
+ if (!btn.dataset.loaded) {
1866
+ if (!currentSessionId || !btn.dataset.toolUseId) return;
1867
+ btn.disabled = true;
1868
+ btn.textContent = 'Loading…';
1869
+ try {
1870
+ const r = await fetch(
1871
+ `/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
1872
+ );
1873
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
1874
+ const { content } = await r.json();
1875
+ f.textContent = stripLineNumbers(content);
1876
+ btn.dataset.loaded = '1';
1877
+ } catch (_e) {
1878
+ btn.textContent = 'Show more';
1879
+ btn.disabled = false;
1880
+ showToast('Failed to load full output');
1881
+ return;
1882
+ }
1883
+ btn.disabled = false;
1884
+ }
1885
+ _applyExpandToggle(btn, f);
1886
+ }
1887
+
1819
1888
  function buildToolContent(m) {
1820
1889
  let content = m.fullDetail || m.detail || '';
1821
1890
  if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
1822
1891
  return content;
1823
1892
  }
1824
1893
 
1894
+ function getMessageDisplayContent(m) {
1895
+ return m.type === 'tool_use' ? buildToolContent(m) : m.compactSummary || stripAnsi(m.fullText || m.text);
1896
+ }
1897
+
1825
1898
  function getDetailMsg() {
1826
1899
  if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx];
1827
1900
  if (currentPinDetailId) return currentPins.find((p) => p.id === currentPinDetailId);
@@ -1832,8 +1905,7 @@ function getDetailMsg() {
1832
1905
  async function copyMsgToClipboard(btn) {
1833
1906
  const m = getDetailMsg();
1834
1907
  if (!m) return;
1835
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1836
- copyWithFeedback(content, btn);
1908
+ copyWithFeedback(getMessageDisplayContent(m), btn);
1837
1909
  }
1838
1910
 
1839
1911
  async function postAndToast(url, body, label) {
@@ -1853,9 +1925,8 @@ async function postAndToast(url, body, label) {
1853
1925
  async function openMsgInEditor() {
1854
1926
  const m = getDetailMsg();
1855
1927
  if (!m) return;
1856
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1857
- const title = m.type === 'tool_use' ? m.tool : m.type;
1858
- postAndToast('/api/open-in-editor', { content, title }, 'in editor');
1928
+ const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
1929
+ postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
1859
1930
  }
1860
1931
 
1861
1932
  function formatDuration(ms) {
@@ -2332,7 +2403,8 @@ function renderSessions() {
2332
2403
 
2333
2404
  const pinState = getSessionPinState(session.id);
2334
2405
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
2335
- const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
2406
+ const pinTitle =
2407
+ pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
2336
2408
  const showCtx = !!session.contextStatus;
2337
2409
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2338
2410
  const bookmarksCount = loadPins(session.id).length;
@@ -2383,12 +2455,10 @@ function renderSessions() {
2383
2455
  const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
2384
2456
  const renderGroupSessions = (sessions, pinKey) => {
2385
2457
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
2386
- const gPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id));
2458
+ const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
2387
2459
  if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
2388
2460
  const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
2389
- const gUnpinned = sessions.filter(
2390
- (s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
2391
- );
2461
+ const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
2392
2462
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
2393
2463
  if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
2394
2464
  return (
@@ -2415,8 +2485,7 @@ function renderSessions() {
2415
2485
  );
2416
2486
  };
2417
2487
  if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2418
- const pinWeight = (s) =>
2419
- stickySessionIds.has(s.id) ? 2 : pinnedSessionIds.has(s.id) && !isSessionActive(s) ? 1 : 0;
2488
+ const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
2420
2489
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
2421
2490
  for (const [, arr] of groups) arr.sort(pinSort);
2422
2491
  ungrouped.sort(pinSort);
@@ -2492,12 +2561,10 @@ function renderSessions() {
2492
2561
 
2493
2562
  sessionsList.innerHTML = html;
2494
2563
  } else {
2495
- const sticky = filteredSessions.filter((s) => stickySessionIds.has(s.id));
2496
- const idlePinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id) && !isSessionActive(s));
2564
+ const sticky = filteredSessions.filter((s) => isPlacedSticky(s.id));
2565
+ const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
2497
2566
  const rest = filteredSessions.filter(
2498
- (s) =>
2499
- (!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
2500
- (pinnedSessionIds.has(s.id) && isSessionActive(s)),
2567
+ (s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
2501
2568
  );
2502
2569
  let html = '';
2503
2570
  if (sticky.length > 0) {
@@ -2860,17 +2927,26 @@ function getGroupSessionsContainer(header) {
2860
2927
 
2861
2928
  function getNavigableItems() {
2862
2929
  const items = [];
2930
+ const walkGroupContainer = (container) => {
2931
+ if (!container) return;
2932
+ for (const child of container.children) {
2933
+ if (child.classList.contains('pinned-sub-section')) {
2934
+ const subHeader = child.querySelector('.pinned-sub-header');
2935
+ if (subHeader) items.push(subHeader);
2936
+ const subItems = child.querySelector('.pinned-sub-items');
2937
+ if (subItems && !subItems.classList.contains('collapsed')) {
2938
+ for (const s of subItems.querySelectorAll(':scope > .session-item')) items.push(s);
2939
+ }
2940
+ } else if (child.classList.contains('session-item')) {
2941
+ items.push(child);
2942
+ }
2943
+ }
2944
+ };
2863
2945
  for (const el of sessionsList.children) {
2864
2946
  if (el.classList.contains('project-group-header')) {
2865
2947
  items.push(el);
2866
2948
  if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
2867
- const container = getGroupSessionsContainer(el);
2868
- if (container) {
2869
- for (const s of container.querySelectorAll('.session-item')) {
2870
- if (s.closest('.pinned-sub-items.collapsed')) continue;
2871
- items.push(s);
2872
- }
2873
- }
2949
+ walkGroupContainer(getGroupSessionsContainer(el));
2874
2950
  }
2875
2951
  } else if (el.classList.contains('session-item')) {
2876
2952
  items.push(el);
@@ -2931,49 +3007,57 @@ function setGroupCollapsed(header, collapsed) {
2931
3007
  } catch (_) {}
2932
3008
  }
2933
3009
 
3010
+ function isGroupHeader(el) {
3011
+ return el.classList.contains('project-group-header') || el.classList.contains('pinned-sub-header');
3012
+ }
3013
+
3014
+ function findParentHeader(el) {
3015
+ const subContainer = el.closest('.pinned-sub-items');
3016
+ if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
3017
+ return subContainer.previousElementSibling;
3018
+ }
3019
+ const container = el.closest('.project-group-sessions');
3020
+ if (!container) return null;
3021
+ let header = container.previousElementSibling;
3022
+ while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
3023
+ return header;
3024
+ }
3025
+
2934
3026
  function handleSidebarHorizontal(direction) {
2935
3027
  const items = getNavigableItems();
2936
3028
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2937
3029
  const el = items[selectedSessionIdx];
2938
- const isHeader = el.classList.contains('project-group-header');
2939
3030
  const collapse = direction < 0;
2940
3031
 
2941
- if (isHeader) {
2942
- const groupPath = el.dataset.groupPath;
2943
- const isCollapsed = collapsedProjectGroups.has(groupPath);
3032
+ if (isGroupHeader(el)) {
3033
+ const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
2944
3034
  if (collapse) {
2945
3035
  if (!isCollapsed) setGroupCollapsed(el, true);
3036
+ } else if (isCollapsed) {
3037
+ setGroupCollapsed(el, false);
2946
3038
  } else {
2947
- if (isCollapsed) {
2948
- setGroupCollapsed(el, false);
2949
- } else {
2950
- navigateSession(1);
2951
- }
2952
- }
2953
- } else {
2954
- if (collapse) {
2955
- const container = el.closest('.project-group-sessions');
2956
- if (container) {
2957
- let header = container.previousElementSibling;
2958
- while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
2959
- if (header) {
2960
- const headerIdx = items.indexOf(header);
2961
- if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
2962
- }
2963
- }
2964
- } else {
2965
- activateSelectedSession(items);
3039
+ navigateSession(1);
2966
3040
  }
3041
+ return;
3042
+ }
3043
+
3044
+ if (!collapse) {
3045
+ activateSelectedSession(items);
3046
+ return;
2967
3047
  }
3048
+
3049
+ const header = findParentHeader(el);
3050
+ if (!header) return;
3051
+ const headerIdx = items.indexOf(header);
3052
+ if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
2968
3053
  }
2969
3054
 
2970
3055
  function activateSelectedSession(items) {
2971
3056
  items = items || getNavigableItems();
2972
3057
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2973
3058
  const el = items[selectedSessionIdx];
2974
- if (el.classList.contains('project-group-header')) {
2975
- const groupPath = el.dataset.groupPath;
2976
- setGroupCollapsed(el, !collapsedProjectGroups.has(groupPath));
3059
+ if (isGroupHeader(el)) {
3060
+ setGroupCollapsed(el, !collapsedProjectGroups.has(el.dataset.groupPath));
2977
3061
  } else {
2978
3062
  el.click();
2979
3063
  }
@@ -4082,6 +4166,14 @@ document.addEventListener('keydown', (e) => {
4082
4166
  showStorageManager();
4083
4167
  return;
4084
4168
  }
4169
+ if (e.key === '.' || e.key === '>') {
4170
+ const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
4171
+ if (sid) {
4172
+ e.preventDefault();
4173
+ (e.shiftKey ? toggleSessionSticky : toggleSessionPin)(sid);
4174
+ return;
4175
+ }
4176
+ }
4085
4177
 
4086
4178
  // Tab toggles focus zone
4087
4179
  if (e.key === 'Tab') {
@@ -4213,6 +4305,18 @@ document.addEventListener('keydown', (e) => {
4213
4305
  hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
4214
4306
  return;
4215
4307
  }
4308
+ if (e.code === 'KeyC' && e.shiftKey) {
4309
+ e.preventDefault();
4310
+ if (!contextSid) {
4311
+ showToast('No session selected');
4312
+ return;
4313
+ }
4314
+ navigator.clipboard
4315
+ .writeText(contextSid)
4316
+ .then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
4317
+ .catch(() => showToast('Failed to copy session id'));
4318
+ return;
4319
+ }
4216
4320
  if (matchKey(e, 'KeyR')) {
4217
4321
  e.preventDefault();
4218
4322
  if (_manualRefreshing) return;
@@ -5986,4 +6090,24 @@ window.hubNavigate = function hubNavigate(app, url) {
5986
6090
  if (!window.__HUB__?.enabled) return;
5987
6091
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
5988
6092
  };
6093
+
6094
+ (function initHubTheme() {
6095
+ const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
6096
+ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
6097
+ let lastTheme = getTheme();
6098
+ window.addEventListener('message', (e) => {
6099
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
6100
+ if (e.data?.type !== 'hub:theme') return;
6101
+ if (getTheme() === e.data.theme) return;
6102
+ window.toggleTheme();
6103
+ lastTheme = getTheme();
6104
+ });
6105
+ new MutationObserver(() => {
6106
+ const t = getTheme();
6107
+ if (t === lastTheme) return;
6108
+ lastTheme = t;
6109
+ const origin = hubOrigin();
6110
+ if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t }, origin);
6111
+ }).observe(document.body, { attributes: true, attributeFilter: ['class'] });
6112
+ })();
5989
6113
  // #endregion HUB_INTEGRATION
package/public/index.html CHANGED
@@ -421,6 +421,14 @@
421
421
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">N</kbd></td>
422
422
  <td style="padding: 4px 0; color: var(--text-primary);">Toggle scratchpad</td>
423
423
  </tr>
424
+ <tr>
425
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">.</kbd></td>
426
+ <td style="padding: 4px 0; color: var(--text-primary);">Pin/unpin selected session</td>
427
+ </tr>
428
+ <tr>
429
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&gt;</kbd></td>
430
+ <td style="padding: 4px 0; color: var(--text-primary);">Toggle sticky on selected session</td>
431
+ </tr>
424
432
  <tr>
425
433
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">T</kbd></td>
426
434
  <td style="padding: 4px 0; color: var(--text-primary);">Toggle theme</td>
@@ -445,6 +453,10 @@
445
453
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
446
454
  <td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
447
455
  </tr>
456
+ <tr>
457
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+C</kbd></td>
458
+ <td style="padding: 4px 0; color: var(--text-primary);">Copy session id</td>
459
+ </tr>
448
460
  <tr>
449
461
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J/K</kbd></td>
450
462
  <td style="padding: 4px 0; color: var(--text-primary);">Navigate messages in detail modal</td>
package/public/style.css CHANGED
@@ -2236,6 +2236,19 @@ body::before {
2236
2236
  font-family: var(--font-mono);
2237
2237
  font-size: 0.85rem;
2238
2238
  }
2239
+ .msg-detail-pre-tinted {
2240
+ background: rgba(127, 127, 127, 0.15);
2241
+ border-radius: 4px;
2242
+ padding: 8px 10px;
2243
+ }
2244
+ .expand-toggle-btn {
2245
+ background: none;
2246
+ border: none;
2247
+ color: var(--accent);
2248
+ cursor: pointer;
2249
+ text-decoration: underline;
2250
+ margin-left: 6px;
2251
+ }
2239
2252
  .msg-cmd .msg-text code {
2240
2253
  background: var(--bg-hover);
2241
2254
  padding: 2px 6px;
@@ -3488,7 +3501,8 @@ pre.mermaid svg {
3488
3501
  color: var(--text-primary);
3489
3502
  }
3490
3503
 
3491
- .project-group-header.kb-selected {
3504
+ .project-group-header.kb-selected,
3505
+ .pinned-sub-header.kb-selected {
3492
3506
  color: var(--text-primary);
3493
3507
  background: var(--bg-hover);
3494
3508
  border-radius: 4px;
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const express = require('express');
4
4
  const path = require('path');
5
5
  const fs = require('fs').promises;
6
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync } = require('fs');
6
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync } = require('fs');
7
7
  const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
@@ -19,7 +19,8 @@ const {
19
19
  readCompactSummaries,
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
- extractModelFromTranscript
22
+ extractModelFromTranscript,
23
+ readFullToolResult
23
24
  } = require('./lib/parsers');
24
25
 
25
26
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -72,6 +73,27 @@ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
72
73
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
73
74
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
74
75
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
76
+ const PINS_FILE = path.join(CCK_DIR, 'pins.json');
77
+
78
+ // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
79
+ function readPins() {
80
+ try {
81
+ const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
82
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
83
+ } catch (_) {}
84
+ return {};
85
+ }
86
+
87
+ function writePins(pins) {
88
+ try {
89
+ mkdirSync(CCK_DIR, { recursive: true });
90
+ const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
92
+ renameSync(tmp, PINS_FILE);
93
+ } catch (e) {
94
+ console.error('Failed to write pins.json:', e.message);
95
+ }
96
+ }
75
97
 
76
98
  const PERMISSION_TTL_MS = 1800000;
77
99
  const AGENT_TTL_MS = 3600000;
@@ -1293,12 +1315,23 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1293
1315
  }
1294
1316
  }
1295
1317
  for (const msg of messages) {
1296
- if (msg.toolUseId) delete msg.toolUseId;
1318
+ // Keep toolUseId on truncated tool results so the client can lazy-fetch the full text
1319
+ if (msg.toolUseId && !msg.toolResultTruncated) delete msg.toolUseId;
1297
1320
  delete msg.promptId;
1298
1321
  }
1299
1322
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
1300
1323
  });
1301
1324
 
1325
+ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1326
+ const metadata = loadSessionMetadata();
1327
+ const meta = metadata[req.params.sessionId];
1328
+ const jsonlPath = meta?.jsonlPath;
1329
+ if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
1330
+ const content = readFullToolResult(jsonlPath, req.params.toolUseId);
1331
+ if (content == null) return res.status(404).json({ error: 'tool result not found' });
1332
+ res.json({ toolUseId: req.params.toolUseId, content });
1333
+ });
1334
+
1302
1335
  app.get('/api/version', (req, res) => {
1303
1336
  const pkg = require('./package.json');
1304
1337
  res.json({ version: pkg.version });
@@ -1549,6 +1582,10 @@ app.post('/api/session/pin', async (req, res) => {
1549
1582
  if (!['none', 'pinned', 'sticky'].includes(state)) {
1550
1583
  return res.status(400).json({ error: 'state must be none|pinned|sticky' });
1551
1584
  }
1585
+ const pins = readPins();
1586
+ if (state === 'none') delete pins[id];
1587
+ else pins[id] = state;
1588
+ writePins(pins);
1552
1589
  broadcast({ type: 'session:pin', id, state });
1553
1590
  res.json({ success: true, id, state });
1554
1591
  } catch (error) {
@@ -1557,6 +1594,18 @@ app.post('/api/session/pin', async (req, res) => {
1557
1594
  }
1558
1595
  });
1559
1596
 
1597
+ app.get('/api/session/pins', (req, res) => {
1598
+ res.setHeader('Cache-Control', 'no-store');
1599
+ try {
1600
+ const pins = readPins();
1601
+ const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
1602
+ res.json({ pins, items });
1603
+ } catch (error) {
1604
+ console.error('Error in GET /api/session/pins:', error);
1605
+ res.status(500).json({ error: error.message || 'Failed' });
1606
+ }
1607
+ });
1608
+
1560
1609
  app.get('/api/preview', async (req, res) => {
1561
1610
  try {
1562
1611
  const abs = resolvePreviewPath(req.query.path, req.query.base);