claude-code-kanban 4.0.0 → 4.1.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
@@ -118,16 +118,23 @@ const TOOL_RESULT_MAX = 1500;
118
118
  const USER_TEXT_MAX = 500;
119
119
  const INTERRUPT_MARKER = '[Request interrupted by user]';
120
120
 
121
- function pushUserMessage(messages, text, timestamp, sysLabel) {
121
+ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
122
122
  if (sysLabel === '__skip__') return;
123
- const truncated = text.length > USER_TEXT_MAX;
124
- messages.push({
123
+ const safeText = text || '';
124
+ const truncated = safeText.length > USER_TEXT_MAX;
125
+ const msg = {
125
126
  type: 'user',
126
- text: truncated ? text.slice(0, USER_TEXT_MAX) + '...' : text,
127
- fullText: truncated ? text : null,
127
+ text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
128
+ fullText: truncated ? safeText : null,
128
129
  timestamp,
129
130
  ...(sysLabel && { systemLabel: sysLabel })
130
- });
131
+ };
132
+ if (extras) {
133
+ if (extras.uuid) msg.uuid = extras.uuid;
134
+ if (extras.images && extras.images.length) msg.images = extras.images;
135
+ if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
136
+ }
137
+ messages.push(msg);
131
138
  }
132
139
 
133
140
  // Cache: jsonlPath -> { scannedUpTo, customTitle }
@@ -532,16 +539,19 @@ function readRecentMessages(jsonlPath, limit = 10) {
532
539
  }
533
540
  pushUserMessage(messages, t, obj.timestamp, getSystemMessageLabel(t));
534
541
  } else if (Array.isArray(obj.message.content)) {
535
- const joined = obj.message.content
536
- .filter(b => b.type === 'text' && typeof b.text === 'string' && b.text)
537
- .map(b => b.text)
538
- .join('\n')
539
- .trim();
540
- if (joined && joined !== INTERRUPT_MARKER) {
541
- pushUserMessage(messages, joined, obj.timestamp, getSystemMessageLabel(joined));
542
- }
543
- for (const block of obj.message.content) {
544
- if (block.type === 'tool_result' && block.tool_use_id) {
542
+ const texts = [];
543
+ const images = [];
544
+ const toolResultRefs = [];
545
+ obj.message.content.forEach((block, idx) => {
546
+ if (block.type === 'text' && typeof block.text === 'string' && block.text) {
547
+ texts.push(block.text);
548
+ } else if (block.type === 'image' && block.source && block.source.type === 'base64') {
549
+ images.push({
550
+ blockIndex: idx,
551
+ mediaType: block.source.media_type || 'image/png',
552
+ dataLen: typeof block.source.data === 'string' ? block.source.data.length : 0
553
+ });
554
+ } else if (block.type === 'tool_result' && block.tool_use_id) {
545
555
  let resultText = '';
546
556
  if (typeof block.content === 'string') {
547
557
  resultText = block.content;
@@ -554,7 +564,23 @@ function readRecentMessages(jsonlPath, limit = 10) {
554
564
  if (resultText) {
555
565
  toolResults.set(block.tool_use_id, resultText);
556
566
  }
567
+ toolResultRefs.push({
568
+ toolUseId: block.tool_use_id,
569
+ preview: resultText ? resultText.slice(0, 200) : ''
570
+ });
557
571
  }
572
+ });
573
+ const joined = texts.join('\n').trim();
574
+ const hasText = joined && joined !== INTERRUPT_MARKER;
575
+ const hasImages = images.length > 0;
576
+ if (hasText || hasImages) {
577
+ pushUserMessage(
578
+ messages,
579
+ joined,
580
+ obj.timestamp,
581
+ getSystemMessageLabel(joined),
582
+ { uuid: obj.uuid, images, toolResultRefs: hasText ? toolResultRefs : [] }
583
+ );
558
584
  }
559
585
  }
560
586
  }
@@ -620,6 +646,31 @@ function readFullToolResult(jsonlPath, toolUseId) {
620
646
  return null;
621
647
  }
622
648
 
649
+ function readUserImage(jsonlPath, msgUuid, blockIndex) {
650
+ if (!msgUuid || !jsonlPath) return null;
651
+ const idx = Number(blockIndex);
652
+ if (!Number.isInteger(idx) || idx < 0) return null;
653
+ try {
654
+ const content = readFileSync(jsonlPath, 'utf8');
655
+ const lines = content.split('\n');
656
+ for (const line of lines) {
657
+ if (!line || line.indexOf(msgUuid) === -1) continue;
658
+ try {
659
+ const obj = JSON.parse(line);
660
+ if (obj?.uuid !== msgUuid) continue;
661
+ if (!Array.isArray(obj?.message?.content)) continue;
662
+ const block = obj.message.content[idx];
663
+ if (!block || block.type !== 'image' || !block.source || block.source.type !== 'base64') return null;
664
+ return {
665
+ mediaType: block.source.media_type || 'image/png',
666
+ data: block.source.data
667
+ };
668
+ } catch (_) {}
669
+ }
670
+ } catch (_) {}
671
+ return null;
672
+ }
673
+
623
674
  function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
624
675
  const fetchLimit = limit + 1;
625
676
  const applyFilter = beforeTimestamp
@@ -913,6 +964,7 @@ module.exports = {
913
964
  readRecentMessages,
914
965
  readMessagesPage,
915
966
  readFullToolResult,
967
+ readUserImage,
916
968
  buildAgentProgressMap,
917
969
  buildSessionDigest,
918
970
  readCompactSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.0.0",
3
+ "version": "4.1.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": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -70,6 +70,16 @@
70
70
  "timeout": 5
71
71
  }
72
72
  ]
73
+ },
74
+ {
75
+ "matcher": "ExitPlanMode",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh",
80
+ "timeout": 5
81
+ }
82
+ ]
73
83
  }
74
84
  ],
