claude-code-kanban 4.11.0 → 4.13.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/README.md CHANGED
@@ -8,9 +8,7 @@
8
8
 
9
9
  > Watch Claude Code work, in real time.
10
10
 
11
- ![Dark mode](assets/screenshot-dark-v2.png)
12
-
13
- ![Light mode](assets/screenshot-light-v2.png)
11
+ ![Kanban board with session log](assets/shot-session-log.png)
14
12
 
15
13
  ## Getting Started
16
14
 
@@ -22,11 +20,7 @@ Hooks enable subagent tracking, waiting-for-user detection, and session activity
22
20
  npx claude-code-kanban --install
23
21
  ```
24
22
 
25
- This will:
26
- - Copy `agent-spy.sh` to `~/.claude/hooks/` (requires `jq`)
27
- - Add `SubagentStart`, `SubagentStop`, `TeammateIdle`, and `PostToolUse` hooks to `~/.claude/settings.json`
28
-
29
- All changes are non-destructive — existing settings are preserved.
23
+ Non-destructive — existing settings in `~/.claude/settings.json` are preserved. Uninstall anytime with `npx claude-code-kanban --uninstall`.
30
24
 
31
25
  ### 2. Start the dashboard
32
26
 
@@ -34,72 +28,34 @@ All changes are non-destructive — existing settings are preserved.
34
28
  npx claude-code-kanban --open
35
29
  ```
36
30
 
37
- Open http://localhost:3541 (or use `--open` to auto-launch the browser).
38
-
39
31
  ### 3. Use Claude Code as usual
40
32
 
41
- Tasks, agents, and messages appear on the board automatically. No changes to your Claude Code workflow needed.
42
-
43
- > To uninstall hooks: `npx claude-code-kanban --uninstall`
33
+ Tasks, agents, and messages appear on the board automatically Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. It never directs Claude's work.
44
34
 
45
35
  ## Features
46
36
 
47
37
  - **Real-time Kanban board** — Tasks move through Pending → In Progress → Completed as Claude works
48
- - **Agent log** — Live subagent tracking (start/stop/idle) with prompts, duration, and status
49
- - **Session message log** — Browse recent messages, tool calls, and model info (`Shift+L`)
38
+ - **Session log** — The full conversation timeline: prompts, replies, tool calls and results (`Shift+L`)
39
+ - **Agent log** — Live subagent tracking with prompts, duration, status, and idle detection
40
+ - **Task detail panel** — Full description, notes, blockedBy/blocks dependencies, inline editing
41
+ - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
42
+ - **Tool stats & impact** — Per-session tool usage breakdown and file impact
50
43
  - **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
51
- - **Plan correlation** — Plan sessions linked to their implementation sessions
52
44
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
53
- - **Task dependencies** — See blockedBy/blocks relationships and the critical path
54
- - **Live activity feed** — Stream of all in-progress tasks across every session
55
- - **Session management** — Fuzzy search, project/branch display, active/stale session styling
56
- - **Cleanup** — Delete tasks (with dependency checks) or bulk-delete entire sessions
57
- - **Keyboard shortcuts** — Press `?` for help; `Shift+M` for live message follow
58
-
59
- ## Configuration
60
-
61
- ```bash
62
- PORT=8080 npx claude-code-kanban # Custom port
63
- npx claude-code-kanban --open # Auto-open browser
64
- npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
65
- ```
66
-
67
- If port 3541 is in use, the server falls back to a random available port.
68
-
69
- ### Global install
70
-
71
- ```bash
72
- npm install -g claude-code-kanban
73
- claude-code-kanban --open
74
- ```
75
-
76
- ## How It Works
45
+ - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more each in light and dark
46
+ - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
47
+ - **Keyboard-first** — Press `?` for the full shortcut reference
77
48
 
