claude-code-kanban 4.7.0 → 4.8.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
@@ -34,7 +34,7 @@ All changes are non-destructive — existing settings are preserved.
34
34
  npx claude-code-kanban --open
35
35
  ```
36
36
 
37
- Open http://localhost:3456 (or use `--open` to auto-launch the browser).
37
+ Open http://localhost:3541 (or use `--open` to auto-launch the browser).
38
38
 
39
39
  ### 3. Use Claude Code as usual
40
40
 
@@ -64,7 +64,7 @@ npx claude-code-kanban --open # Auto-open browser
64
64
  npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
65
65
  ```
66
66
 
67
- If port 3456 is in use, the server falls back to a random available port.
67
+ If port 3541 is in use, the server falls back to a random available port.
68
68
 
69
69
  ### Global install
70
70
 
package/cli.js CHANGED
@@ -145,7 +145,7 @@ function printTopHelp() {
145
145
  console.log(' --help, -h Show help (top-level, noun-level, or leaf-level)');
146
146
  console.log(' --version, -v Print version and exit');
147
147
  console.log('\nServer mode (no subcommand):');
148
- console.log(' --port <n> Port to listen on (default 3456)');
148
+ console.log(' --port <n> Port to listen on (default 3541)');
149
149
  console.log(' --dir <path> Override Claude config dir (default ~/.claude)');
150
150
  console.log(' --open Open browser on start');
151
151
  console.log(' --install, --uninstall Install or remove the agent-spy hook');
@@ -187,7 +187,7 @@ function getArgValue(args, name) {
187
187
  return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
188
188
  }
189
189
 
190
- function cliPort() { return process.env.PORT || 3456; }
190
+ function cliPort() { return process.env.PORT || 3541; }
191
191
  function unreachable() { return `Cannot reach cck server on port ${cliPort()}. Start it first with "claude-code-kanban".`; }
192
192
 
193
193
  class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
package/lib/parsers.js CHANGED
@@ -146,17 +146,49 @@ function parseImageMarkers(text) {
146
146
  return { refs, text: cleaned };
147
147
  }
148
148
 
149
+ // Compact usage suffix for a task-notification chip: " · 22.3k tok · 6 tools · 119s".
150
+ function formatTaskUsage(usage) {
151
+ if (!usage) return '';
152
+ const parts = [];
153
+ if (usage.subagentTokens != null) {
154
+ const t = usage.subagentTokens;
155
+ const tok = t >= 1000 ? (t / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(t);
156
+ parts.push(tok + ' tok');
157
+ }
158
+ if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
159
+ if (usage.durationMs != null) parts.push(Math.round(usage.durationMs / 1000) + 's');
160
+ return parts.length ? ' · ' + parts.join(' · ') : '';
161
+ }
162
+
149
163
  function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
150
164
  if (sysLabel === '__skip__') return;
151
- const safeText = text || '';
165
+ let safeText = text || '';
166
+ let label = sysLabel;
167
+ // Background-task completion notifications are harness events, not user input.
168
+ // Whichever path delivered them (normal type:'user' or a queued enqueue), show
169
+ // a rich summary+usage chip and surface the agent's actual result as the body —
170
+ // never the raw <task-notification> envelope under a person icon.
171
+ const notif = parseTaskNotification(safeText);
172
+ if (notif) {
173
+ const base = notif.summary || (notif.status ? `Background task ${notif.status}` : 'Background task notification');
174
+ label = base + formatTaskUsage(notif.usage);
175
+ safeText = notif.result || base;
176
+ }
152
177
  const truncated = safeText.length > USER_TEXT_MAX;
153
178
  const msg = {
154
179
  type: 'user',
155
180
  text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
156
181
  fullText: truncated ? safeText : null,
157
182
  timestamp,
158
- ...(sysLabel && { systemLabel: sysLabel })
183
+ ...(label && { systemLabel: label })
159
184
  };
185
+ // Tag task-notifications so the client can group the duplicate enqueue+delivered
186
+ // pair (same taskId) and join the agent type from the agents list.
187
+ if (notif) {
188
+ msg.taskNotification = true;
189
+ if (notif.taskId) msg.taskId = notif.taskId;
190
+ if (notif.status) msg.taskStatus = notif.status;
191
+ }
160
192
  if (extras) {
161
193
  if (extras.uuid) msg.uuid = extras.uuid;
162
194
  if (extras.images && extras.images.length) msg.images = extras.images;
@@ -435,6 +467,58 @@ function readSessionInfoFromJsonl(jsonlPath) {
435
467
  return result;
436
468
  }
437
469
 
470
+ // Background-task completion records arrive as a user message whose content is a
471
+ // <task-notification> envelope. Rendering the raw text leaks the tag values as a
472
+ // run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
473
+ // mashed together (subagent_tokens|tool_uses|duration_ms with tags stripped).
474
+ // Parse the envelope into structured fields so the UI can show just the agent's
475
+ // result and a clean metadata line instead of the raw wrapper. Returns null when
476
+ // the text is not a task-notification.
477
+ function parseTaskNotification(text) {
478
+ if (typeof text !== 'string' || !text.includes('<task-notification>')) return null;
479
+ // Metadata fields are machine-generated single-line values that appear before
480
+ // the free-form <result>, so first-match non-greedy is safe for them.
481
+ const pick = (tag) => {
482
+ const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
483
+ return m ? m[1].trim() : null;
484
+ };
485
+ // <result> and <usage> carry UNescaped agent text that can itself contain
486
+ // literal "</result>" / "<usage>" (e.g. an agent describing this very format).
487
+ // The real closing tags are always the LAST occurrence, so match greedily and
488
+ // take the last <usage> block to avoid truncating on embedded markers.
489
+ const lastBlock = (tag) => {
490
+ const m = text.match(new RegExp(`<${tag}>([\\s\\S]*)<\\/${tag}>`));
491
+ return m ? m[1] : null;
492
+ };
493
+ let usage = null;
494
+ const usageRaw = lastBlock('usage');
495
+ if (usageRaw != null) {
496
+ const uNum = (tag) => {
497
+ // Take the LAST match: the real <usage> wins over any example block an agent
498
+ // embedded in its result.
499
+ const last = [...usageRaw.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].at(-1);
500
+ if (!last) return null;
501
+ const n = Number(last[1]);
502
+ return Number.isFinite(n) ? n : null;
503
+ };
504
+ usage = {
505
+ subagentTokens: uNum('subagent_tokens'),
506
+ toolUses: uNum('tool_uses'),
507
+ durationMs: uNum('duration_ms')
508
+ };
509
+ }
510
+ const resultRaw = lastBlock('result');
511
+ return {
512
+ taskId: pick('task-id'),
513
+ toolUseId: pick('tool-use-id'),
514
+ outputFile: pick('output-file'),
515
+ status: pick('status'),
516
+ summary: pick('summary'),
517
+ result: resultRaw != null ? resultRaw.trim() : null,
518
+ usage
519
+ };
520
+ }
521
+
438
522
  function getSystemMessageLabel(text) {
439
523
  const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
440
524
  if (taskMatch) return taskMatch[1].trim();
@@ -1044,11 +1128,25 @@ function buildSessionDigest(jsonlPath) {
1044
1128
  const obj = JSON.parse(line);
1045
1129
  const tur = obj.toolUseResult;
1046
1130
  if (tur?.agentId) {
1131
+ // Foreground agents report their cost on the completion toolUseResult
1132
+ // (totalTokens/totalToolUseCount/totalDurationMs) — the same numbers a
1133
+ // background agent surfaces via its <task-notification> <usage> block. Keep
1134
+ // the raw numbers (for the agent modal's per-stat chips) and a formatted
1135
+ // one-liner via the shared formatter (for the inline message row):
1136
+ // " · 31.5k tok · 5 tools · 34s".
1137
+ const usage = {};
1138
+ if (tur.totalTokens != null) usage.tokens = tur.totalTokens;
1139
+ if (tur.totalToolUseCount != null) usage.tools = tur.totalToolUseCount;
1140
+ if (tur.totalDurationMs != null) usage.durationMs = tur.totalDurationMs;
1141
+ const hasUsage = Object.keys(usage).length > 0;
1142
+ const usageText = hasUsage
1143
+ ? formatTaskUsage({ subagentTokens: usage.tokens, toolUses: usage.tools, durationMs: usage.durationMs })
1144
+ : null;
1047
1145
  const blocks = obj.message?.content;
1048
1146
  if (Array.isArray(blocks)) {
1049
1147
  for (const b of blocks) {
1050
1148
  if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
1051
- map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null };
1149
+ map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage: hasUsage ? usage : null, usageText };
1052
1150
  }
1053
1151
  }
1054
1152
  }
@@ -1333,6 +1431,8 @@ module.exports = {
1333
1431
  parseTeamConfig,
1334
1432
  parseSessionsIndex,
1335
1433
  parseJsonlLine,
1434
+ parseTaskNotification,
1435
+ getSystemMessageLabel,
1336
1436
  readSessionInfoFromJsonl,
1337
1437
  readRecentMessages,
1338
1438
  readMessagesPage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.7.0",
3
+ "version": "4.8.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.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Agent activity tracking for claude-code-kanban dashboard"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: kanban
3
3
  description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "pin/unpin a session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
4
- compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
4
+ compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3541).
5
5
  ---
6
6
 
7
7
  # Kanban Skill
@@ -69,6 +69,6 @@ Add `--json` to any list-style verb for machine-readable output.
69
69
 
70
70
  ## Troubleshooting
71
71
 
72
- - **"Cannot reach cck server on port 3456"** → ask the user to start it: `claude-code-kanban` (or `npm start` in the cck repo).
72
+ - **"Cannot reach cck server on port 3541"** → ask the user to start it: `claude-code-kanban` (or `npm start` in the cck repo).
73
73
  - **Different port** → set `PORT=<n>` env var when invoking the CLI.
74
74
  - **Ambiguous session prefix (HTTP 409)** → use the full id. With `${CLAUDE_SESSION_ID}` this won't happen.
package/public/app.js CHANGED
@@ -945,6 +945,130 @@ function cleanMessageText(text) {
945
945
  .trim();
946
946
  }
947
947
 
948
+ // A GFM table delimiter row (|---|:--:|): only pipes/dashes/colons/spaces, with
949
+ // at least one dash. Pairs with a preceding line that has cells to mark a table.
950
+ function isTableDelimiter(line) {
951
+ const l = (line || '').trim();
952
+ return l.includes('-') && /^\|?[ :|-]+\|?$/.test(l);
953
+ }
954
+
955
+ // Cut a string to at most `max` chars at a word boundary (no mid-word chops).
956
+ function truncateAtWord(s, max) {
957
+ if (s.length <= max) return s;
958
+ const cut = s.slice(0, max);
959
+ const sp = cut.lastIndexOf(' ');
960
+ return (sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd();
961
+ }
962
+
963
+ // Build a markdown preview for an assistant message: render real markdown but
964
+ // truncate on clean structural boundaries so the feed stays compact and the
965
+ // output is always valid markdown (never cut mid code-fence or mid table-row).
966
+ // Prose is bounded by a line count AND a char budget (long single paragraphs are
967
+ // cut at a word boundary). Returns { md, remainder } where remainder is a short
968
+ // label describing what was cut (e.g. "+12 lines"), or '' when nothing was cut.
969
+ function buildAssistantPreview(text) {
970
+ const lines = stripAnsi(text || '')
971
+ .replace(/\r/g, '')
972
+ .split('\n');
973
+ const MAX_LINES = 5; // non-blank content lines kept before truncating
974
+ const MAX_CHARS = 280; // total prose/code/table chars kept
975
+ const MAX_ROWS = 3; // table body rows
976
+ const MAX_CODE_LINES = 8; // lines kept inside a fenced code block
977
+ const countRest = (idx) => {
978
+ let c = 0;
979
+ for (let j = idx; j < lines.length; j++) if (lines[j].trim()) c++;
980
+ return c;
981
+ };
982
+ const pluralize = (n, word) => `+${n} ${word}${n > 1 ? 's' : ''}`;
983
+ const out = [];
984
+ let remainder = '';
985
+ let content = 0;
986
+ let chars = 0;
987
+ let i = 0;
988
+
989
+ for (; i < lines.length; i++) {
990
+ const line = lines[i];
991
+ const trimmed = line.trim();
992
+ if (content >= MAX_LINES || chars >= MAX_CHARS) {
993
+ const rest = countRest(i);
994
+ if (rest) remainder = pluralize(rest, 'line');
995
+ break;
996
+ }
997
+
998
+ // Fenced code block — copy verbatim (capped), never break the fence open.
999
+ const fence = trimmed.match(/^(```|~~~)/);
1000
+ if (fence) {
1001
+ const marker = fence[1];
1002
+ out.push(line);
1003
+ let codeLines = 0;
1004
+ let dropped = 0;
1005
+ // Advance to the real closing fence, keeping at most MAX_CODE_LINES lines,
1006
+ // so leftover code isn't re-parsed as top-level markdown after the cap.
1007
+ for (i++; i < lines.length && !lines[i].trim().startsWith(marker); i++) {
1008
+ if (codeLines < MAX_CODE_LINES) {
1009
+ out.push(lines[i]);
1010
+ codeLines++;
1011
+ chars += lines[i].length;
1012
+ } else {
1013
+ dropped++;
1014
+ }
1015
+ }
1016
+ out.push(marker); // close the fence (covers capped / unterminated blocks)
1017
+ content++;
1018
+ if (dropped) {
1019
+ // Stop at the truncated code block rather than resuming after the gap.
1020
+ remainder = pluralize(dropped, 'code line');
1021
+ break;
1022
+ }
1023
+ continue;
1024
+ }
1025
+
1026
+ // Table — keep header + separator + up to MAX_ROWS body rows.
1027
+ if (line.includes('|') && isTableDelimiter(lines[i + 1])) {
1028
+ out.push(line, lines[i + 1]);
1029
+ content++;
1030
+ chars += line.length;
1031
+ i++;
1032
+ let rows = 0;
1033
+ let droppedRows = 0;
1034
+ while (lines[i + 1]?.includes('|')) {
1035
+ if (rows >= MAX_ROWS) {
1036
+ // Skip remaining rows so they aren't re-rendered as prose lines.
1037
+ droppedRows++;
1038
+ i++;
1039
+ continue;
1040
+ }
1041
+ out.push(lines[++i]);
1042
+ rows++;
1043
+ }
1044
+ if (droppedRows) {
1045
+ // Stop at the truncated table — don't resume with later content across
1046
+ // the gap; the "+N rows" chip signals the table continues.
1047
+ remainder = pluralize(droppedRows, 'row');
1048
+ break;
1049
+ }
1050
+ continue;
1051
+ }
1052
+
1053
+ // Prose / list / heading — bound by the remaining char budget, cutting a
1054
+ // long line at a word boundary rather than rendering a giant paragraph.
1055
+ const remaining = MAX_CHARS - chars;
1056
+ if (trimmed && line.length > remaining) {
1057
+ out.push(truncateAtWord(line, remaining));
1058
+ // Mid-line word cut — line counts would be misleading; count only the
1059
+ // additional full lines that follow, else just signal "more".
1060
+ const rest = countRest(i + 1);
1061
+ remainder = rest ? pluralize(rest, 'line') : 'more';
1062
+ break;
1063
+ }
1064
+ out.push(line);
1065
+ chars += line.length;
1066
+ if (trimmed) content++;
1067
+ }
1068
+
1069
+ return { md: out.join('\n').trim(), remainder };
1070
+ }
1071
+
948
1072
  function renderMsgPinBtn(m, i) {
949
1073
  const pinned = isPinned(m);
950
1074
  return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
@@ -1019,10 +1143,17 @@ function toolGroupKey(m) {
1019
1143
 
1020
1144
  function renderToolItem(m, i, compact) {
1021
1145
  const toolDetail = getToolDetail(m.tool, m.params, m.detail);
1146
+ // m.agentId is resolved server-side even while the agent is still running (correlated
1147
+ // from the live agent-activity files), so the ⇗ link, agent-log button, and modal
1148
+ // click all light up DURING the run, identical to post-completion.
1022
1149
  const agentLink =
1023
1150
  m.tool === 'Agent' && m.agentId
1024
1151
  ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
1025
1152
  : '';
1153
+ // Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
1154
+ // background agent shows, sourced here from the foreground toolUseResult.
1155
+ const agentUsage =
1156
+ m.tool === 'Agent' && m.agentUsage ? `<span class="msg-agent-usage">${escapeHtml(m.agentUsage)}</span>` : '';
1026
1157
  const agentLogBtn = resolveAgentLogBtn(m);
1027
1158
  const recipientColor = m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
1028
1159
  const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
@@ -1035,7 +1166,7 @@ function renderToolItem(m, i, compact) {
1035
1166
  const pinBtn = renderMsgPinBtn(m, i);
1036
1167
  return `<div class="msg-item msg-tool${compactClass}" data-msg-idx="${i}" ${itemClickAttr}>
1037
1168
  ${getToolIcon(m.tool)}
1038
- <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
1169
+ <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}${agentUsage}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
1039
1170
  </div>`;
1040
1171
  }
1041
1172
 
@@ -1075,6 +1206,53 @@ function renderMessageList(messages) {
1075
1206
  continue;
1076
1207
  }
1077
1208
 
1209
+ // Background task-notifications: the harness writes each one twice (an enqueue
1210
+ // record + the delivered type:'user' record, same taskId). Collapse a run of
1211
+ // same-taskId notifications into one ×N group (like tool groups), prefixed with
1212
+ // the agent type joined from the loaded agents list.
1213
+ if (m.taskNotification) {
1214
+ let runEnd = i + 1;
1215
+ while (runEnd < messages.length && messages[runEnd].taskNotification && messages[runEnd].taskId === m.taskId)
1216
+ runEnd++;
1217
+ const count = runEnd - i;
1218
+ const agentType = m.taskId ? currentAgents.find((a) => a.agentId === m.taskId)?.type : null;
1219
+ const typePrefix = agentType ? `${escapeHtml(agentType)} · ` : '';
1220
+ const labelHtml = `${typePrefix}<code>${escapeHtml(m.systemLabel || 'Background task')}</code>`;
1221
+ // Shared header row for both the single notification and the ×N group header.
1222
+ const headerRow = (extraClass, onclick, badge, queuedTag) =>
1223
+ `<div class="msg-item msg-system${extraClass}" ${onclick} style="cursor:pointer">
1224
+ ${ICON_AGENT}
1225
+ <div class="msg-body"><div class="msg-text">${labelHtml}${badge}</div><div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${renderMsgPinBtn(m, i)}
1226
+ </div>`;
1227
+
1228
+ if (count >= 2) {
1229
+ const gid = `task-group-${i}`;
1230
+ const items = Array.from({ length: count }, (_, j) => {
1231
+ const r = messages[i + j];
1232
+ const idx = i + j;
1233
+ return `<div class="msg-item msg-system msg-tool-grouped" data-msg-idx="${idx}" onclick="msgDetailFollowLatest=false;showMsgDetail(${idx})" style="cursor:pointer">
1234
+ ${ICON_AGENT}
1235
+ <div class="msg-body"><div class="msg-text">${r.queued ? 'queued' : 'delivered'}</div><div class="msg-time">${formatDate(r.timestamp)}</div></div>
1236
+ </div>`;
1237
+ }).join('');
1238
+ parts.push(`<div class="msg-tool-group">
1239
+ ${headerRow(' msg-tool-group-header', `onclick="toggleToolGroup('${gid}')"`, `<span class="tool-count-badge">×${count}</span>`, '')}
1240
+ <div class="msg-tool-group-items" id="${gid}">${items}</div>
1241
+ </div>`);
1242
+ i = runEnd;
1243
+ continue;
1244
+ }
1245
+
1246
+ // Not collapsed (the enqueue/delivered pair wasn't consecutive): tag the queued
1247
+ // record with the same golden marker used for other queued messages.
1248
+ const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
1249
+ parts.push(
1250
+ headerRow('', `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})"`, '', queuedTag),
1251
+ );
1252
+ i++;
1253
+ continue;
1254
+ }
1255
+
1078
1256
  const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1079
1257
  const pinBtn = renderMsgPinBtn(m, i);
1080
1258
  if (m.type === 'user') {
@@ -1108,9 +1286,16 @@ function renderMessageList(messages) {
1108
1286
  </div>`);
1109
1287
  }
1110
1288
  } else if (m.type === 'assistant') {
1289
+ const preview = buildAssistantPreview(m.fullText || m.text);
1290
+ const moreChip = preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : '';
1291
+ const bodyHtml = preview.md
1292
+ ? `<div class="msg-text msg-text-md">
1293
+ <div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${moreChip}
1294
+ </div>`
1295
+ : `<div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div>`;
1111
1296
  parts.push(`<div class="msg-item msg-assistant" ${clickable}>
1112
1297
  ${MSG_ICON_ASSISTANT}
1113
- <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
1298
+ <div class="msg-body">${bodyHtml}<div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
1114
1299
  </div>`);
1115
1300
  } else if (m.type === 'teammate') {
1116
1301
  if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
@@ -1309,6 +1494,8 @@ const ICON_CHECKMARK =
1309
1494
  '<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>';
1310
1495
  const ICON_AGENT_WAITING =
1311
1496
  '<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>';
1497
+ const ICON_AGENT_ACTIVE =
1498
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
1312
1499
  const ICON_CHAT =
1313
1500
  '<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>';
1314
1501
  const TOOL_ICONS = {
@@ -2519,10 +2706,18 @@ function showAgentModal(agentId) {
2519
2706
  return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
2520
2707
  };
2521
2708
 
2709
+ // Agent cost stats live on the launch row's message (resolved from the completion
2710
+ // toolUseResult), not on the agent-activity record — so read them from there.
2711
+ const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2712
+ const usageStats = agentMsg?.agentUsageStats || null;
2713
+ const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
2714
+
2522
2715
  const chips = [];
2523
2716
  if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
2524
2717
  chips.push(chip('', escapeHtml(agent.status), { cls: `agent-chip-status agent-chip-${agent.status}` }));
2525
2718
  chips.push(chip('⏱', formatDuration(elapsed)));
2719
+ if (usageStats?.tokens != null) chips.push(chip('tokens', fmtTok(usageStats.tokens), { cls: 'agent-chip-mono' }));
2720
+ if (usageStats?.tools != null) chips.push(chip('tools', String(usageStats.tools), { cls: 'agent-chip-mono' }));
2526
2721
  if (shortModel) chips.push(chip('model', escapeHtml(shortModel), { cls: 'agent-chip-mono' }));
2527
2722
  if (agent.agentName) {
2528
2723
  const c = getOwnerColor(agent.agentName);
@@ -2535,8 +2730,6 @@ function showAgentModal(agentId) {
2535
2730
  if (started) chips.push(chip('started', started.toLocaleTimeString()));
2536
2731
  if (stopped) chips.push(chip('stopped', stopped.toLocaleTimeString()));
2537
2732
 
2538
- const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2539
-
2540
2733
  let html = `<div class="agent-chips">${chips.join('')}</div>`;
2541
2734
 
2542
2735
  const promptText = stripTeammateWrapper(agentMsg?.agentPrompt || agent.prompt || null);
@@ -2818,6 +3011,7 @@ function renderSessions() {
2818
3011
  ${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>` : ''}
2819
3012
  ${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>` : ''}
2820
3013
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3014
+ ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
2821
3015
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
2822
3016
  </span>
2823
3017
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
@@ -5162,6 +5356,11 @@ function setupEventSource() {
5162
5356
  refreshProjectAgents();
5163
5357
  } else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
5164
5358
  fetchAgents(currentSessionId);
5359
+ // An agent starting/stopping adds its Agent tool_use + completion rows to
5360
+ // the transcript. Refresh the message log on the same signal so they appear
5361
+ // at start, not only when an unrelated metadata/context refresh coincides
5362
+ // with completion.
5363
+ if (!agentLogMode) fetchMessages(currentSessionId);
5165
5364
  }
5166
5365
  pendingAgentSessionIds.clear();
5167
5366
  }, 500);
package/public/style.css CHANGED
@@ -2101,7 +2101,63 @@ body::before {
2101
2101
  overflow: hidden;
2102
2102
  }
2103
2103
  .msg-assistant .msg-text {
2104
- color: var(--accent-text);
2104
+ color: var(--text-primary);
2105
+ }
2106
+ .msg-text-md {
2107
+ display: block; /* override .msg-text -webkit-box clamp */
2108
+ -webkit-line-clamp: none;
2109
+ white-space: normal; /* override .msg-text pre-wrap */
2110
+ font-size: 12px;
2111
+ }
2112
+ /* Element spacing/borders come from .rendered-md (also on .msg-md-content);
2113
+ these are only the compact-feed deltas: smaller type + code/quote chrome. */
2114
+ .msg-text-md table,
2115
+ .msg-text-md pre,
2116
+ .msg-text-md code {
2117
+ font-size: 11px;
2118
+ }
2119
+ .msg-text-md h1,
2120
+ .msg-text-md h2,
2121
+ .msg-text-md h3,
2122
+ .msg-text-md h4,
2123
+ .msg-text-md h5,
2124
+ .msg-text-md h6 {
2125
+ font-size: 12px;
2126
+ font-weight: 600;
2127
+ }
2128
+ .msg-text-md pre {
2129
+ padding: 6px 8px;
2130
+ background: var(--bg-hover);
2131
+ border-radius: 4px;
2132
+ overflow-x: auto;
2133
+ }
2134
+ .msg-text-md blockquote {
2135
+ padding-left: 8px;
2136
+ border-left: 2px solid var(--border);
2137
+ color: var(--text-secondary);
2138
+ }
2139
+ .msg-md-content > :first-child {
2140
+ margin-top: 0;
2141
+ }
2142
+ .msg-md-content > :last-child {
2143
+ margin-bottom: 0;
2144
+ }
2145
+ /* Soften just the bottom edge of a truncated preview so it reads as "continues"
2146
+ without obscuring the last line of text. */
2147
+ .msg-md-content.is-truncated {
2148
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 0.85em), transparent);
2149
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 0.85em), transparent);
2150
+ }
2151
+ .msg-md-more {
2152
+ display: block;
2153
+ width: fit-content;
2154
+ margin: 3px auto 0 0;
2155
+ padding: 1px 8px;
2156
+ font-size: 10px;
2157
+ font-weight: 500;
2158
+ color: var(--text-secondary);
2159
+ background: var(--bg-hover);
2160
+ border-radius: 9px;
2105
2161
  }
2106
2162
  .msg-tool .msg-text {
2107
2163
  color: var(--text-secondary);
@@ -2147,6 +2203,12 @@ body::before {
2147
2203
  .msg-agent-link:hover {
2148
2204
  opacity: 1;
2149
2205
  }
2206
+ .msg-agent-usage {
2207
+ color: var(--text-muted);
2208
+ font-size: 10px;
2209
+ opacity: 0.55;
2210
+ margin-left: 6px;
2211
+ }
2150
2212
  .msg-pin-btn {
2151
2213
  flex-shrink: 0;
2152
2214
  background: none;
@@ -2912,6 +2974,10 @@ body::before {
2912
2974
  color: var(--warning);
2913
2975
  animation: agent-badge-pulse 1.6s ease-in-out infinite;
2914
2976
  }
2977
+ .agent-badge-active {
2978
+ color: var(--accent, var(--text-secondary));
2979
+ animation: agent-badge-pulse 1.6s ease-in-out infinite;
2980
+ }
2915
2981
  @keyframes agent-badge-pulse {
2916
2982
  0%,
2917
2983
  100% {
package/server.js CHANGED
@@ -38,7 +38,7 @@ if (require("./cli").runCli(process.argv)) return;
38
38
 
39
39
 
40
40
  const app = express();
41
- const PORT = process.env.PORT || 3456;
41
+ const PORT = process.env.PORT || 3541;
42
42
 
43
43
  // Parse --dir flag for custom Claude directory
44
44
  function getClaudeDir() {
@@ -120,6 +120,16 @@ function readAgentJsonl(filePath) {
120
120
  return merged;
121
121
  }
122
122
 
123
+ // Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
124
+ // (_waiting.json, _name-*). Returns [] if the dir is missing/unreadable.
125
+ function listAgentFiles(agentDir) {
126
+ try {
127
+ return readdirSync(agentDir).filter((f) => f.endsWith('.jsonl') && !f.startsWith('_'));
128
+ } catch (_) {
129
+ return [];
130
+ }
131
+ }
132
+
123
133
  function persistAgent(dir, agent) {
124
134
  const file = path.join(dir, agent.agentId + '.jsonl');
125
135
  fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
@@ -1284,7 +1294,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1284
1294
  const isTeam = !!teamConfig;
1285
1295
  const teamMemberNames = isTeam ? new Set(teamConfig.members.map(m => m.name)) : null;
1286
1296
 
1287
- const files = readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'));
1297
+ const files = listAgentFiles(agentDir);
1288
1298
  const agents = [];
1289
1299
  for (const file of files) {
1290
1300
  try {
@@ -1738,6 +1748,8 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1738
1748
  if (entry) {
1739
1749
  msg.agentId = entry.agentId;
1740
1750
  if (entry.description) msg.agentDescription = entry.description;
1751
+ if (entry.usageText) msg.agentUsage = entry.usageText;
1752
+ if (entry.usage) msg.agentUsageStats = entry.usage;
1741
1753
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
1742
1754
  try {
1743
1755
  const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
@@ -1752,6 +1764,33 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1752
1764
  } catch (_) {}
1753
1765
  }
1754
1766
  }
1767
+ // Mid-run fallback: the progressMap only carries agentId once the agent completes
1768
+ // (this build writes no agent_progress lines), so a still-running subagent's launch
1769
+ // row has no agentId yet — leaving its ⇗ link / agent modal inert until completion.
1770
+ // Correlate by PROMPT against the live agent-activity files (which know the agentId
1771
+ // from launch): the activity prompt is the launch prompt plus appended harness
1772
+ // boilerplate, so the tool_use prompt is a prefix. Resolving agentId here makes the
1773
+ // row behave identically while running as it does after it finishes.
1774
+ const unresolved = agentMessages.filter(m => !m.agentId && m.agentPrompt);
1775
+ if (unresolved.length && existsSync(agentDir)) {
1776
+ const activity = listAgentFiles(agentDir)
1777
+ .map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
1778
+ .filter((a) => a && a.agentId && a.prompt);
1779
+ const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
1780
+ for (const msg of unresolved) {
1781
+ const key = msg.agentPrompt.slice(0, 200);
1782
+ const match = activity.find(a =>
1783
+ !usedIds.has(a.agentId) &&
1784
+ (!msg.agentType || a.type === msg.agentType) &&
1785
+ a.prompt.startsWith(key)
1786
+ );
1787
+ if (match) {
1788
+ msg.agentId = match.agentId;
1789
+ usedIds.add(match.agentId);
1790
+ if (match.lastMessage) msg.agentLastMessage = match.lastMessage;
1791
+ }
1792
+ }
1793
+ }
1755
1794
  }
1756
1795
  const cachedCompact = compactSummaryCache.get(jsonlPath);
1757
1796
  let compactSummaries;