75
85
  "PostToolUse": [
@@ -38,17 +38,17 @@ if [ "$EVENT" = "SessionStart" ]; then
38
38
  fi
39
39
 
40
40
  # PostToolUse / non-waiting PreToolUse: clear waiting state
41
- if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ]; }; then
41
+ if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
42
42
  WFILE="$CCK_ACTIVITY/$SESSION_ID/_waiting.json"
43
43
  rm -f "$WFILE"
44
44
  [ "$EVENT" = "PostToolUse" ] && exit 0
45
45
  fi
46
46
 
47
- # Plan mode tools don't fire PostToolUse — skip to avoid stale markers
48
- [ "$TOOL_NAME" = "EnterPlanMode" ] || [ "$TOOL_NAME" = "ExitPlanMode" ] && exit 0
47
+ # EnterPlanMode has no waiting semantics — skip
48
+ [ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
49
49
 
50
50
  # Waiting-for-user events → write _waiting.json marker
51
- if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; }; then
51
+ if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
52
52
  DIR="$CCK_ACTIVITY/$SESSION_ID"
53
53
  mkdir -p "$DIR"
54
54
  KIND="permission"
package/public/app.js CHANGED
@@ -18,6 +18,7 @@ let bulkDeleteSessionId = null; // Track session for bulk delete
18
18
  let ownerFilter = '';
19
19
  let currentAgents = [];
20
20
  let currentWaiting = null;
21
+ let lastWaitingHash = '';
21
22
  let lastAgentsHash = '';
22
23
  let messagePanelOpen = false;
23
24
  let lastMessagesHash = '';
@@ -443,11 +444,12 @@ function renderActivityChip() {
443
444
  let waiting = 0;
444
445
  let active = 0;
445
446
  for (const s of sessions) {
447
+ if (dismissedSessionIds.has(s.id)) continue;
446
448
  if (s.hasWaitingForUser) waiting++;
447
449
  else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
448
450
  }
449
451
 
450
- const key = `${waiting}|${active}|${[...activityFilter].sort().join(',')}`;
452
+ const key = `${waiting}|${active}|${dismissedSessionIds.size}|${[...activityFilter].sort().join(',')}`;
451
453
  if (key === lastChipKey) return;
452
454
  lastChipKey = key;
453
455
 
@@ -565,15 +567,18 @@ async function fetchTasks(sessionId) {
565
567
  }
566
568
  }
567
569
 
568
- const _AGENT_COOLDOWN_MS = 3 * 60 * 1000;
569
- const _AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for force-stopping
570
+ // #region TIMINGS
570
571
  const WAITING_TTL_MS = 30 * 60 * 1000;
571
572
  const AGENT_LOG_MAX = 8;
573
+ const LIVE_INDICATOR_MS = 10 * 1000;
574
+ const ACTIVE_PLAN_MS = 10 * 60 * 1000;
575
+ // #endregion
572
576
 
573
577
  function resetAgentState() {
574
578
  currentAgents = [];
575
579
  currentWaiting = null;
576
580
  lastAgentsHash = '';
581
+ lastWaitingHash = '';
577
582
  renderAgentFooter();
578
583
  }
579
584
 
@@ -595,6 +600,11 @@ async function fetchAgents(sessionId) {
595
600
  for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
596
601
  renderAgentFooter();
597
602
  if (currentSessionId === sessionId) renderKanban();
603
+ const waitHash = JSON.stringify(currentWaiting);
604
+ if (waitHash !== lastWaitingHash) {
605
+ lastWaitingHash = waitHash;
606
+ if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
607
+ }
598
608
  } catch (e) {
599
609
  console.error('[fetchAgents]', e);
600
610
  }
@@ -899,6 +909,11 @@ function parseCommandMessage(text) {
899
909
  return null;
900
910
  }
901
911
 
912
+ function parseCommandArgs(text) {
913
+ const m = (text || '').match(/<command-args>([^<]*)<\/command-args>/);
914
+ return m?.[1].trim() || '';
915
+ }
916
+
902
917
  function cleanMessageText(text) {
903
918
  const cmd = parseCommandMessage(text);
904
919
  if (cmd) return cmd;
@@ -1051,11 +1066,25 @@ function renderMessageList(messages) {
1051
1066
  </div>`);
1052
1067
  } else {
1053
1068
  const cmd = parseCommandMessage(m.text);
1069
+ const cmdArgs = cmd ? parseCommandArgs(m.fullText || m.text) : '';
1054
1070
  const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
1055
1071
  const isCmd = !!cmd;
1072
+ const cmdArgsHtml =
1073
+ cmd && cmdArgs ? ` <span style="color:var(--text-secondary)">${escapeHtml(cmdArgs)}</span>` : '';
1074
+ const chips = [];
1075
+ const imgCount = m.images?.length || 0;
1076
+ const trCount = m.toolResultRefs?.length || 0;
1077
+ if (imgCount) chips.push(`<span class="user-attach-chip">${imgCount} image${imgCount > 1 ? 's' : ''}</span>`);
1078
+ if (trCount)
1079
+ chips.push(`<span class="user-attach-chip">${trCount} tool result${trCount > 1 ? 's' : ''}</span>`);
1080
+ const chipsHtml = chips.length ? `<div class="user-attach-chips">${chips.join('')}</div>` : '';
1081
+ let textHtml;
1082
+ if (displayText) textHtml = isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText;
1083
+ else if (chips.length) textHtml = '<em class="msg-text-muted">(attachment)</em>';
1084
+ else textHtml = '';
1056
1085
  parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
1057
1086
  ${MSG_ICON_USER}
1058
- <div class="msg-body"><div class="msg-text">${isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1087
+ <div class="msg-body"><div class="msg-text">${textHtml}${cmdArgsHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1059
1088
  </div>`);
1060
1089
  }
