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 +23 -77
- package/lib/parsers.js +49 -3
- package/package.json +1 -1
- package/public/app.js +127 -27
- package/public/style.css +72 -2
- package/server.js +56 -16
package/README.md
CHANGED
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
> Watch Claude Code work, in real time.
|
|
10
10
|
|
|
11
|
-

|
|
11
|
+

|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
49
|
-
- **
|
|
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
|
-
- **
|
|
54
|
-
- **
|
|
55
|
-
- **
|
|
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
|
-
|
|
49
|
+

|
|
79
50
|
|
|
80
|
-
|
|
51
|
+

|
|
81
52
|
|
|
82
|
-
|
|
53
|
+

|
|
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
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
### What you get
|
|
70
|
+
The script pipes through, so your existing statusline keeps working.
|
|
117
71
|
|
|
118
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
|
1647
|
-
const
|
|
1648
|
-
pin.toolResult
|
|
1649
|
-
pin.
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
|
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
|
-
|
|
1691
|
-
if (
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
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
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
465
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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');
|