78
- Claude Code writes task files to `~/.claude/tasks/` and conversation logs to `~/.claude/projects/`. The dashboard watches these directories with [chokidar](https://github.com/paulmillr/chokidar) and pushes updates to the browser via Server-Sent Events (SSE). Nothing is modified — the dashboard is read-only.
49
+ ![Session info](assets/shot-session-info.png)
79
50
 
80
- **Tasks** are picked up automatically from Claude Code's native task system (TodoWrite). No hooks needed.
51
+ ![Subagent preview](assets/shot-subagent-preview.png)
81
52
 
82
- **Hooks** extend observability beyond tasks. When installed, lightweight shell scripts fire on Claude Code lifecycle events and write JSON markers that the dashboard picks up:
53
+ ![Theme picker](assets/shot-theme-picker.png)
83
54
 
84
- | Hook event | What it enables |
85
- |------------|----------------|
86
- | `SubagentStart` / `SubagentStop` | Agent log — see subagent spawns, durations, prompts |
87
- | `TeammateIdle` | Idle detection for team member agents |
88
- | `PostToolUse` | Waiting-for-user detection (permission prompts, AskUserQuestion) |
89
55
 
90
56
  ## Context Window Monitoring
91
57
 
92
- Track real-time context window usage for each Claude Code session directly in the dashboard sidebar and detail panel.
93
-
94
- ### Setup
95
-
96
- The installer copies `context-status.sh` alongside the agent hooks:
97
-
98
- ```bash
99
- npx claude-code-kanban --install
100
- ```
101
-
102
- Then add it to your statusline in `~/.claude/settings.json`:
58
+ Per-session context usage bars, token/cost breakdowns, and model info in the sidebar and detail panel. The installer copies `context-status.sh` — wire it into your statusline in `~/.claude/settings.json`:
103
59
 
104
60
  ```json
105
61
  {
@@ -111,27 +67,17 @@ Then add it to your statusline in `~/.claude/settings.json`:
111
67
  }
112
68
  ```
113
69
 
114
- The script pipes through your existing statusline still works. It just writes a snapshot to `~/.claude/context-status/{sessionId}.json` on each update.
115
-
116
- ### What you get
70
+ The script pipes through, so your existing statusline keeps working.
117
71
 
118
- - **Sidebar bar** — compact context usage bar per session with color thresholds (green → yellow → orange → red) and a 200K token marker
119
- - **Detail panel** — input/output token breakdown, cache read tokens, cost, duration, API time, lines added/removed, and model name
120
- - Only shown for active or pinned sessions
121
-
122
- ## FAQ
123
-
124
- **Does this control Claude?**
125
- No. The viewer only observes — it never writes to task files or directs Claude's work.
126
-
127
- **Does it work with agent teams?**
128
- Yes. Team sessions are auto-detected with color-coded members and owner filtering.
72
+ ## Configuration
129
73
 
130
- **Does it require Claude Code to be running?**
131
- No. You can browse past sessions anytime. Live updates resume when Claude starts working again.
74
+ ```bash
75
+ PORT=8080 npx claude-code-kanban # Custom port (falls back if busy)
76
+ npx claude-code-kanban --open # Auto-open browser
77
+ npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
78
+ ```
132
79
 
133
- **What happens without hooks?**
134
- The Kanban board still shows tasks, but you won't see the agent log, waiting-for-user indicators, or live session activity. Run `npx claude-code-kanban --install` for the full experience.
80
+ Global install: `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
135
81
 
136
82
  ## License
137
83
 
package/lib/parsers.js CHANGED
@@ -551,6 +551,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
551
551
  const messages = [];
552
552
  const toolResults = new Map();
553
553
  const toolResultExtras = new Map();
554
+ const toolResultImageCounts = new Map();
554
555
  let readSize = Math.min(65536, stat.size);
555
556
 
556
557
  while (messages.length < limit) {
@@ -567,6 +568,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
567
568
  messages.length = 0;
568
569
  toolResults.clear();
569
570
  toolResultExtras.clear();
571
+ toolResultImageCounts.clear();
570
572
  for (const line of clean.split('\n')) {
571
573
  if (!line.trim()) continue;
572
574
  try {
@@ -823,6 +825,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
823
825
  .filter(c => c.type === 'text' && c.text)
824
826
  .map(c => c.text)
825
827
  .join('\n');
828
+ // A Read on an image file (e.g. .png) returns the image as a
829
+ // base64 block inside the tool_result. Count them so the
830
+ // renderer can request each by index and show what the agent
831
+ // saw; the bytes are fetched lazily per image via the endpoint.
832
+ let imgCount = 0;
833
+ for (const c of block.content) {
834
+ if (c && c.type === 'image' && c.source && c.source.type === 'base64') imgCount++;
835
+ }
836
+ if (imgCount) toolResultImageCounts.set(block.tool_use_id, imgCount);
826
837
  }
827
838
  if (resultText) {
828
839
  toolResults.set(block.tool_use_id, resultText);
@@ -900,6 +911,9 @@ function readRecentMessages(jsonlPath, limit = 10) {
900
911
  }
901
912
  msg.toolResultTruncated = truncated;
902
913
  }
914
+ if (msg.type === 'tool_use' && msg.toolUseId && toolResultImageCounts.has(msg.toolUseId)) {
915
+ msg.toolResultImageCount = toolResultImageCounts.get(msg.toolUseId);
916
+ }
903
917
  if (msg.type === 'tool_use' && msg.tool === 'AskUserQuestion'
904
918
  && msg.toolUseId && toolResultExtras.has(msg.toolUseId)) {
905
919
  const extra = toolResultExtras.get(msg.toolUseId);
@@ -981,18 +995,49 @@ function readUserImage(jsonlPath, msgUuid, blockIndex) {
981
995
  return null;
982
996
  }
983
997
 
998
+ // Read the Nth base64 image embedded in a tool_result block (e.g. the output of
999
+ // a Read on an image file). Scans for the tool_result matching toolUseId.
1000
+ function readToolResultImage(jsonlPath, toolUseId, n) {
1001
+ if (!toolUseId || !jsonlPath) return null;
1002
+ const idx = Number(n);
1003
+ if (!Number.isInteger(idx) || idx < 0) return null;
1004
+ try {
1005
+ const content = readFileSync(jsonlPath, 'utf8');
1006
+ const lines = content.split('\n');
1007
+ for (const line of lines) {
1008
+ if (!line || line.indexOf(toolUseId) === -1) continue;
1009
+ let obj;
1010
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1011
+ const blocks = obj?.message?.content;
1012
+ if (!Array.isArray(blocks)) continue;
1013
+ for (const block of blocks) {
1014
+ if (block?.type !== 'tool_result' || block.tool_use_id !== toolUseId) continue;
1015
+ if (!Array.isArray(block.content)) continue;
1016
+ const images = block.content.filter(
1017
+ (c) => c && c.type === 'image' && c.source && c.source.type === 'base64',
1018
+ );
1019
+ const img = images[idx];
1020
+ if (!img) return null;
1021
+ return { mediaType: img.source.media_type || 'image/png', data: img.source.data };
1022
+ }
1023
+ }
1024
+ } catch (_) {}
1025
+ return null;
1026
+ }
1027
+
984
1028
  const CACHED_IMAGE_MIME = {
985
1029
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
986
1030
  };
987
1031
 
988
- // Serve pasted image N from ~/.claude/image-cache/<sessionId>/. The cached file may
1032
+ // Serve pasted image N from <claudeDir>/image-cache/<sessionId>/. The cached file may
989
1033
  // be any image format, so resolve it by index rather than assuming .png. sessionId
990
1034
  // is validated to a bare id to prevent path traversal.
991
- function readCachedImage(sessionId, n) {
1035
+ function readCachedImage(sessionId, n, claudeDir) {
992
1036
  const idx = Number(n);
993
1037
  if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
994
1038
  if (!Number.isInteger(idx) || idx < 0) return null;
995
- const dir = path.join(require('os').homedir(), '.claude', 'image-cache', sessionId);
1039
+ const baseDir = claudeDir || path.join(require('os').homedir(), '.claude');
1040
+ const dir = path.join(baseDir, 'image-cache', sessionId);
996
1041
  try {
997
1042
  const match = readdirSync(dir).find((f) => {
998
1043
  const dot = f.lastIndexOf('.');
@@ -1438,6 +1483,7 @@ module.exports = {
1438
1483
  readMessagesPage,
1439
1484
  readFullToolResult,
1440
1485
  readUserImage,
1486
+ readToolResultImage,
1441
1487
  readCachedImage,
1442
1488
  updateLoopInfo,
1443
1489
  buildLoopInfoFromState,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.11.0",
3
+ "version": "4.13.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1643,17 +1643,24 @@ function _renderPinToDetail(pin) {
1643
1643
  if (pin.type === 'tool_use') {
1644
1644
  document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1645
1645
  const fullText = pin.fullDetail || pin.detail || '';
1646
- const pinParamsHtml = renderToolParamsHtml(pin.params);
1647
- const pinResultHtml = renderToolResultHtml(
1648
- pin.toolResult,
1649
- pin.toolResultTruncated,
1650
- pin.toolResultFull,
1651
- pin.toolUseId,
1652
- );
1646
+ const pinFindings = pin.tool === 'ReportFindings';
1647
+ const pinParamsHtml = pinFindings
1648
+ ? renderFindingsReport(pin.params, pin.toolResult)
1649
+ : renderToolParamsHtml(pin.params);
1650
+ const pinResultHtml = pinFindings
1651
+ ? ''
1652
+ : renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull, pin.toolUseId);
1653
1653
  const pinDetailEscaped = escapeHtml(fullText);
1654
1654
  const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1655
+ // Tool-result images are intentionally omitted here: their URL is built from
1656
+ // the global currentSessionId, but a pin can belong to a different session,
1657
+ // so rendering them would point at the wrong session's image.
1655
1658
  body.innerHTML =
1656
- (fullText ? `<pre class="${TINTED_PRE_CLASS}">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1659
+ (fullText
1660
+ ? `<pre class="${TINTED_PRE_CLASS}">${pinDetailRendered}</pre>`
1661
+ : pinFindings
1662
+ ? ''
1663
+ : '<em>No details</em>') +
1657
1664
  pinParamsHtml +
1658
1665
  pinResultHtml;
1659
1666
  } else if (pin.type === 'agent') {
@@ -1679,6 +1686,17 @@ const linkSvg = (size) =>
1679
1686
  //#endregion
1680
1687
 
1681
1688
  //#region MODALS
1689
+ // Shared markup for an attachment image and the labeled section that holds a grid
1690
+ // of them — used by both user-pasted images and agent tool-result images.
1691
+ function attachImageTag(url, alt) {
1692
+ return `<img src="${url}" loading="lazy" alt="${alt}" class="user-attach-image" />`;
1693
+ }
1694
+ function attachImageSection(label, imgsHtml) {
1695
+ return imgsHtml
1696
+ ? `<div class="user-attach-section"><div class="user-attach-label">${label}</div><div class="user-attach-images">${imgsHtml}</div></div>`
1697
+ : '';
1698
+ }
1699
+
1682
1700
  function renderUserAttachments(m) {
1683
1701
  const parts = [];
1684
1702
  if (m.images?.length && currentSessionId) {
@@ -1687,20 +1705,17 @@ function renderUserAttachments(m) {
1687
1705
  .map((img) => {
1688
1706
  // Cache-kind images live on disk (image-cache/<sessionId>/<n>.png); base64
1689
1707
  // images are read from the JSONL block and need the message uuid.
1690
- let url;
1691
- if (img.kind === 'cache') {
1692
- url = `/api/sessions/${sid}/cached-image/${img.n}`;
1693
- } else if (m.uuid) {
1694
- url = `/api/sessions/${sid}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1695
- } else {
1696
- return '';
1697
- }
1698
- return `<img src="${url}" loading="lazy" alt="user image" class="user-attach-image" />`;
1708
+ if (img.kind === 'cache') return attachImageTag(`/api/sessions/${sid}/cached-image/${img.n}`, 'user image');
1709
+ if (m.uuid)
1710
+ return attachImageTag(
1711
+ `/api/sessions/${sid}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`,
1712
+ 'user image',
1713
+ );
1714
+ return '';
1699
1715
  })
1700
1716
  .join('');
1701
- parts.push(
1702
- `<div class="user-attach-section"><div class="user-attach-label">Attached images</div><div class="user-attach-images">${imgs}</div></div>`,
1703
- );
1717
+ const section = attachImageSection('Attached images', imgs);
1718
+ if (section) parts.push(section);
1704
1719
  }
1705
1720
  if (m.toolResultRefs?.length) {
1706
1721
  const refs = m.toolResultRefs
@@ -1761,11 +1776,15 @@ function showMsgDetail(idx) {
1761
1776
  agentBtn.style.display = 'none';
1762
1777
  }
1763
1778
  const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
1764
- const toolParamsHtml = renderToolParamsHtml(
1765
- sendProto ? Object.fromEntries(Object.entries(m.params).filter(([k]) => k !== 'protocol')) : m.params,
1766
- );
1767
- const hideResult = m.tool === 'SendMessage' || TASK_TOOLS.has(m.tool);
1779
+ const isFindings = m.tool === 'ReportFindings';
1780
+ const toolParamsHtml = isFindings
1781
+ ? ''
1782
+ : renderToolParamsHtml(
1783
+ sendProto ? Object.fromEntries(Object.entries(m.params).filter(([k]) => k !== 'protocol')) : m.params,
1784
+ );
1785
+ const hideResult = m.tool === 'SendMessage' || TASK_TOOLS.has(m.tool) || isFindings;
1768
1786
  const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
1787
+ const findingsHtml = isFindings ? renderFindingsReport(m.params, m.toolResult) : '';
1769
1788
  const toolResultHtml = hideResult
1770
1789
  ? ''
1771
1790
  : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull, m.toolUseId);
@@ -1777,8 +1796,8 @@ function showMsgDetail(idx) {
1777
1796
  mainHtml = `${descHtml}<div class="markdown-body">${renderMarkdown(fullText)}</div>`;
1778
1797
  } else if (hasAgentTabs) {
1779
1798
  mainHtml = descHtml || '';
1780
- } else if (taskResultHtml) {
1781
- mainHtml = '';
1799
+ } else if (taskResultHtml || findingsHtml) {
1800
+ mainHtml = descHtml || '';
1782
1801
  } else if (fullText) {
1783
1802
  const detailEscaped = escapeHtml(fullText);
1784
1803
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
@@ -1787,8 +1806,16 @@ function showMsgDetail(idx) {
1787
1806
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1788
1807
  }
1789
1808
  const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
1809
+ const toolResultImagesHtml = renderToolResultImagesHtml(m.toolResultImageCount, m.toolUseId);
1790
1810
  body.innerHTML =
1791
- mainHtml + toolParamsHtml + answersHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1811
+ mainHtml +
1812
+ toolParamsHtml +
1813
+ answersHtml +
1814
+ taskResultHtml +
1815
+ findingsHtml +
1816
+ (hasAgentTabs ? '' : toolResultHtml) +
1817
+ toolResultImagesHtml +
1818
+ agentExtraHtml;
1792
1819
  } else if (m.type === 'teammate') {
1793
1820
  document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
1794
1821
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
@@ -1978,6 +2005,7 @@ function formatTaskToolDetail(params) {
1978
2005
  }
1979
2006
  function getToolDetail(tool, params, detail) {
1980
2007
  if (TASK_TOOLS.has(tool)) return formatTaskToolDetail(params);
2008
+ if (tool === 'ReportFindings') return formatFindingsToolDetail(params);
1981
2009
  if (!detail) return '';
1982
2010
  let extra = '';
1983
2011
  if (tool === 'Read' && params) {
@@ -2044,6 +2072,66 @@ function renderAnswerPayloadHtml(answerPayload) {
2044
2072
  </div>`;
2045
2073
  }
2046
2074
 
2075
+ //#region FINDINGS
2076
+ const FINDING_VERDICT_COLORS = {
2077
+ CONFIRMED: 'var(--danger, #ef5350)',
2078
+ PLAUSIBLE: 'var(--warning, #f0b429)',
2079
+ };
2080
+ const FINDING_OUTCOME_COLORS = {
2081
+ fixed: 'var(--success, #3ecf8e)',
2082
+ no_change_needed: 'var(--info, #60a5fa)',
2083
+ skipped: 'var(--text-muted)',
2084
+ };
2085
+ function findingBadge(label, color) {
2086
+ return `<span class="finding-badge" style="color:${color}">${escapeHtml(label)}</span>`;
2087
+ }
2088
+ function formatFindingsToolDetail(params) {
2089
+ const findings = Array.isArray(params?.findings) ? params.findings : [];
2090
+ const parts = [
2091
+ `<span style="color:var(--text-secondary)">${findings.length} finding${findings.length === 1 ? '' : 's'}</span>`,
2092
+ ];
2093
+ const confirmed = findings.filter((f) => f && f.verdict === 'CONFIRMED').length;
2094
+ const plausible = findings.filter((f) => f && f.verdict === 'PLAUSIBLE').length;
2095
+ if (confirmed) parts.push(`<span style="color:${FINDING_VERDICT_COLORS.CONFIRMED}">${confirmed} confirmed</span>`);
2096
+ if (plausible) parts.push(`<span style="color:${FINDING_VERDICT_COLORS.PLAUSIBLE}">${plausible} plausible</span>`);
2097
+ if (params?.level) parts.push(findingBadge(params.level, 'var(--text-muted)'));
2098
+ return ` ${parts.join(' <span style="color:var(--text-muted)">·</span> ')}`;
2099
+ }
2100
+ function renderFindingsReport(params, toolResult) {
2101
+ const findings = Array.isArray(params?.findings) ? params.findings : [];
2102
+ let html = '<div class="protocol-detail findings-report">';
2103
+ html += `<span class="protocol-type-badge">${findings.length} finding${findings.length === 1 ? '' : 's'}</span>`;
2104
+ if (params?.level) html += ` ${findingBadge(`level: ${params.level}`, 'var(--text-muted)')}`;
2105
+ if (!findings.length) {
2106
+ html += '<div class="finding-scenario">No findings survived verification.</div>';
2107
+ } else {
2108
+ html += '<div class="findings-list">';
2109
+ findings.forEach((f, i) => {
2110
+ if (!f || typeof f !== 'object') return;
2111
+ const badges = [];
2112
+ if (f.verdict && FINDING_VERDICT_COLORS[f.verdict]) {
2113
+ badges.push(findingBadge(f.verdict, FINDING_VERDICT_COLORS[f.verdict]));
2114
+ }
2115
+ if (f.outcome && FINDING_OUTCOME_COLORS[f.outcome]) {
2116
+ badges.push(findingBadge(f.outcome.replace(/_/g, ' '), FINDING_OUTCOME_COLORS[f.outcome]));
2117
+ }
2118
+ const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ''}` : '';
2119
+ html += `<div class="finding-card">
2120
+ <span class="finding-rank">${i + 1}</span>
2121
+ <div class="finding-main">
2122
+ ${badges.length || loc ? `<div class="finding-badges">${badges.join('')}${loc ? `<span class="finding-file">${escapeHtml(loc)}</span>` : ''}</div>` : ''}
2123
+ ${f.summary ? `<div class="finding-summary">${escapeHtml(f.summary)}</div>` : ''}
2124
+ ${f.failure_scenario ? `<div class="finding-scenario">${escapeHtml(f.failure_scenario)}</div>` : ''}
2125
+ </div>
2126
+ </div>`;
2127
+ });
2128
+ html += '</div>';
2129
+ }
2130
+ if (toolResult) html += `<div class="findings-result-note">${escapeHtml(toolResult.trim())}</div>`;
2131
+ return `${html}</div>`;
2132
+ }
2133
+ //#endregion
2134
+
2047
2135
  function renderToolParamsHtml(params) {
2048
2136
  if (!params) return '';
2049
2137
  const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'contentFull', 'plan']);
@@ -2240,6 +2328,18 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
2240
2328
  </div>`;
2241
2329
  }
2242
2330
 
2331
+ // Render base64 images returned by a tool (e.g. a Read on a .png) so the user
2332
+ // can see what the agent saw, mirroring how user-attached images are shown.
2333
+ function renderToolResultImagesHtml(count, toolUseId) {
2334
+ if (!count || !currentSessionId || !toolUseId) return '';
2335
+ const sid = encodeURIComponent(currentSessionId);
2336
+ const tid = encodeURIComponent(toolUseId);
2337
+ const imgs = Array.from({ length: count }, (_, i) =>
2338
+ attachImageTag(`/api/sessions/${sid}/tool-result-image/${tid}/${i}`, 'tool result image'),
2339
+ ).join('');
2340
+ return attachImageSection('Image output', imgs);
2341
+ }
2342
+
2243
2343
  async function _toggleToolResultExpand(btn) {
2244
2344
  const f = document.getElementById(btn.dataset.expandId);
2245
2345
  if (!f) return;
package/public/style.css CHANGED
@@ -2337,7 +2337,7 @@ body::before {
2337
2337
  z-index: 1;
2338
2338
  line-height: 0;
2339
2339
  }
2340
- .session-item:hover .session-pin-btn:not(.pinned) {
2340
+ .session-item:hover .session-pin-btn:not(.pinned):not(.sticky) {
2341
2341
  opacity: 0.5;
2342
2342
  }
2343
2343
  .session-pin-btn:hover {
@@ -2833,6 +2833,75 @@ body::before {
2833
2833
 
2834
2834
  /* #endregion */
2835
2835
 
2836
+ /* #region FINDINGS */
2837
+ .findings-list {
2838
+ display: flex;
2839
+ flex-direction: column;
2840
+ gap: 8px;
2841
+ margin-top: 8px;
2842
+ }
2843
+ .finding-card {
2844
+ display: flex;
2845
+ gap: 10px;
2846
+ padding: 8px 10px;
2847
+ border: 1px solid var(--border);
2848
+ border-radius: 6px;
2849
+ background: var(--bg-surface);
2850
+ }
2851
+ .finding-rank {
2852
+ color: var(--text-muted);
2853
+ font-weight: 600;
2854
+ font-size: 0.8rem;
2855
+ min-width: 1.6em;
2856
+ text-align: right;
2857
+ line-height: 1.7;
2858
+ }
2859
+ .finding-main {
2860
+ flex: 1;
2861
+ min-width: 0;
2862
+ }
2863
+ .finding-badges {
2864
+ display: flex;
2865
+ flex-wrap: wrap;
2866
+ align-items: baseline;
2867
+ gap: 6px;
2868
+ margin-bottom: 4px;
2869
+ }
2870
+ .finding-badge {
2871
+ display: inline-block;
2872
+ font-size: 0.68rem;
2873
+ font-weight: 700;
2874
+ letter-spacing: 0.04em;
2875
+ text-transform: uppercase;
2876
+ padding: 1px 6px;
2877
+ border-radius: 3px;
2878
+ border: 1px solid currentColor;
2879
+ white-space: nowrap;
2880
+ }
2881
+ .finding-file {
2882
+ font-family: var(--mono);
2883
+ font-size: 0.78rem;
2884
+ color: var(--text-secondary);
2885
+ word-break: break-all;
2886
+ }
2887
+ .finding-summary {
2888
+ font-size: 0.88rem;
2889
+ color: var(--text-primary);
2890
+ margin-bottom: 2px;
2891
+ }
2892
+ .finding-scenario {
2893
+ font-size: 0.8rem;
2894
+ color: var(--text-muted);
2895
+ }
2896
+ .findings-result-note {
2897
+ margin-top: 10px;
2898
+ padding-top: 8px;
2899
+ border-top: 1px solid var(--border);
2900
+ font-size: 0.8rem;
2901
+ color: var(--text-muted);
2902
+ }
2903
+ /* #endregion */
2904
+
2836
2905
  /* #region AGENT_FOOTER */
2837
2906
  .agent-footer {
2838
2907
  display: none;
@@ -4355,7 +4424,8 @@ pre.mermaid svg {
4355
4424
  }
4356
4425
  .session-item.stale:hover,
4357
4426
  .session-item.stale.active,
4358
- .session-item.stale.kb-selected {
4427
+ .session-item.stale.kb-selected,
4428
+ .session-item.stale:has(.session-pin-btn.sticky) {
4359
4429
  opacity: 1;
4360
4430
  }
4361
4431
  .session-item.stale .session-time {
package/server.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  extractModelFromTranscript,
23
23
  readFullToolResult,
24
24
  readUserImage,
25
+ readToolResultImage,
25
26
  readCachedImage,
26
27
  updateLoopInfo,
27
28
  buildLoopInfoFromState
@@ -53,7 +54,7 @@ function getClaudeDir() {
53
54
  return dir.startsWith('~') ? dir.replace('~', os.homedir()) : dir;
54
55
  }
55
56
  }
56
- return process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
57
+ return process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude');
57
58
  }
58
59
 
59
60
  function getArgUrl(argName, envName) {
@@ -232,13 +233,13 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
232
233
  for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
233
234
  try {
234
235
  const agent = readAgentJsonl(path.join(agentDir, file));
235
- if (isTeam && isAgentLive(agent)) {
236
+ // Idle agents never mark a session active (an idle teammate lingers and
237
+ // would pin the filter). Teams skip freshness so long-running teammates stay visible.
238
+ if (agent.status === 'active' && (isTeam || isAgentFresh(agent))) {
236
239
  result.hasActive = true;
237
- if (agent.status === 'active') result.hasRunning = true;
238
- } else if (isAgentFresh(agent)) {
239
- if (agent.status === 'active') { result.hasActive = true; result.hasRunning = true; }
240
+ result.hasRunning = true;
240
241
  }
241
- if (result.hasRunning && result.hasActive) break;
242
+ if (result.hasRunning) break;
242
243
  } catch (e) { /* skip invalid */ }
243
244
  }
244
245
  } catch (e) { /* ignore */ }
@@ -307,7 +308,7 @@ function loadLiveSessions() {
307
308
  try {
308
309
  const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
309
310
  if (s?.sessionId && s.kind === 'interactive') {
310
- sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0 });
311
+ sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
311
312
  }
312
313
  } catch (_) { /* skip invalid */ }
313
314
  }
@@ -318,6 +319,20 @@ function loadLiveSessions() {
318
319
  return sessions;
319
320
  }
320
321
 
322
+ // An open-but-idle interactive session keeps touching its JSONL (metadata-line
323
+ // rewrites), so mtime alone reads as activity for as long as the terminal stays
324
+ // open. Claude Code's live-session registry knows the real state — trust its
325
+ // 'idle' over the mtime. No registry entry (or any other status) falls back to
326
+ // the mtime rule.
327
+ function isRegistryIdle(sessionId) {
328
+ const live = loadLiveSessions().find(s => s.sessionId === sessionId);
329
+ return live?.status === 'idle';
330
+ }
331
+
332
+ function hasRecentLogActivity(sessionId, logAge) {
333
+ return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
334
+ }
335
+
321
336
  // Given a self-team config, return the live interactive session id that owns it
322
337
  // (same cwd, startedAt within the boot window of the team's createdAt), or null.
323
338
  function resolveSelfTeamOwner(cfg) {
@@ -452,19 +467,29 @@ function getCustomTaskDir(sessionId) {
452
467
  // Check team-named task directory (teams store tasks under ~/.claude/tasks/<teamName>/).
453
468
  // Match either the recorded leadSessionId, or — for 2.1.x self-teams whose lead is a
454
469
  // team-lead agent id — the live interactive session that owns the team (see resolveSelfTeamOwner).
470
+ // A live session can own several team dirs at once — its own (often empty) self-team plus a
471
+ // resumed session's dir holding the real tasks — so pick the richest, mirroring attachTeamTasks'
472
+ // "prefer more tasks" rule. Returning the first match (readdir order) can pick the empty dir,
473
+ // making the board show 0 tasks while the session card counts the other dir.
455
474
  if (existsSync(TEAMS_DIR)) {
456
475
  try {
476
+ let bestDir = null, bestCount = -1;
457
477
  for (const dir of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
458
478
  if (!dir.isDirectory()) continue;
459
479
  const cfg = loadTeamConfig(dir.name);
460
480
  if (!cfg) continue;
461
481
  const owns = cfg.leadSessionId === sessionId
462
482
  || (isAutoSelfTeam(cfg) && resolveSelfTeamOwner(cfg) === sessionId);
463
- if (owns) {
464
- const teamTaskDir = path.join(TASKS_DIR, dir.name);
465
- if (existsSync(teamTaskDir)) return teamTaskDir;
483
+ if (!owns) continue;
484
+ const teamTaskDir = path.join(TASKS_DIR, dir.name);
485
+ if (!existsSync(teamTaskDir)) continue;
486
+ const count = getTaskCounts(teamTaskDir).taskCount;
487
+ if (count > bestCount) {
488
+ bestCount = count;
489
+ bestDir = teamTaskDir;
466
490
  }
467
491
  }
492
+ if (bestDir) return bestDir;
468
493
  } catch (_) {}
469
494
  }
470
495
  return null;
@@ -808,7 +833,7 @@ function buildSessionObject(id, meta, overrides = {}) {
808
833
  hasActiveAgents: false,
809
834
  hasRunningAgents: false,
810
835
  hasWaitingForUser: false,
811
- hasRecentLog: logAge <= SESSION_STALE_MS,
836
+ hasRecentLog: hasRecentLogActivity(id, logAge),
812
837
  jsonlPath: meta.jsonlPath || null,
813
838
  tasksDir: null,
814
839
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
@@ -866,7 +891,7 @@ app.get('/api/sessions', async (req, res) => {
866
891
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
867
892
  // Mirrors the post-filter predicate using only signals already computed above.
868
893
  if (activeFilter && !pinnedIds.has(entry.name)) {
869
- const hasRecentLog = logAge <= SESSION_STALE_MS;
894
+ const hasRecentLog = hasRecentLogActivity(entry.name, logAge);
870
895
  const cheaplyActive = logStat.hasMessages && (
871
896
  hasRecentLog
872
897
  || agentStatus.hasActive
@@ -967,7 +992,7 @@ app.get('/api/sessions', async (req, res) => {
967
992
 
968
993
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
969
994
  if (activeFilter && !pinnedIds.has(sessionId)) {
970
- const hasRecentLog = logAge <= SESSION_STALE_MS;
995
+ const hasRecentLog = hasRecentLogActivity(sessionId, logAge);
971
996
  const cheaplyActive = logStat.hasMessages && (
972
997
  hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
973
998
  );
@@ -1927,9 +1952,11 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1927
1952
  compactedMsgs[i].compactSummary = compactSummaries[i].summary;
1928
1953
  }
1929
1954
  }
1955
+ // The client keeps needing toolUseId when it builds a follow-up URL from it:
1956
+ // lazy-fetching a truncated tool result, or fetching each tool-result image.
1957
+ const clientNeedsToolUseId = (msg) => msg.toolResultTruncated || msg.toolResultImageCount;
1930
1958
  for (const msg of messages) {
1931
- // Keep toolUseId on truncated tool results so the client can lazy-fetch the full text
1932
- if (msg.toolUseId && !msg.toolResultTruncated) delete msg.toolUseId;
1959
+ if (msg.toolUseId && !clientNeedsToolUseId(msg)) delete msg.toolUseId;
1933
1960
  delete msg.promptId;
1934
1961
  }
1935
1962
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
@@ -2069,8 +2096,21 @@ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) =
2069
2096
  res.end(buf);
2070
2097
  });
2071
2098
 
2099
+ app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', (req, res) => {
2100
+ const metadata = loadSessionMetadata();
2101
+ const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
2102
+ const jsonlPath = meta?.jsonlPath;
2103
+ if (!jsonlPath) return res.status(404).end();
2104
+ const img = readToolResultImage(jsonlPath, req.params.toolUseId, req.params.n);
2105
+ if (!img) return res.status(404).end();
2106
+ const buf = Buffer.from(img.data, 'base64');
2107
+ res.setHeader('Content-Type', img.mediaType);
2108
+ res.setHeader('Cache-Control', 'no-store');
2109
+ res.end(buf);
2110
+ });
2111
+
2072
2112
  app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
2073
- const img = readCachedImage(req.params.sessionId, req.params.n);
2113
+ const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
2074
2114
  if (!img) return res.status(404).end();
2075
2115
  res.setHeader('Content-Type', img.mediaType);
2076
2116
  res.setHeader('Cache-Control', 'no-store');