claude-code-kanban 4.22.0 → 4.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/file-url.js CHANGED
@@ -24,6 +24,8 @@ function fileUrlToPath(value) {
24
24
  if (typeof value !== 'string' || !/^file:/i.test(value)) return value;
25
25
  // Backslashes are not legal in a URL, so this cannot corrupt a well-formed one.
26
26
  let url = value.replace(/\\/g, '/');
27
+ // A scheme with no path body: POSIX Node resolves it to "/" rather than throwing.
28
+ if (/^file:\/*$/i.test(url)) return value;
27
29
  const drive = DRIVE_URL.exec(url);
28
30
  if (drive) url = `file:///${drive[1]}`;
29
31
  try {
package/lib/parsers.js CHANGED
@@ -160,6 +160,17 @@ function formatTaskUsage(usage) {
160
160
  return parts.length ? ' · ' + parts.join(' · ') : '';
161
161
  }
162
162
 
163
+ // Raw per-agent cost for the agent modal's chips plus the formatted one-liner for
164
+ // the inline message row. Null when the source carried no numbers.
165
+ function agentUsageEntry(subagentTokens, toolUses, durationMs) {
166
+ const usage = {};
167
+ if (subagentTokens != null) usage.tokens = subagentTokens;
168
+ if (toolUses != null) usage.tools = toolUses;
169
+ if (durationMs != null) usage.durationMs = durationMs;
170
+ if (!Object.keys(usage).length) return null;
171
+ return { usage, usageText: formatTaskUsage({ subagentTokens, toolUses, durationMs }) };
172
+ }
173
+
163
174
  function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
164
175
  if (sysLabel === '__skip__') return;
165
176
  let safeText = text || '';
@@ -1099,6 +1110,7 @@ function buildSessionDigest(jsonlPath) {
1099
1110
  const taskIdRe = /<task-id>([a-zA-Z0-9_-]+)<\/task-id>/;
1100
1111
  const nameByToolUseId = {};
1101
1112
  const descByToolUseId = {};
1113
+ const usageByToolUseId = {};
1102
1114
  for (const line of content.split('\n')) {
1103
1115
  // Terminated-teammate detection: check first since cheap substring guards
1104
1116
  if (line.includes('teammate-message') &&
@@ -1171,34 +1183,34 @@ function buildSessionDigest(jsonlPath) {
1171
1183
  } else if (line.includes('User rejected tool use') && line.includes('"tool_use_id"')) {
1172
1184
  const m = tmToolIdRe.exec(line);
1173
1185
  if (m) rejectedToolUseIds.add(m[1]);
1174
- } else if (line.includes('<task-notification>') &&
1175
- (line.includes('<status>killed</status>') || line.includes('<status>error</status>'))) {
1176
- const idMatch = taskIdRe.exec(line);
1177
- if (idMatch) killedAgentIds.add(idMatch[1]);
1186
+ } else if (line.includes('<task-notification>')) {
1187
+ // The envelope's tags survive JSON escaping, so read them off the raw line.
1188
+ // This covers every delivery shape (user message, queue-operation,
1189
+ // queued_command attachment) without enumerating them.
1190
+ if (line.includes('<status>killed</status>') || line.includes('<status>error</status>')) {
1191
+ const idMatch = taskIdRe.exec(line);
1192
+ if (idMatch) killedAgentIds.add(idMatch[1]);
1193
+ }
1194
+ if (line.includes('<subagent_tokens>')) {
1195
+ const notif = parseTaskNotification(line);
1196
+ const u = notif?.usage;
1197
+ const entry = u && notif.toolUseId ? agentUsageEntry(u.subagentTokens, u.toolUses, u.durationMs) : null;
1198
+ if (entry) usageByToolUseId[notif.toolUseId] = entry;
1199
+ }
1178
1200
  } else if (line.includes('"toolUseResult"') && line.includes('"agentId"') && line.includes('"tool_result"')) {
1179
1201
  try {
1180
1202
  const obj = JSON.parse(line);
1181
1203
  const tur = obj.toolUseResult;
1182
1204
  if (tur?.agentId) {
1183
- // Foreground agents report their cost on the completion toolUseResult
1184
- // (totalTokens/totalToolUseCount/totalDurationMs) — the same numbers a
1185
- // background agent surfaces via its <task-notification> <usage> block. Keep
1186
- // the raw numbers (for the agent modal's per-stat chips) and a formatted
1187
- // one-liner via the shared formatter (for the inline message row):
1188
- // " · 31.5k tok · 5 tools · 34s".
1189
- const usage = {};
1190
- if (tur.totalTokens != null) usage.tokens = tur.totalTokens;
1191
- if (tur.totalToolUseCount != null) usage.tools = tur.totalToolUseCount;
1192
- if (tur.totalDurationMs != null) usage.durationMs = tur.totalDurationMs;
1193
- const hasUsage = Object.keys(usage).length > 0;
1194
- const usageText = hasUsage
1195
- ? formatTaskUsage({ subagentTokens: usage.tokens, toolUses: usage.tools, durationMs: usage.durationMs })
1196
- : null;
1205
+ // Foreground agents report their cost on the completion toolUseResult;
1206
+ // background agents report the same numbers in their <task-notification>.
1207
+ const { usage, usageText } = agentUsageEntry(tur.totalTokens, tur.totalToolUseCount, tur.totalDurationMs)
1208
+ || { usage: null, usageText: null };
1197
1209
  const blocks = obj.message?.content;
1198
1210
  if (Array.isArray(blocks)) {
1199
1211
  for (const b of blocks) {
1200
1212
  if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
1201
- map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage: hasUsage ? usage : null, usageText };
1213
+ map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage, usageText };
1202
1214
  }
1203
1215
  }
1204
1216
  }
@@ -1207,6 +1219,7 @@ function buildSessionDigest(jsonlPath) {
1207
1219
  }
1208
1220
  }
1209
1221
  for (const [key, entry] of Object.entries(map)) {
1222
+ if (!entry.usage && usageByToolUseId[key]) Object.assign(entry, usageByToolUseId[key]);
1210
1223
  if (nameByToolUseId[key]) entry.name = nameByToolUseId[key];
1211
1224
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
1212
1225
  // Prefer the full prompt from the assistant's tool_use input — Claude Code's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.22.0",
3
+ "version": "4.23.2",
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
@@ -1460,8 +1460,8 @@ function getToolIcon(toolName) {
1460
1460
  }
1461
1461
  const AGENT_LOG_ICON =
1462
1462
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
1463
- function agentLogButton(agentId) {
1464
- return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${escAttrJs(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
1463
+ function agentLogButton(agentId, cls = 'msg-agent-log-btn') {
1464
+ return `<button class="${escapeHtml(cls)}" onclick="event.stopPropagation();viewAgentLog('${escAttrJs(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
1465
1465
  }
1466
1466
 
1467
1467
  function getPinId(m) {
@@ -2763,9 +2763,14 @@ function renderAgentFooter() {
2763
2763
  const agentColor = resolveNamedColor(a.color);
2764
2764
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
2765
2765
  const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
2766
+ const cardModel = shortModelName(a.model);
2767
+ const modelChipHtml = cardModel
2768
+ ? `<span class="agent-card-model" title="${escapeHtml(a.model)}">${escapeHtml(cardModel)}</span>`
2769
+ : '';
2766
2770
  return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
2771
+ ${agentLogButton(a.agentId, 'agent-card-log-btn')}
2767
2772
  <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
2768
- <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
2773
+ <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span>${modelChipHtml}</div>
2769
2774
  ${msgHtml}
2770
2775
  </div>`;
2771
2776
  })
@@ -2878,10 +2883,8 @@ function showAgentModal(agentId) {
2878
2883
  return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
2879
2884
  };
2880
2885
 
2881
- // Agent cost stats live on the launch row's message (resolved from the completion
2882
- // toolUseResult), not on the agent-activity record — so read them from there.
2883
2886
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2884
- const usageStats = agentMsg?.agentUsageStats || null;
2887
+ const usageStats = agent.usage || null;
2885
2888
  const fmtTok = fmtTokens;
2886
2889
 
2887
2890
  const chips = [];
@@ -3137,9 +3140,9 @@ function renderSessions() {
3137
3140
  const total = session.taskCount;
3138
3141
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3139
3142
  const isActive = session.id === currentSessionId && viewMode === 'session';
3140
- const hasInProgress = session.inProgress > 0;
3141
- const isLive =
3142
- hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
3143
+ // hasRecentLog carries the server's registry-idle correction: an open-but-idle
3144
+ // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
3145
+ const isLive = session.hasRecentLog && Date.now() - Date.parse(session.modifiedAt) <= LIVE_INDICATOR_MS;
3143
3146
  const rawName = session.name || session.id;
3144
3147
  const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
3145
3148
  ? rawName.slice(0, 8)
@@ -4612,8 +4615,6 @@ function activateSelectedSession(items) {
4612
4615
 
4613
4616
  function setFocusZone(zone) {
4614
4617
  const sidebar = document.querySelector('.sidebar');
4615
- // Clear all zone visuals
4616
- sidebar.classList.remove('sidebar-focused');
4617
4618
  clearKbSelection();
4618
4619
  clearTaskSelection();
4619
4620
 
@@ -4623,7 +4624,6 @@ function setFocusZone(zone) {
4623
4624
  sidebar.classList.remove('collapsed');
4624
4625
  localStorage.setItem('sidebar-collapsed', false);
4625
4626
  }
4626
- sidebar.classList.add('sidebar-focused');
4627
4627
  const items = getNavigableItems();
4628
4628
  if (items.length > 0) {
4629
4629
  const activeIdx = items.findIndex((el) => el.classList.contains('active'));
@@ -5767,7 +5767,6 @@ document.addEventListener('keydown', (e) => {
5767
5767
  }
5768
5768
  if (e.key === 'Escape') {
5769
5769
  // Plain unfocus — drop the keyboard cursor without jumping into the board
5770
- document.querySelector('.sidebar').classList.remove('sidebar-focused');
5771
5770
  clearKbSelection();
5772
5771
  focusZone = 'board';
5773
5772
  return;
package/public/style.css CHANGED
@@ -562,6 +562,7 @@ body::before {
562
562
  }
563
563
 
564
564
  .session-item {
565
+ position: relative;
565
566
  display: block;
566
567
  width: 100%;
567
568
  padding: 13px 15px;
@@ -574,11 +575,63 @@ body::before {
574
575
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
575
576
  }
576
577
 
578
+ /* `.active` is the open session, `.kb-selected` the keyboard cursor. The cursor
579
+ must win when both land on one card, which is why it is ordered second. */
577
580
  .session-item.active {
578
581
  background: var(--bg-deep);
579
582
  border-color: var(--accent);
580
583
  }
581
584
 
585
+ .session-item.kb-selected {
586
+ background: var(--bg-elevated);
587
+ border-color: var(--accent);
588
+ box-shadow: 0 0 0 1px var(--accent-dim);
589
+ }
590
+
591
+ .session-item.stale {
592
+ opacity: 0.85;
593
+ }
594
+
595
+ .session-item.stale:hover,
596
+ .session-item.stale.active,
597
+ .session-item.stale.kb-selected,
598
+ .session-item.stale:has(.session-pin-btn.sticky) {
599
+ opacity: 1;
600
+ }
601
+
602
+ .session-item.stale .session-time,
603
+ .session-item.warm .session-time {
604
+ display: inline-flex;
605
+ align-items: center;
606
+ gap: 6px;
607
+ }
608
+
609
+ .session-item.stale .session-time::before,
610
+ .session-item.warm .session-time::before {
611
+ content: '';
612
+ width: 6px;
613
+ height: 6px;
614
+ border-radius: 50%;
615
+ flex-shrink: 0;
616
+ }
617
+
618
+ .session-item.warm .session-time {
619
+ color: var(--success);
620
+ }
621
+
622
+ .session-item.warm .session-time::before {
623
+ background: var(--success);
624
+ }
625
+
626
+ .session-item.stale .session-time {
627
+ color: var(--text-muted);
628
+ }
629
+
630
+ .session-item.stale .session-time::before {
631
+ background: transparent;
632
+ border: 1px solid var(--text-muted);
633
+ }
634
+
582
635
  .session-name {
583
636
  font-size: 13px;
584
637
  color: var(--text-primary);
@@ -590,12 +643,11 @@ body::before {
590
643
  }
591
644
 
592
645
  .session-indicators .pulse {
593
- width: 8px;
594
- height: 8px;
646
+ width: 6px;
647
+ height: 6px;
595
648
  background: var(--accent);
596
649
  border-radius: 50%;
597
650
  animation: pulse 2s ease-in-out infinite;
598
- box-shadow: 0 0 12px var(--accent-glow);
599
651
  }
600
652
 
601
653
  @keyframes pulse {
@@ -694,6 +746,10 @@ body::before {
694
746
  transition: width 0.5s ease-out;
695
747
  }
696
748
 
749
+ .session-item:not(.active) .progress-fill {
750
+ background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
751
+ }
752
+
697
753
  .progress-text {
698
754
  font-size: 11px;
699
755
  font-weight: 500;
@@ -3597,6 +3653,25 @@ body::before {
3597
3653
  .agent-card:hover {
3598
3654
  border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
3599
3655
  }
3656
+ .agent-card-log-btn {
3657
+ position: absolute;
3658
+ top: 4px;
3659
+ right: 4px;
3660
+ display: flex;
3661
+ padding: 2px;
3662
+ background: none;
3663
+ border: none;
3664
+ color: var(--text-muted);
3665
+ cursor: pointer;
3666
+ opacity: 0.45;
3667
+ transition:
3668
+ opacity 0.15s,
3669
+ color 0.15s;
3670
+ }
3671
+ .agent-card-log-btn:hover {
3672
+ opacity: 1;
3673
+ color: var(--accent);
3674
+ }
3600
3675
  .agent-card.selected,
3601
3676
  .agent-card.selected:hover {
3602
3677
  background: var(--bg-elevated);
@@ -3662,6 +3737,20 @@ body::before {
3662
3737
  font-size: 11px;
3663
3738
  color: var(--text-tertiary);
3664
3739
  }
3740
+ .agent-card-model {
3741
+ margin-left: auto;
3742
+ padding: 1px 6px;
3743
+ font-family: var(--font-mono, monospace);
3744
+ font-size: 9.5px;
3745
+ line-height: 1.4;
3746
+ color: var(--text-tertiary);
3747
+ border: 1px solid var(--border);
3748
+ border-radius: 999px;
3749
+ white-space: nowrap;
3750
+ overflow: hidden;
3751
+ text-overflow: ellipsis;
3752
+ max-width: 45%;
3753
+ }
3665
3754
  .agent-message {
3666
3755
  font-size: 11px;
3667
3756
  color: var(--text-muted);
@@ -5415,79 +5504,6 @@ pre.mermaid svg {
5415
5504
  color: var(--accent);
5416
5505
  }
5417
5506
 
5418
- /* Session list hover accent bar */
5419
- .session-item {
5420
- position: relative;
5421
- }
5422
-
5423
- .session-item::before {
5424
- content: '';
5425
- position: absolute;
5426
- left: 0;
5427
- top: 50%;
5428
- transform: translateY(-50%);
5429
- width: 0;
5430
- height: 60%;
5431
- background: var(--accent);
5432
- border-radius: 0 2px 2px 0;
5433
- transition: width 0.15s ease;
5434
- }
5435
-
5436
- .session-item:hover::before {
5437
- width: 0;
5438
- }
5439
-
5440
- .sidebar-focused .session-item.active::before {
5441
- width: 0;
5442
- }
5443
-
5444
- .session-item.kb-selected,
5445
- .session-item.kb-selected:hover,
5446
- .session-item.active.kb-selected {
5447
- background: var(--bg-elevated);
5448
- border-color: var(--accent);
5449
- box-shadow: 0 0 0 1px var(--accent-dim);
5450
- }
5451
-
5452
- .session-item.kb-selected::before {
5453
- width: 0;
5454
- }
5455
- .session-item.stale .session-time,
5456
- .session-item.warm .session-time {
5457
- display: inline-flex;
5458
- align-items: center;
5459
- gap: 6px;
5460
- }
5461
- .session-item.stale .session-time::before,
5462
- .session-item.warm .session-time::before {
5463
- content: '';
5464
- width: 6px;
5465
- height: 6px;
5466
- border-radius: 50%;
5467
- flex-shrink: 0;
5468
- }
5469
- .session-item.warm .session-time {
5470
- color: var(--success);
5471
- }
5472
- .session-item.warm .session-time::before {
5473
- background: var(--success);
5474
- }
5475
- .session-item.stale {
5476
- opacity: 0.85;
5477
- }
5478
- .session-item.stale:hover,
5479
- .session-item.stale.active,
5480
- .session-item.stale.kb-selected,
5481
- .session-item.stale:has(.session-pin-btn.sticky) {
5482
- opacity: 1;
5483
- }
5484
- .session-item.stale .session-time {
5485
- color: var(--text-muted);
5486
- }
5487
- .session-item.stale .session-time::before {
5488
- background: transparent;
5489
- border: 1px solid var(--text-muted);
5490
- }
5491
5507
  /* #endregion */
5492
5508
 
5493
5509
  /* #region SESSION_GROUPS */
package/server.js CHANGED
@@ -2007,16 +2007,16 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2007
2007
  // (same pattern as agentsNeedingModel below). Each field shares the same resolve flow:
2008
2008
  // look up in progressMap by agentId, fall back to per-field extractor, persist only
2009
2009
  // on actual change.
2010
+ // progressMap is keyed by tool_use_id; re-key by agentId, first value per field wins.
2010
2011
  const byAgentId = {};
2011
- const nameByAgentId = {};
2012
- const descByAgentId = {};
2013
2012
  if (meta.jsonlPath) {
2014
2013
  try {
2015
2014
  const progressMap = getProgressMap(meta.jsonlPath);
2016
2015
  for (const entry of Object.values(progressMap)) {
2017
- if (entry.prompt && !byAgentId[entry.agentId]) byAgentId[entry.agentId] = entry.prompt;
2018
- if (entry.name && !nameByAgentId[entry.agentId]) nameByAgentId[entry.agentId] = entry.name;
2019
- if (entry.description && !descByAgentId[entry.agentId]) descByAgentId[entry.agentId] = entry.description;
2016
+ const e = (byAgentId[entry.agentId] ||= {});
2017
+ for (const f of ['prompt', 'name', 'description', 'usage']) {
2018
+ if (entry[f] && !e[f]) e[f] = entry[f];
2019
+ }
2020
2020
  }
2021
2021
  } catch (_) {}
2022
2022
  }
@@ -2025,12 +2025,12 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2025
2025
  field: 'prompt',
2026
2026
  flag: 'promptUnavailable',
2027
2027
  lookup: (a) => {
2028
- if (byAgentId[a.agentId]) return byAgentId[a.agentId];
2028
+ if (byAgentId[a.agentId]?.prompt) return byAgentId[a.agentId].prompt;
2029
2029
  try { return extractPromptFromTranscript(subagentJsonlForExtraction(meta, a.agentId)); } catch (_) { return null; }
2030
2030
  },
2031
2031
  },
2032
- { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
2033
- { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => descByAgentId[a.agentId] || null },
2032
+ { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => byAgentId[a.agentId]?.name || null },
2033
+ { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => byAgentId[a.agentId]?.description || null },
2034
2034
  ];
2035
2035
  if (meta.jsonlPath) {
2036
2036
  for (const { field, flag, lookup } of reconcileFields) {
@@ -2133,6 +2133,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2133
2133
  }
2134
2134
 
2135
2135
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
2136
+ // Attached after persistAgent so it stays response-only.
2137
+ for (const agent of visibleAgents) {
2138
+ const usage = byAgentId[agent.agentId]?.usage;
2139
+ if (usage) agent.usage = usage;
2140
+ }
2136
2141
  res.json({ agents: visibleAgents, waitingForUser, teamColors });
2137
2142
  } catch {
2138
2143
  res.json({ agents: [], waitingForUser: null });
@@ -2499,7 +2504,6 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2499
2504
  msg.agentId = entry.agentId;
2500
2505
  if (entry.description) msg.agentDescription = entry.description;
2501
2506
  if (entry.usageText) msg.agentUsage = entry.usageText;
2502
- if (entry.usage) msg.agentUsageStats = entry.usage;
2503
2507
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
2504
2508
  try {
2505
2509
  const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);