1061
1090
  } else if (m.type === 'assistant') {
@@ -1103,6 +1132,45 @@ function toggleToolGroup(id) {
1103
1132
  if (el) el.classList.toggle('show');
1104
1133
  }
1105
1134
 
1135
+ const WAITING_PLAN_PREVIEW_CHARS = 120;
1136
+ const WAITING_PREVIEW_MAX_CHARS = 200;
1137
+
1138
+ function getWaitingLabel(kind, tool) {
1139
+ if (kind !== 'question') return `Awaiting permission: ${tool}`;
1140
+ if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
1141
+ return 'Question pending';
1142
+ }
1143
+
1144
+ function getWaitingPreview(toolInput) {
1145
+ if (!toolInput) return '';
1146
+ try {
1147
+ const parsed = JSON.parse(toolInput);
1148
+ if (parsed.questions?.[0]?.question) return parsed.questions[0].question;
1149
+ if (parsed.plan) {
1150
+ const t = parsed.plan.match(/^#\s+(.+)/m);
1151
+ return t ? t[1] : parsed.plan.slice(0, WAITING_PLAN_PREVIEW_CHARS);
1152
+ }
1153
+ if (parsed.command) return parsed.command;
1154
+ if (parsed.file_path) return parsed.file_path;
1155
+ } catch (_) {
1156
+ /* toolInput may be truncated/non-JSON */
1157
+ }
1158
+ return '';
1159
+ }
1160
+
1161
+ function renderWaitingEntry() {
1162
+ if (!currentWaiting?.timestamp) return '';
1163
+ const age = Date.now() - new Date(currentWaiting.timestamp).getTime();
1164
+ if (age >= WAITING_TTL_MS) return '';
1165
+ const tool = currentWaiting.toolName || 'unknown';
1166
+ const label = getWaitingLabel(currentWaiting.kind, tool);
1167
+ const preview = getWaitingPreview(currentWaiting.toolInput);
1168
+ const previewHtml = preview
1169
+ ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
1170
+ : '';
1171
+ return `<div class="msg-item msg-waiting">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div></div>`;
1172
+ }
1173
+
1106
1174
  function renderMessages(messages) {
1107
1175
  const container = document.getElementById('message-panel-content');
1108
1176
  const pinnedContainer = document.getElementById('message-panel-pinned');
@@ -1116,7 +1184,7 @@ function renderMessages(messages) {
1116
1184
  currentMessages.length >= MSG_MAX_LOADED
1117
1185
  ? `<div class="msg-limit-banner">Showing last ${MSG_MAX_LOADED} messages</div>`
1118
1186
  : '';
1119
- container.innerHTML = limitBanner + msgsHtml;
1187
+ container.innerHTML = limitBanner + msgsHtml + renderWaitingEntry();
1120
1188
  if (!msgUserScrolledUp) container.scrollTop = container.scrollHeight;
1121
1189
  // Auto-load more if content doesn't overflow yet
1122
1190
  if (
@@ -1152,6 +1220,16 @@ const ICON_TASK =
1152
1220
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
1153
1221
  const ICON_WEB =
1154
1222
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
1223
+ const ICON_OPEN_EXTERNAL =
1224
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3h7v7"/><path d="M10 14L21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>';
1225
+ const ICON_COPY =
1226
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1227
+ const ICON_CHECKMARK =
1228
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1229
+ const ICON_AGENT_WAITING =
1230
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
1231
+ const ICON_CHAT =
1232
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
1155
1233
  const TOOL_ICONS = {
1156
1234
  Bash: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="18" rx="2"/><polyline points="7 10 10 13 7 16"/><line x1="13" y1="16" x2="17" y2="16"/></svg>',
1157
1235
  Read: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
@@ -1169,8 +1247,8 @@ const TOOL_ICONS = {
1169
1247
  TaskList: ICON_TASK,
1170
1248
  ToolSearch:
1171
1249
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
1172
- AskUserQuestion:
1173
- '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
1250
+ AskUserQuestion: ICON_CHAT,
1251
+ ExitPlanMode: ICON_CHAT,
1174
1252
  Skill:
1175
1253
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
1176
1254
  WebFetch: ICON_WEB,
@@ -1470,10 +1548,6 @@ function _renderPinToDetail(pin) {
1470
1548
  }
1471
1549
 
1472
1550
  const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
1473
- const MARKETPLACE_SVG =
1474
- '<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>';
1475
- const MEMORY_SVG =
1476
- '<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>';
1477
1551
  const LINK_SVG_PATHS =
1478
1552
  '<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"/>';
1479
1553
  const linkSvg = (size) =>
@@ -1482,6 +1556,40 @@ const linkSvg = (size) =>
1482
1556
  //#endregion
1483
1557
 
1484
1558
  //#region MODALS
1559
+ function renderUserAttachments(m) {
1560
+ const parts = [];
1561
+ if (m.images?.length && m.uuid && currentSessionId) {
1562
+ const imgs = m.images
1563
+ .map((img) => {
1564
+ const url = `/api/sessions/${encodeURIComponent(currentSessionId)}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1565
+ return `<img src="${url}" loading="lazy" alt="user image" class="user-attach-image" />`;
1566
+ })
1567
+ .join('');
1568
+ parts.push(
1569
+ `<div class="user-attach-section"><div class="user-attach-label">Attached images</div><div class="user-attach-images">${imgs}</div></div>`,
1570
+ );
1571
+ }
1572
+ if (m.toolResultRefs?.length) {
1573
+ const refs = m.toolResultRefs
1574
+ .map((ref) => {
1575
+ const safeId = escapeHtml(ref.toolUseId);
1576
+ const shortId = ref.toolUseId.length > 14 ? `${ref.toolUseId.slice(0, 14)}…` : ref.toolUseId;
1577
+ const preview = ref.preview ? escapeHtml(ref.preview) : '<em>(no text)</em>';
1578
+ const expandId = `user-tr-${ref.toolUseId}`;
1579
+ return `<details class="user-attach-toolresult">
1580
+ <summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
1581
+ <pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
1582
+ <button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${safeId}" onclick="_toggleToolResultExpand(this)">Show full</button>
1583
+ </details>`;
1584
+ })
1585
+ .join('');
1586
+ parts.push(
1587
+ `<div class="user-attach-section"><div class="user-attach-label">Tool results in this message</div>${refs}</div>`,
1588
+ );
1589
+ }
1590
+ return parts.join('');
1591
+ }
1592
+
1485
1593
  function showMsgDetail(idx) {
1486
1594
  currentMsgDetailIdx = idx;
1487
1595
  const m = currentMessages[idx];
@@ -1546,25 +1654,27 @@ function showMsgDetail(idx) {
1546
1654
  body.innerHTML = renderMarkdown(text);
1547
1655
  }
1548
1656
  } else {
1549
- const rawText = stripAnsi(m.fullText || m.text);
1657
+ const rawText = stripAnsi(m.fullText || m.text || '');
1550
1658
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1551
1659
  document.getElementById('msg-detail-title').textContent =
1552
1660
  m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
1553
1661
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1662
+ const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1554
1663
  if (m.compactSummary) {
1555
- body.innerHTML = renderMarkdown(m.compactSummary);
1664
+ body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
1556
1665
  } else if (cmd) {
1557
- const argsMatch = rawText.match(/<command-args>([^<]*)<\/command-args>/);
1558
- const args = argsMatch?.[1].trim() ? argsMatch[1].trim() : null;
1666
+ const args = parseCommandArgs(rawText) || null;
1559
1667
  const cleanBody = rawText
1560
1668
  .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
1561
1669
  .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
1562
1670
  .trim();
1563
1671
  let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
1564
1672
  if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
1565
- body.innerHTML = cmdHtml;
1673
+ body.innerHTML = cmdHtml + userExtras;
1674
+ } else if (rawText) {
1675
+ body.innerHTML = renderMarkdown(rawText) + userExtras;
1566
1676
  } else {
1567
- body.innerHTML = renderMarkdown(rawText);
1677
+ body.innerHTML = userExtras || '<em>No content</em>';
1568
1678
  }
1569
1679
  }
1570
1680
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
@@ -1649,8 +1759,7 @@ async function copyWithFeedback(text, btn) {
1649
1759
  await navigator.clipboard.writeText(text);
1650
1760
  btn.dataset.copying = '1';
1651
1761
  const svg = btn.innerHTML;
1652
- btn.innerHTML =
1653
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1762
+ btn.innerHTML = ICON_CHECKMARK;
1654
1763
  setTimeout(() => {
1655
1764
  btn.innerHTML = svg;
1656
1765
  delete btn.dataset.copying;
@@ -2322,10 +2431,8 @@ function renderSessions() {
2322
2431
 
2323
2432
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
2324
2433
  // project filter → search filter → ensure pinned/sticky sessions are always included
2325
- const LIVE_INDICATOR_MS = 10 * 1000;
2326
2434
  let filteredSessions = sessions;
2327
2435
  if (sessionFilter === 'active') {
2328
- const ACTIVE_PLAN_MS = 15 * 60 * 1000;
2329
2436
  const now = Date.now();
2330
2437
  const activeSessionIds = new Set();
2331
2438
  filteredSessions = filteredSessions.filter((s) => {
@@ -2476,12 +2583,9 @@ function renderSessions() {
2476
2583
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2477
2584
  ${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>` : ''}
2478
2585
  ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
2479
- ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2480
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2481
- ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
2482
- ${(window.__HUB__?.enabled || appConfig.marketplaceUrl) && session.project ? `<span class="marketplace-btn" data-project-path="${escapeHtml(session.project)}" onclick="event.stopPropagation(); openMarketplace(this.dataset.projectPath)" title="Open in Marketplace">${MARKETPLACE_SVG}</span>` : ''}
2483
- ${(window.__HUB__?.enabled || appConfig.memoryUrl) && session.project ? `<span class="marketplace-btn" data-project-path="${escapeHtml(session.project)}" onclick="event.stopPropagation(); openMemory(this.dataset.projectPath)" title="Open in Memory">${MEMORY_SVG}</span>` : ''}
2484
- ${isLive ? '<span class="pulse"></span>' : ''}
2586
+ ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')"><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>` : ''}
2587
+ ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2588
+ ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
2485
2589
  </span>
2486
2590
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
2487
2591
  <span class="progress-text">${session.completed}/${total}</span>
@@ -4380,6 +4484,7 @@ document.addEventListener('keydown', (e) => {
4380
4484
  dismissedSessionIds.add(contextSid);
4381
4485
  updateDismissBtnState();
4382
4486
  renderSessions();
4487
+ renderActivityChip();
4383
4488
  const newItems = getNavigableItems();
4384
4489
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
4385
4490
  // If the dismissed session is currently open, navigate to the previous one
@@ -5088,7 +5193,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
5088
5193
  }
5089
5194
  if (!tabs.length) return '';
5090
5195
  const defaultTab = responseHtml ? 'response' : tabs[0].key;
5091
- const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
5196
+ const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)">${ICON_COPY}</button>`;
5092
5197
  const tabsHtml = tabs
5093
5198
  .map(
5094
5199
  (t) =>
@@ -5493,13 +5598,13 @@ async function showSessionInfoModal(sessionId) {
5493
5598
  // and re-rendered when they arrive, so the modal doesn't block on network.
5494
5599
  _planSessionId = sessionId;
5495
5600
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
5496
- showInfoModal(session, null, cachedTasks, null);
5601
+ showInfoModal(session, null, cachedTasks, null, null);
5497
5602
 
5498
- const rerender = (teamConfig, tasks, planContent) => {
5603
+ const rerender = (teamConfig, tasks, planContent, parentInfo) => {
5499
5604
  if (_planSessionId !== sessionId) return; // user opened a different modal
5500
5605
  const modal = document.getElementById('team-modal');
5501
5606
  if (!modal?.classList.contains('visible')) return; // user closed modal — don't reopen
5502
- showInfoModal(session, teamConfig, tasks, planContent);
5607
+ showInfoModal(session, teamConfig, tasks, planContent, parentInfo);
5503
5608
  };
5504
5609
 
5505
5610
  const teamPromise = session.isTeam
@@ -5520,8 +5625,17 @@ async function showSessionInfoModal(sessionId) {
5520
5625
  .then((r) => (r.ok ? r.json() : []))
5521
5626
  .catch(() => []);
5522
5627
 
5523
- const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
5524
- rerender(teamConfig, tasks, planContent);
5628
+ const parentPromise = fetch(`/api/sessions/${sessionId}/parent`)
5629
+ .then((r) => (r.ok ? r.json() : null))
5630
+ .catch(() => null);
5631
+
5632
+ const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
5633
+ teamPromise,
5634
+ planPromise,
5635
+ tasksPromise,
5636
+ parentPromise,
5637
+ ]);
5638
+ rerender(teamConfig, tasks, planContent, parentInfo);
5525
5639
  }
5526
5640
 
5527
5641
  let _infoModalSessionId = null;
@@ -5538,7 +5652,7 @@ function updateStickyBtnState() {
5538
5652
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
5539
5653
  }
5540
5654
 
5541
- function showInfoModal(session, teamConfig, tasks, planContent) {
5655
+ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5542
5656
  const modal = document.getElementById('team-modal');
5543
5657
  const titleEl = document.getElementById('team-modal-title');
5544
5658
  const bodyEl = document.getElementById('team-modal-body');
@@ -5558,6 +5672,13 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5558
5672
  // Each row: [label, displayValue, { openPath?, copyValue? }]
5559
5673
  const infoRows = [];
5560
5674
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
5675
+ if (parentInfo?.parentSessionId) {
5676
+ infoRows.push([
5677
+ 'Forked from',
5678
+ parentInfo.parentSessionId,
5679
+ { openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
5680
+ ]);
5681
+ }
5561
5682
  if (session.slug && session.hasPlan) {
5562
5683
  infoRows.push(['Slug', session.slug, { openClaudeDir: true, openFile: session.planPath }]);
5563
5684
  }
@@ -5589,19 +5710,25 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5589
5710
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: var(--accent-text); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px;";
5590
5711
  const plainStyle =
5591
5712
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
5592
- html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: center;">`;
5713
+ html += `<div class="team-modal-meta info-grid">`;
5593
5714
  infoRows.forEach(([label, value, opts]) => {
5594
5715
  const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
5595
5716
  html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
5596
- if (opts?.openClaudeDir || opts?.openPath) {
5597
- const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5598
- const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5599
- html += `<span data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" style="${clickableStyle}" title="Open in editor">${escapeHtml(value)}</span>`;
5717
+ if (opts?.openSession) {
5718
+ const sid = _escapeForJsAttr(escapeHtml(opts.openSession).replace(/"/g, '&quot;'));
5719
+ html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
5600
5720
  } else {
5601
5721
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
5602
5722
  }
5603
5723
  const jsCopyVal = _escapeForJsAttr(copyVal);
5604
- html += `<button onclick="navigator.clipboard.writeText('${jsCopyVal}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
5724
+ const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
5725
+ let openBtn = '';
5726
+ if (opts?.openClaudeDir || opts?.openPath) {
5727
+ const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5728
+ const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5729
+ openBtn = `<button data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>`;
5730
+ }
5731
+ html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
5605
5732
  });
5606
5733
  html += `</div>`;
5607
5734
 
@@ -5687,6 +5814,11 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5687
5814
  updateDismissBtnState();
5688
5815
  const costBtn = document.getElementById('session-info-cost-btn');
5689
5816
  if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
5817
+ const mkBtn = document.getElementById('session-info-marketplace-btn');
5818
+ const memBtn = document.getElementById('session-info-memory-btn');
5819
+ const proj = session.project;
5820
+ if (mkBtn) mkBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.marketplaceUrl) ? '' : 'none';
5821
+ if (memBtn) memBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.memoryUrl) ? '' : 'none';
5690
5822
  modal.classList.add('visible');
5691
5823
 
5692
5824
  if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
@@ -5707,6 +5839,12 @@ function closeTeamModal() {
5707
5839
  _planSessionId = null;
5708
5840
  }
5709
5841
 
5842
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5843
+ function openSessionFromInfo(sessionId) {
5844
+ closeTeamModal();
5845
+ fetchTasks(sessionId);
5846
+ }
5847
+
5710
5848
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5711
5849
  function toggleDismissSession(sessionId) {
5712
5850
  if (dismissedSessionIds.has(sessionId)) {
@@ -5716,6 +5854,7 @@ function toggleDismissSession(sessionId) {
5716
5854
  }
5717
5855
  updateDismissBtnState();
5718
5856
  renderSessions();
5857
+ renderActivityChip();
5719
5858
  }
5720
5859
 
5721
5860
  function updateDismissBtnState() {
@@ -5801,7 +5940,6 @@ function openCost(sessionId) {
5801
5940
  }
5802
5941
  }
5803
5942
 
5804
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5805
5943
  function openMarketplace(projectPath) {
5806
5944
  const params = new URLSearchParams({ project: projectPath });
5807
5945
  if (window.__HUB__?.enabled) {
@@ -5813,7 +5951,6 @@ function openMarketplace(projectPath) {
5813
5951
  }
5814
5952
  }
5815
5953
 
5816
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5817
5954
  function openMemory(projectPath) {
5818
5955
  const params = new URLSearchParams({ project: projectPath });
5819
5956
  if (window.__HUB__?.enabled) {
@@ -5825,6 +5962,19 @@ function openMemory(projectPath) {
5825
5962
  }
5826
5963
  }
5827
5964
 
5965
+ function openForInfoModalProject(open) {
5966
+ const s = sessions.find((x) => x.id === _infoModalSessionId);
5967
+ if (s?.project) open(s.project);
5968
+ }
5969
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5970
+ function openMarketplaceForInfoModal() {
5971
+ openForInfoModalProject(openMarketplace);
5972
+ }
5973
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5974
+ function openMemoryForInfoModal() {
5975
+ openForInfoModalProject(openMemory);
5976
+ }
5977
+
5828
5978
  //#endregion
5829
5979
 
5830
5980
  //#region OWNER_FILTER
@@ -5990,6 +6140,12 @@ function makeLimitCell(label, bucket) {
5990
6140
  const strong = document.createElement('strong');
5991
6141
  strong.textContent = pct == null ? '-%' : `${Math.ceil(pct)}%`;
5992
6142
  cell.appendChild(strong);
6143
+ if (reset) {
6144
+ const r = document.createElement('span');
6145
+ r.className = 'footer-limit-reset';
6146
+ r.textContent = ` (${reset})`;
6147
+ cell.appendChild(r);
6148
+ }
5993
6149
  return cell;
5994
6150
  }
5995
6151
  function makeLimitSpan(rl) {
package/public/index.html CHANGED
@@ -527,6 +527,12 @@
527
527
  <button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost" onclick="openCost(_infoModalSessionId)">
528
528
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
529
529
  </button>
530
+ <button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace" onclick="openMarketplaceForInfoModal()">
531
+ <svg width="16" height="16" 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>
532
+ </button>
533
+ <button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory" onclick="openMemoryForInfoModal()">
534
+ <svg width="16" height="16" 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>
535
+ </button>
530
536
  <button class="modal-close" aria-label="Close dialog" onclick="closeTeamModal()">
531
537
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
532
538
  <path d="M18 6L6 18M6 6l12 12"/>
package/public/style.css CHANGED
@@ -724,6 +724,12 @@ body::before {
724
724
  font-weight: 600;
725
725
  }
726
726
 
727
+ .sidebar-footer .footer-limits .footer-limit-reset {
728
+ color: var(--text-tertiary);
729
+ font-weight: 400;
730
+ opacity: 0.75;
731
+ }
732
+
727
733
  .sidebar-footer a:hover {
728
734
  color: var(--text-secondary);
729
735
  }
@@ -1743,6 +1749,55 @@ body::before {
1743
1749
  border-top: 1px solid var(--border);
1744
1750
  }
1745
1751
 
1752
+ .info-grid {
1753
+ display: grid;
1754
+ grid-template-columns: auto 1fr auto;
1755
+ gap: 6px 12px;
1756
+ align-items: center;
1757
+ margin-bottom: 16px;
1758
+ }
1759
+ .info-row-actions {
1760
+ display: flex;
1761
+ gap: 4px;
1762
+ opacity: 0.3;
1763
+ transition: opacity 0.15s ease;
1764
+ }
1765
+ .info-grid:hover .info-row-actions {
1766
+ opacity: 1;
1767
+ }
1768
+ .info-row-actions button {
1769
+ display: inline-flex;
1770
+ align-items: center;
1771
+ justify-content: center;
1772
+ width: 22px;
1773
+ height: 22px;
1774
+ padding: 0;
1775
+ background: var(--bg-elevated);
1776
+ border: 1px solid var(--border);
1777
+ border-radius: 4px;
1778
+ color: var(--text-secondary);
1779
+ cursor: pointer;
1780
+ transition:
1781
+ background 0.1s ease,
1782
+ border-color 0.1s ease,
1783
+ color 0.1s ease;
1784
+ }
1785
+ .info-row-actions button:hover {
1786
+ background: var(--bg-hover);
1787
+ border-color: var(--accent);
1788
+ color: var(--accent-text);
1789
+ }
1790
+ .info-row-actions button svg {
1791
+ width: 12px;
1792
+ height: 12px;
1793
+ display: block;
1794
+ }
1795
+ @media (hover: none) {
1796
+ .info-row-actions {
1797
+ opacity: 1;
1798
+ }
1799
+ }
1800
+
1746
1801
  /* #endregion */
1747
1802
 
1748
1803
  /* #region OWNER_FILTER */
@@ -2107,6 +2162,11 @@ body::before {
2107
2162
  opacity: 1;
2108
2163
  color: var(--text-primary);
2109
2164
  }
2165
+ .agent-tab-copy svg {
2166
+ width: 14px;
2167
+ height: 14px;
2168
+ display: block;
2169
+ }
2110
2170
  .toast {
2111
2171
  position: fixed;
2112
2172
  bottom: 16px;
@@ -2222,6 +2282,59 @@ body::before {
2222
2282
  border-radius: 3px;
2223
2283
  font-size: 11px;
2224
2284
  }
2285
+ .user-attach-chips {
2286
+ display: flex;
2287
+ gap: 4px;
2288
+ flex-wrap: wrap;
2289
+ margin-top: 4px;
2290
+ }
2291
+ .user-attach-chip {
2292
+ font-size: 10px;
2293
+ padding: 1px 6px;
2294
+ border-radius: 10px;
2295
+ background: var(--bg-hover);
2296
+ color: var(--text-secondary);
2297
+ }
2298
+ .msg-text-muted {
2299
+ color: var(--text-secondary);
2300
+ font-size: 12px;
2301
+ }
2302
+ .user-attach-section {
2303
+ margin-top: 12px;
2304
+ padding-top: 10px;
2305
+ border-top: 1px solid var(--border);
2306
+ }
2307
+ .user-attach-label {
2308
+ font-size: 11px;
2309
+ color: var(--text-secondary);
2310
+ margin-bottom: 6px;
2311
+ text-transform: uppercase;
2312
+ letter-spacing: 0.5px;
2313
+ }
2314
+ .user-attach-images {
2315
+ display: flex;
2316
+ flex-wrap: wrap;
2317
+ gap: 8px;
2318
+ }
2319
+ .user-attach-image {
2320
+ max-width: 100%;
2321
+ max-height: 480px;
2322
+ border-radius: 4px;
2323
+ border: 1px solid var(--border);
2324
+ background: var(--bg-hover);
2325
+ }
2326
+ .user-attach-toolresult {
2327
+ margin-top: 6px;
2328
+ }
2329
+ .user-attach-toolresult > summary {
2330
+ cursor: pointer;
2331
+ font-size: 12px;
2332
+ color: var(--text-secondary);
2333
+ padding: 4px 0;
2334
+ }
2335
+ .user-attach-toolresult > summary code {
2336
+ font-size: 10px;
2337
+ }
2225
2338
  .msg-item.msg-system {
2226
2339
  border-left: 3px solid var(--border);
2227
2340
  }
@@ -2241,6 +2354,30 @@ body::before {
2241
2354
  border-left: 3px solid var(--border);
2242
2355
  opacity: 0.75;
2243
2356
  }
2357
+ .msg-item.msg-waiting {
2358
+ border-left: 3px solid var(--warning, #f5a623);
2359
+ background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
2360
+ animation: msg-waiting-pulse 2s ease-in-out infinite;
2361
+ }
2362
+ .msg-waiting .msg-text {
2363
+ font-weight: 600;
2364
+ }
2365
+ .msg-waiting-preview {
2366
+ font-size: 11px;
2367
+ opacity: 0.85;
2368
+ margin-top: 2px;
2369
+ white-space: pre-wrap;
2370
+ word-break: break-word;
2371
+ }
2372
+ @keyframes msg-waiting-pulse {
2373
+ 0%,
2374
+ 100% {
2375
+ background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
2376
+ }
2377
+ 50% {
2378
+ background: color-mix(in srgb, var(--warning, #f5a623) 16%, transparent);
2379
+ }
2380
+ }
2244
2381
  .msg-item.msg-idle .msg-icon {
2245
2382
  width: 12px;
2246
2383
  height: 12px;
@@ -2531,8 +2668,26 @@ body::before {
2531
2668
  border: 1px solid var(--border);
2532
2669
  }
2533
2670
  .agent-badge {
2534
- font-size: 12px;
2671
+ display: inline-flex;
2672
+ align-items: center;
2673
+ justify-content: center;
2535
2674
  cursor: default;
2675
+ line-height: 1;
2676
+ flex-shrink: 0;
2677
+ color: var(--text-secondary);
2678
+ }
2679
+ .agent-badge-waiting {
2680
+ color: var(--warning);
2681
+ animation: agent-badge-pulse 1.6s ease-in-out infinite;
2682
+ }
2683
+ @keyframes agent-badge-pulse {
2684
+ 0%,
2685
+ 100% {
2686
+ opacity: 0.65;
2687
+ }
2688
+ 50% {
2689
+ opacity: 1;
2690
+ }
2536
2691
  }
2537
2692
 
2538
2693
  .linked-docs-badge,
@@ -3502,22 +3657,6 @@ pre.mermaid svg {
3502
3657
  color: var(--accent);
3503
3658
  }
3504
3659
 
3505
- .marketplace-btn {
3506
- color: #888;
3507
- cursor: pointer;
3508
- display: inline-flex;
3509
- align-items: center;
3510
- transition:
3511
- color 0.15s,
3512
- filter 0.15s;
3513
- border-radius: 3px;
3514
- }
3515
-
3516
- .marketplace-btn:hover {
3517
- color: var(--accent);
3518
- filter: drop-shadow(0 0 3px var(--accent));
3519
- }
3520
-
3521
3660
  .project-group-header .group-count {
3522
3661
  font-weight: 400;
3523
3662
  color: var(--text-muted);
package/server.js CHANGED
@@ -20,7 +20,8 @@ const {
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
22
  extractModelFromTranscript,
23
- readFullToolResult
23
+ readFullToolResult,
24
+ readUserImage
24
25
  } = require('./lib/parsers');
25
26
 
26
27
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -95,12 +96,16 @@ function writePins(pins) {
95
96
  }
96
97
  }
97
98
 
98
- const PERMISSION_TTL_MS = 1800000;
99
- const AGENT_TTL_MS = 3600000;
100
- const AGENT_STALE_MS = 900000;
101
- const SESSION_STALE_MS = 300000;
102
-
103
- const WAITING_RESOLVE_GRACE_MS = 15000;
99
+ // #region TIMINGS
100
+ const PERMISSION_TTL_MS = 30 * 60 * 1000;
101
+ const AGENT_TTL_MS = 60 * 60 * 1000;
102
+ const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
103
+ const SESSION_STALE_MS = 5 * 60 * 1000;
104
+ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
105
+ const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
106
+ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
107
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
108
+ // #endregion
104
109
 
105
110
  function readAgentJsonl(filePath) {
106
111
  const raw = readFileSync(filePath, 'utf8');
@@ -1252,30 +1257,122 @@ function subagentJsonlPath(meta, agentId) {
1252
1257
  }
1253
1258
 
1254
1259
  // Claude Code can scatter a session's records across multiple project dirs
1255
- // (e.g. main repo + worktree), so the subagent JSONL may live under a
1256
- // different project dir than meta.jsonlPath. Fall back to scanning when the
1257
- // derived path is missing.
1260
+ // (e.g. main repo + worktree) and across sibling sessionId dirs when a
1261
+ // session is forked/resumed the subagent JSONL stays under the original
1262
+ // parent sessionId. Fall back to scanning when the derived path is missing.
1258
1263
  const subagentPathCache = new Map();
1264
+ function findSubagentJsonlInProject(projPath, sessionId, agentId) {
1265
+ const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1266
+ if (existsSync(sameSid)) return sameSid;
1267
+ let sessions;
1268
+ try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
1269
+ for (const sess of sessions) {
1270
+ if (!sess.isDirectory() || sess.name === sessionId) continue;
1271
+ const candidate = path.join(projPath, sess.name, 'subagents', 'agent-' + agentId + '.jsonl');
1272
+ if (existsSync(candidate)) return candidate;
1273
+ }
1274
+ return null;
1275
+ }
1259
1276
  function resolveSubagentJsonl(meta, sessionId, agentId) {
1260
1277
  const primary = subagentJsonlPath(meta, agentId);
1261
1278
  if (existsSync(primary)) return primary;
1262
1279
  const key = sessionId + '/' + agentId;
1263
- if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
1280
+ const cached = subagentPathCache.get(key);
1281
+ if (cached) return cached;
1264
1282
  let found = null;
1265
- try {
1266
- for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1267
- if (!entry.isDirectory()) continue;
1268
- const candidate = path.join(
1269
- PROJECTS_DIR, entry.name, sessionId,
1270
- 'subagents', 'agent-' + agentId + '.jsonl'
1271
- );
1272
- if (existsSync(candidate)) { found = candidate; break; }
1273
- }
1274
- } catch (_) { /* projects dir missing */ }
1275
- subagentPathCache.set(key, found);
1283
+ const parent = lookupParentSession(sessionId);
1284
+ if (parent.parentSessionId && parent.parentJsonlPath) {
1285
+ const projDir = path.dirname(parent.parentJsonlPath);
1286
+ const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1287
+ if (existsSync(candidate)) found = candidate;
1288
+ }
1289
+ if (!found) {
1290
+ try {
1291
+ for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1292
+ if (!proj.isDirectory()) continue;
1293
+ found = findSubagentJsonlInProject(path.join(PROJECTS_DIR, proj.name), sessionId, agentId);
1294
+ if (found) break;
1295
+ }
1296
+ } catch (_) { /* projects dir missing */ }
1297
+ }
1298
+ if (found) subagentPathCache.set(key, found);
1276
1299
  return found || primary;
1277
1300
  }
1278
1301
 
1302
+ // Claude Code marks fork lineage in two ways:
1303
+ // 1. `logicalParentUuid` on a system record (when present) points to a uuid
1304
+ // in the parent session's JSONL.
1305
+ // 2. When absent, the fork copies the parent's early records verbatim, so
1306
+ // the earliest `uuid` in this session also exists (same uuid+timestamp)
1307
+ // in the parent's JSONL.
1308
+ // We try (1) first, then fall back to (2).
1309
+ const parentSessionCache = new Map();
1310
+ // Both anchor signals live in the first few records (system marker on top,
1311
+ // fork-copy starts at line 0), so cap the scan instead of reading the whole file.
1312
+ const FORK_ANCHOR_SCAN_LINES = 10;
1313
+ function findForkAnchorUuid(jsonlPath) {
1314
+ let text;
1315
+ try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
1316
+ let firstUuid = null;
1317
+ let scanned = 0;
1318
+ for (const l of text.split('\n')) {
1319
+ if (!l) continue;
1320
+ if (scanned++ >= FORK_ANCHOR_SCAN_LINES) break;
1321
+ try {
1322
+ const d = JSON.parse(l);
1323
+ if (d.logicalParentUuid) return d.logicalParentUuid;
1324
+ if (!firstUuid && d.uuid) firstUuid = d.uuid;
1325
+ } catch { /* skip malformed */ }
1326
+ }
1327
+ return firstUuid;
1328
+ }
1329
+ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1330
+ let files;
1331
+ try { files = readdirSync(projectDir); } catch { return null; }
1332
+ const candidates = [];
1333
+ for (const f of files) {
1334
+ if (!f.endsWith('.jsonl')) continue;
1335
+ const fp = path.join(projectDir, f);
1336
+ if (fp === excludeJsonlPath) continue;
1337
+ let text;
1338
+ try { text = readFileSync(fp, 'utf8'); } catch { continue; }
1339
+ if (!text.includes(targetUuid)) continue;
1340
+ for (const l of text.split('\n')) {
1341
+ if (!l || !l.includes(targetUuid)) continue;
1342
+ try {
1343
+ const d = JSON.parse(l);
1344
+ if (d.uuid === targetUuid && d.sessionId) {
1345
+ let mtime = 0;
1346
+ try { mtime = statSync(fp).mtimeMs; } catch { /* ignore */ }
1347
+ candidates.push({ parentSessionId: d.sessionId, parentJsonlPath: fp, mtime });
1348
+ break;
1349
+ }
1350
+ } catch { /* skip */ }
1351
+ }
1352
+ }
1353
+ if (!candidates.length) return null;
1354
+ candidates.sort((a, b) => a.mtime - b.mtime);
1355
+ const { parentSessionId, parentJsonlPath } = candidates[0];
1356
+ return { parentSessionId, parentJsonlPath };
1357
+ }
1358
+ function lookupParentSession(sessionId) {
1359
+ if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
1360
+ const meta = loadSessionMetadata()[sessionId];
1361
+ const result = { parentSessionId: null, parentJsonlPath: null };
1362
+ if (meta?.jsonlPath) {
1363
+ const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
1364
+ if (anchorUuid) {
1365
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
1366
+ if (hit) Object.assign(result, hit);
1367
+ }
1368
+ }
1369
+ if (result.parentSessionId) parentSessionCache.set(sessionId, result);
1370
+ return result;
1371
+ }
1372
+ app.get('/api/sessions/:sessionId/parent', (req, res) => {
1373
+ res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
1374
+ });
1375
+
1279
1376
  app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1280
1377
  const sessionId = resolveSessionId(req.params.sessionId);
1281
1378
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1418,6 +1515,19 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1418
1515
  res.json({ toolUseId: req.params.toolUseId, content });
1419
1516
  });
1420
1517
 
1518
+ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
1519
+ const metadata = loadSessionMetadata();
1520
+ const meta = metadata[req.params.sessionId];
1521
+ const jsonlPath = meta?.jsonlPath;
1522
+ if (!jsonlPath) return res.status(404).end();
1523
+ const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
1524
+ if (!img) return res.status(404).end();
1525
+ const buf = Buffer.from(img.data, 'base64');
1526
+ res.setHeader('Content-Type', img.mediaType);
1527
+ res.setHeader('Cache-Control', 'no-store');
1528
+ res.end(buf);
1529
+ });
1530
+
1421
1531
  app.get('/api/version', (req, res) => {
1422
1532
  const pkg = require('./package.json');
1423
1533
  res.json({ version: pkg.version });
@@ -1920,7 +2030,6 @@ contextStatusWatcher.on('all', (event, filePath) => {
1920
2030
  }
1921
2031
  });
1922
2032
 
1923
- const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
1924
2033
  async function cleanupContextStatus() {
1925
2034
  try {
1926
2035
  const entries = await fs.readdir(CONTEXT_STATUS_DIR);
@@ -1939,10 +2048,6 @@ async function cleanupContextStatus() {
1939
2048
  } catch (e) { /* dir may not exist */ }
1940
2049
  }
1941
2050
 
1942
- // Cleanup agent-activity folders older than 2 days
1943
- const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
1944
- const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
1945
-
1946
2051
  async function cleanupAgentActivity() {
1947
2052
  try {
1948
2053
  const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });