claude-code-kanban 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -227,7 +227,7 @@ const SESSION_INFO_CACHE_MAX = 2000;
227
227
  // live branch must resolve it from cwd separately. Cache is reset on inode
228
228
  // change or truncation (size < scannedUpTo).
229
229
  function readSessionInfoFromJsonl(jsonlPath) {
230
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
230
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null };
231
231
  let stat;
232
232
  let fd;
233
233
  try {
@@ -247,6 +247,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
247
247
  projectPath: cached.projectPath,
248
248
  cwd: cached.cwd,
249
249
  gitBranch: cached.gitBranch,
250
+ logicalParentUuid: cached.logicalParentUuid || null,
250
251
  customTitle: readCustomTitle(jsonlPath, stat)
251
252
  };
252
253
  }
@@ -256,6 +257,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
256
257
  result.projectPath = cached.projectPath;
257
258
  result.cwd = cached.cwd;
258
259
  result.gitBranch = cached.gitBranch;
260
+ result.logicalParentUuid = cached.logicalParentUuid || null;
259
261
  }
260
262
 
261
263
  let lastCwdSeen = result.cwd;
@@ -268,6 +270,9 @@ function readSessionInfoFromJsonl(jsonlPath) {
268
270
  lastCwdSeen = data.cwd;
269
271
  }
270
272
  if (data.gitBranch) result.gitBranch = data.gitBranch;
273
+ if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
274
+ result.logicalParentUuid = data.logicalParentUuid;
275
+ }
271
276
  } catch (e) {}
272
277
  };
273
278
 
@@ -361,7 +366,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
361
366
  slug: result.slug,
362
367
  projectPath: result.projectPath,
363
368
  gitBranch: result.gitBranch,
364
- cwd: result.cwd
369
+ cwd: result.cwd,
370
+ logicalParentUuid: result.logicalParentUuid
365
371
  });
366
372
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
367
373
  const firstKey = sessionInfoCache.keys().next().value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.3.0",
3
+ "version": "4.4.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
@@ -494,10 +494,21 @@ function renderActivityChip() {
494
494
  .join('');
495
495
  }
496
496
 
497
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
498
- function setActivityFilter(kind) {
497
+ function toggleActivityKind(kind) {
499
498
  if (activityFilter.has(kind)) activityFilter.delete(kind);
500
499
  else activityFilter.add(kind);
500
+ }
501
+
502
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
503
+ function setActivityFilter(kind) {
504
+ if (kind === 'active') {
505
+ // waiting is a sub-state of active — couple them so one click covers all running sessions
506
+ toggleActivityKind('active');
507
+ toggleActivityKind('waiting');
508
+ } else {
509
+ toggleActivityKind(kind);
510
+ }
511
+ localStorage.setItem('activityFilter', JSON.stringify([...activityFilter]));
501
512
  // active/waiting only make sense with the active session filter on
502
513
  const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
503
514
  if (targetFilter !== sessionFilter) {
@@ -2607,7 +2618,7 @@ function renderSessions() {
2607
2618
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
2608
2619
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2609
2620
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2610
- ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><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>` : ''}
2621
+ ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><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>` : ''}
2611
2622
  ${renderLoopBadge(session)}
2612
2623
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2613
2624
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
@@ -4256,13 +4267,14 @@ function matchKey(e, ...keys) {
4256
4267
  return keys.some((k) => e.key === k || e.code === k);
4257
4268
  }
4258
4269
 
4259
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
4270
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'tool-stats-modal', 'plan-modal', 'loop-modal'];
4260
4271
  const MODAL_CLOSERS = {
4261
4272
  'preview-modal': () => closePreviewModal(),
4262
4273
  'msg-detail-modal': () => {
4263
4274
  closeMsgDetailModal();
4264
4275
  msgDetailFollowLatest = false;
4265
4276
  },
4277
+ 'tool-stats-modal': () => closeToolStatsModal(),
4266
4278
  'plan-modal': () => closePlanModal(),
4267
4279
  'loop-modal': () => closeLoopModal(),
4268
4280
  'team-modal': () => closeTeamModal(),
@@ -5885,6 +5897,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5885
5897
  const keyHandler = (e) => {
5886
5898
  if (e.key === 'Escape') {
5887
5899
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
5900
+ if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
5888
5901
  e.preventDefault();
5889
5902
  closeTeamModal();
5890
5903
  document.removeEventListener('keydown', keyHandler);
@@ -6140,6 +6153,105 @@ function openMemoryForInfoModal() {
6140
6153
 
6141
6154
  //#endregion
6142
6155
 
6156
+ //#region TOOL_STATS_MODAL
6157
+ let _toolStatsSortCol = 'count';
6158
+ let _toolStatsSortDir = 'desc';
6159
+ let _toolStatsData = null;
6160
+
6161
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6162
+ function showToolStatsModal(sessionId) {
6163
+ if (!sessionId) return;
6164
+ const body = document.getElementById('tool-stats-modal-body');
6165
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6166
+ document.getElementById('tool-stats-modal').classList.add('visible');
6167
+
6168
+ fetch(`/api/sessions/${sessionId}/tool-stats`)
6169
+ .then((r) => (r.ok ? r.json() : null))
6170
+ .catch(() => null)
6171
+ .then((data) => {
6172
+ if (!data) {
6173
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load tool statistics.</div>';
6174
+ return;
6175
+ }
6176
+ _toolStatsSortCol = 'count';
6177
+ _toolStatsSortDir = 'desc';
6178
+ _toolStatsData = data;
6179
+ body.innerHTML = renderToolStatsBody(data);
6180
+ });
6181
+ }
6182
+
6183
+ function renderToolStatsBody(data) {
6184
+ const { totalCalls, uniqueTools, totalFailed, tools } = data;
6185
+
6186
+ const summary = `
6187
+ <div class="tool-stats-summary">
6188
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val">${totalCalls}</span><span class="tool-stats-chip-lbl">Total calls</span></div>
6189
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val">${uniqueTools}</span><span class="tool-stats-chip-lbl">Unique tools</span></div>
6190
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val${totalFailed > 0 ? ' failed' : ''}">${totalFailed}</span><span class="tool-stats-chip-lbl">Failed</span></div>
6191
+ </div>`;
6192
+
6193
+ if (!tools?.length) {
6194
+ return (
6195
+ summary +
6196
+ '<div style="padding:24px;text-align:center;color:var(--text-tertiary);">No tool calls recorded in this session.</div>'
6197
+ );
6198
+ }
6199
+
6200
+ const sorted = [...tools].sort((a, b) => {
6201
+ if (_toolStatsSortCol === 'name')
6202
+ return _toolStatsSortDir === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
6203
+ return _toolStatsSortDir === 'asc'
6204
+ ? a[_toolStatsSortCol] - b[_toolStatsSortCol]
6205
+ : b[_toolStatsSortCol] - a[_toolStatsSortCol];
6206
+ });
6207
+ const arrow = (col) => (col === _toolStatsSortCol ? (_toolStatsSortDir === 'asc' ? ' ▲' : ' ▼') : '');
6208
+
6209
+ const table = `<table class="tool-stats-table">
6210
+ <thead><tr>
6211
+ <th onclick="toolStatsSortBy('name')">Tool${arrow('name')}</th>
6212
+ <th onclick="toolStatsSortBy('count')">Calls${arrow('count')}</th>
6213
+ <th onclick="toolStatsSortBy('success')">✓ Success${arrow('success')}</th>
6214
+ <th onclick="toolStatsSortBy('failed')">✗ Failed${arrow('failed')}</th>
6215
+ <th onclick="toolStatsSortBy('impact')" title="Share of total tool output by character count">Impact${arrow('impact')}</th>
6216
+ </tr></thead>
6217
+ <tbody>${sorted
6218
+ .map(
6219
+ (t) => `<tr>
6220
+ <td class="tool-name">${escapeHtml(t.name)}</td>
6221
+ <td>${t.count}</td>
6222
+ <td>${t.success > 0 ? `<span class="badge-success">${t.success}</span>` : '—'}</td>
6223
+ <td>${t.failed > 0 ? `<span class="badge-failed">${t.failed}</span>` : '—'}</td>
6224
+ <td class="impact-cell">${
6225
+ t.impact != null
6226
+ ? `<div class="impact-cell-inner"><div class="impact-bar-wrap"><div class="impact-bar-fill" style="width:${t.impact}%"></div></div><span class="impact-pct">${t.impact < 1 ? '<1' : t.impact}%</span></div>`
6227
+ : '—'
6228
+ }</td>
6229
+ </tr>`,
6230
+ )
6231
+ .join('')}</tbody>
6232
+ </table>`;
6233
+
6234
+ return summary + table;
6235
+ }
6236
+
6237
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6238
+ function toolStatsSortBy(col) {
6239
+ if (_toolStatsSortCol === col) {
6240
+ _toolStatsSortDir = _toolStatsSortDir === 'asc' ? 'desc' : 'asc';
6241
+ } else {
6242
+ _toolStatsSortCol = col;
6243
+ _toolStatsSortDir = col === 'name' ? 'asc' : 'desc';
6244
+ }
6245
+ if (!_toolStatsData) return;
6246
+ const body = document.getElementById('tool-stats-modal-body');
6247
+ body.innerHTML = renderToolStatsBody(_toolStatsData);
6248
+ }
6249
+
6250
+ function closeToolStatsModal() {
6251
+ document.getElementById('tool-stats-modal').classList.remove('visible');
6252
+ }
6253
+ //#endregion
6254
+
6143
6255
  //#region OWNER_FILTER
6144
6256
  function updateOwnerFilter() {
6145
6257
  const bar = document.getElementById('owner-filter-bar');
@@ -6239,6 +6351,11 @@ try {
6239
6351
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
6240
6352
  cg.forEach((p) => collapsedProjectGroups.add(p));
6241
6353
  } catch (_) {}
6354
+ try {
6355
+ const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
6356
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
6357
+ af.forEach((k) => activityFilter.add(k));
6358
+ } catch (_) {}
6242
6359
  initSidebarResize();
6243
6360
  loadPanelWidths();
6244
6361
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
package/public/index.html CHANGED
@@ -543,11 +543,30 @@
543
543
  <div id="team-modal-body" class="modal-body"></div>
544
544
  <div class="modal-footer">
545
545
  <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Dismiss</button>
546
+ <button class="btn btn-secondary" onclick="showToolStatsModal(_infoModalSessionId)" title="Tool invocation statistics">Tool Stats</button>
546
547
  <button class="btn btn-primary" onclick="closeTeamModal()">Close</button>
547
548
  </div>
548
549
  </div>
549
550
  </div>
550
551
 
552
+ <!-- Tool Stats Modal (stacked on top of info modal) -->
553
+ <div id="tool-stats-modal" class="modal-overlay plan-modal-overlay" onclick="closeToolStatsModal()">
554
+ <div class="modal tool-stats-modal" onclick="event.stopPropagation()">
555
+ <div class="modal-header">
556
+ <h3 id="tool-stats-modal-title" class="modal-title">Tool Statistics</h3>
557
+ <button class="modal-close" aria-label="Close dialog" onclick="closeToolStatsModal()">
558
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
559
+ <path d="M18 6L6 18M6 6l12 12"/>
560
+ </svg>
561
+ </button>
562
+ </div>
563
+ <div id="tool-stats-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
564
+ <div class="modal-footer">
565
+ <button class="btn btn-primary" onclick="closeToolStatsModal()">Close</button>
566
+ </div>
567
+ </div>
568
+ </div>
569
+
551
570
  <!-- Plan Modal (stacked on top of info modal) -->
552
571
  <div id="plan-modal" class="modal-overlay plan-modal-overlay" onclick="closePlanModal()">
553
572
  <div class="modal plan-modal" onclick="event.stopPropagation()">
package/public/style.css CHANGED
@@ -3351,6 +3351,150 @@ select.form-input option:checked {
3351
3351
 
3352
3352
  /* #endregion */
3353
3353
 
3354
+ /* #region TOOL_STATS */
3355
+ .modal.tool-stats-modal {
3356
+ width: 90%;
3357
+ max-width: 860px;
3358
+ max-height: 85vh;
3359
+ display: flex;
3360
+ flex-direction: column;
3361
+ }
3362
+
3363
+ .tool-stats-summary {
3364
+ display: flex;
3365
+ gap: 12px;
3366
+ flex-wrap: wrap;
3367
+ margin-bottom: 20px;
3368
+ }
3369
+
3370
+ .tool-stats-chip {
3371
+ display: flex;
3372
+ flex-direction: column;
3373
+ align-items: center;
3374
+ background: var(--bg-secondary);
3375
+ border: 1px solid var(--border);
3376
+ border-radius: 8px;
3377
+ padding: 10px 16px;
3378
+ min-width: 80px;
3379
+ }
3380
+
3381
+ .tool-stats-chip-val {
3382
+ font-size: 22px;
3383
+ font-weight: 600;
3384
+ color: var(--text-primary);
3385
+ line-height: 1;
3386
+ }
3387
+
3388
+ .tool-stats-chip-val.failed {
3389
+ color: var(--status-failed, #ef4444);
3390
+ }
3391
+
3392
+ .tool-stats-chip-lbl {
3393
+ font-size: 11px;
3394
+ color: var(--text-tertiary);
3395
+ margin-top: 4px;
3396
+ text-transform: uppercase;
3397
+ letter-spacing: 0.04em;
3398
+ }
3399
+
3400
+ .tool-stats-table {
3401
+ width: 100%;
3402
+ border-collapse: collapse;
3403
+ font-size: 13px;
3404
+ margin-bottom: 8px;
3405
+ }
3406
+
3407
+ .tool-stats-table thead th {
3408
+ text-align: right;
3409
+ padding: 6px 10px;
3410
+ font-size: 11px;
3411
+ font-weight: 600;
3412
+ color: var(--text-tertiary);
3413
+ text-transform: uppercase;
3414
+ letter-spacing: 0.05em;
3415
+ border-bottom: 1px solid var(--border);
3416
+ cursor: pointer;
3417
+ user-select: none;
3418
+ white-space: nowrap;
3419
+ }
3420
+
3421
+ .tool-stats-table thead th:first-child {
3422
+ text-align: left;
3423
+ }
3424
+
3425
+ .tool-stats-table thead th:hover {
3426
+ color: var(--text-primary);
3427
+ }
3428
+
3429
+ .tool-stats-table tbody tr:nth-child(even) {
3430
+ background: var(--bg-secondary);
3431
+ }
3432
+
3433
+ .tool-stats-table tbody td {
3434
+ padding: 6px 10px;
3435
+ text-align: right;
3436
+ color: var(--text-primary);
3437
+ border-bottom: 1px solid var(--border-subtle, var(--border));
3438
+ }
3439
+
3440
+ .tool-stats-table tbody td.tool-name {
3441
+ text-align: left;
3442
+ font-family: var(--font-mono, monospace);
3443
+ font-size: 12px;
3444
+ }
3445
+
3446
+ .impact-cell-inner {
3447
+ display: flex;
3448
+ align-items: center;
3449
+ justify-content: flex-end;
3450
+ gap: 6px;
3451
+ }
3452
+
3453
+ .badge-success {
3454
+ display: inline-block;
3455
+ background: rgba(34, 197, 94, 0.15);
3456
+ color: #22c55e;
3457
+ border-radius: 4px;
3458
+ padding: 1px 6px;
3459
+ font-size: 12px;
3460
+ font-weight: 600;
3461
+ }
3462
+
3463
+ .badge-failed {
3464
+ display: inline-block;
3465
+ background: rgba(239, 68, 68, 0.15);
3466
+ color: #ef4444;
3467
+ border-radius: 4px;
3468
+ padding: 1px 6px;
3469
+ font-size: 12px;
3470
+ font-weight: 600;
3471
+ }
3472
+
3473
+ .impact-bar-wrap {
3474
+ width: 48px;
3475
+ height: 6px;
3476
+ border-radius: 3px;
3477
+ background: var(--bg-hover, var(--bg-secondary));
3478
+ flex-shrink: 0;
3479
+ overflow: hidden;
3480
+ }
3481
+
3482
+ .impact-bar-fill {
3483
+ height: 100%;
3484
+ border-radius: 3px;
3485
+ background: var(--accent, #6366f1);
3486
+ min-width: 2px;
3487
+ }
3488
+
3489
+ .impact-pct {
3490
+ font-size: 12px;
3491
+ font-weight: 500;
3492
+ color: var(--text-primary);
3493
+ min-width: 32px;
3494
+ text-align: right;
3495
+ }
3496
+ /* #endregion */
3497
+
3354
3498
  /* #region SCRATCHPAD */
3355
3499
  .scratchpad-modal {
3356
3500
  width: 90%;
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const express = require('express');
4
4
  const path = require('path');
5
5
  const fs = require('fs').promises;
6
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync } = require('fs');
6
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('fs');
7
7
  const readline = require('readline');
8
8
  const chokidar = require('chokidar');
9
9
  const os = require('os');
@@ -456,6 +456,7 @@ function refreshSessionMetadataPath(jsonlPath) {
456
456
  if (info.cwd) existing.cwd = info.cwd;
457
457
  if (info.gitBranch) existing.gitBranch = info.gitBranch;
458
458
  if (info.customTitle) existing.customTitle = info.customTitle;
459
+ if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
459
460
  return true;
460
461
  }
461
462
 
@@ -540,7 +541,8 @@ function loadSessionMetadata() {
540
541
  cwd: sessionInfo.cwd || null,
541
542
  gitBranch: sessionInfo.gitBranch || null,
542
543
  customTitle: sessionInfo.customTitle || null,
543
- jsonlPath: jsonlPath
544
+ jsonlPath: jsonlPath,
545
+ logicalParentUuid: sessionInfo.logicalParentUuid || null
544
546
  };
545
547
  sessionIds.push(sessionId);
546
548
  }
@@ -933,14 +935,28 @@ app.get('/api/sessions', async (req, res) => {
933
935
  if (group.length < 2) continue;
934
936
  group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
935
937
  const planSession = group.find(s => s.hasPlan);
936
- const implSession = group.find(s => s !== planSession && new Date(s.modifiedAt) >= new Date(planSession?.modifiedAt || 0));
937
- if (planSession && implSession) {
938
+ const linkedSession = group.find(s => s !== planSession && !s.hasPlan && new Date(s.modifiedAt) >= new Date(planSession?.modifiedAt || 0));
939
+ if (planSession && linkedSession) {
938
940
  planSession.hasWaitingForUser = false;
939
- planSession.planImplementationSessionId = implSession.id;
940
- implSession.planSourceSessionId = planSession.id;
941
+ planSession.planImplementationSessionId = linkedSession.id;
942
+ linkedSession.planSourceSessionId = planSession.id;
941
943
  }
942
944
  }
943
945
 
946
+ // Suppress parent sessions that have a compact continuation — compaction is involuntary
947
+ // (context limit hit), not an intentional fork. Only the continuation is shown.
948
+ const compactSuppressed = new Set();
949
+ for (const [sid] of sessionsMap) {
950
+ const compactAnchor = metadata[sid]?.logicalParentUuid;
951
+ if (!compactAnchor) continue;
952
+ const parent = lookupParentSession(sid);
953
+ if (parent.parentSessionId && sessionsMap.has(parent.parentSessionId)) {
954
+ compactSuppressed.add(parent.parentSessionId);
955
+ sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
956
+ }
957
+ }
958
+ for (const sid of compactSuppressed) sessionsMap.delete(sid);
959
+
944
960
  // Backfill contextStatus for already-built sessions that are pinned
945
961
  for (const pid of pinnedIds) {
946
962
  const s = sessionsMap.get(pid);
@@ -1316,14 +1332,21 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1316
1332
  }
1317
1333
  }
1318
1334
 
1319
- const agentsNeedingModel = agents.filter(a => !a.model && !a.modelUnavailable);
1335
+ // Retry stopped agents even if modelUnavailable was set — it may have been marked
1336
+ // unavailable while the agent was still active and its JSONL wasn't ready yet.
1337
+ const agentsNeedingModel = agents.filter(a => !a.model && (!a.modelUnavailable || a.status === 'stopped'));
1320
1338
  if (agentsNeedingModel.length && meta.jsonlPath) {
1321
1339
  for (const agent of agentsNeedingModel) {
1322
1340
  let model = null;
1323
1341
  try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1324
- if (model) agent.model = model;
1325
- else agent.modelUnavailable = true;
1326
- dirty.add(agent);
1342
+ if (model) {
1343
+ agent.model = model;
1344
+ delete agent.modelUnavailable;
1345
+ dirty.add(agent);
1346
+ } else if (agent.status === 'stopped' && !agent.modelUnavailable) {
1347
+ agent.modelUnavailable = true;
1348
+ dirty.add(agent);
1349
+ }
1327
1350
  }
1328
1351
  }
1329
1352
 
@@ -1462,41 +1485,61 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1462
1485
  return found || primary;
1463
1486
  }
1464
1487
 
1465
- // Claude Code marks fork lineage in two ways:
1466
- // 1. `logicalParentUuid` on a system record (when present) points to a uuid
1467
- // in the parent session's JSONL.
1468
- // 2. When absent, the fork copies the parent's early records verbatim, so
1469
- // the earliest `uuid` in this session also exists (same uuid+timestamp)
1470
- // in the parent's JSONL.
1471
- // We try (1) first, then fall back to (2).
1488
+ // Claude Code creates child sessions in two ways:
1489
+ // Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
1490
+ // Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
1491
+ // Birthtime (not mtime) identifies the parent mtime changes on resume, birthtime is immutable.
1472
1492
  const parentSessionCache = new Map();
1473
- // Both anchor signals live in the first few records (system marker on top,
1474
- // fork-copy starts at line 0), so cap the scan instead of reading the whole file.
1475
1493
  const FORK_ANCHOR_SCAN_LINES = 10;
1476
1494
  function findForkAnchorUuid(jsonlPath) {
1477
1495
  let text;
1478
1496
  try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
1479
- let firstUuid = null;
1480
- let scanned = 0;
1497
+ let firstUuid = null, scanned = 0;
1481
1498
  for (const l of text.split('\n')) {
1482
1499
  if (!l) continue;
1483
1500
  if (scanned++ >= FORK_ANCHOR_SCAN_LINES) break;
1484
- try {
1485
- const d = JSON.parse(l);
1486
- if (d.logicalParentUuid) return d.logicalParentUuid;
1487
- if (!firstUuid && d.uuid) firstUuid = d.uuid;
1488
- } catch { /* skip malformed */ }
1501
+ try { const d = JSON.parse(l); if (!firstUuid && d.uuid) firstUuid = d.uuid; } catch { /* skip malformed */ }
1489
1502
  }
1490
1503
  return firstUuid;
1491
1504
  }
1492
- function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1505
+ // Fallback when metadata cache lacks logicalParentUuid (older entries, cold cache).
1506
+ // Hot path reads from metadata directly; this never runs from the suppression loop.
1507
+ // Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
1508
+ // always sits in the preamble before the first user/assistant record.
1509
+ const COMPACT_ANCHOR_READ_MAX = 1048576;
1510
+ function findCompactAnchorUuid(jsonlPath) {
1511
+ let fd;
1512
+ try {
1513
+ fd = openSync(jsonlPath, 'r');
1514
+ const buf = Buffer.alloc(COMPACT_ANCHOR_READ_MAX);
1515
+ const n = readSync(fd, buf, 0, COMPACT_ANCHOR_READ_MAX, 0);
1516
+ const text = buf.toString('utf8', 0, n);
1517
+ const lastNl = text.lastIndexOf('\n');
1518
+ const complete = lastNl >= 0 ? text.slice(0, lastNl) : text;
1519
+ for (const l of complete.split('\n')) {
1520
+ if (!l) continue;
1521
+ try {
1522
+ const d = JSON.parse(l);
1523
+ if (d.type === 'user' || d.type === 'assistant') return null;
1524
+ if (d.subtype === 'compact_boundary' && d.logicalParentUuid) return d.logicalParentUuid;
1525
+ } catch { /* skip malformed */ }
1526
+ }
1527
+ return null;
1528
+ } catch { return null; }
1529
+ finally { if (fd !== undefined) { try { closeSync(fd); } catch {} } }
1530
+ }
1531
+ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, maxBirthtimeMs) {
1493
1532
  let files;
1494
1533
  try { files = readdirSync(projectDir); } catch { return null; }
1495
- const candidates = [];
1534
+ let best = null;
1496
1535
  for (const f of files) {
1497
1536
  if (!f.endsWith('.jsonl')) continue;
1498
1537
  const fp = path.join(projectDir, f);
1499
1538
  if (fp === excludeJsonlPath) continue;
1539
+ let birthtime = 0;
1540
+ try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
1541
+ if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
1542
+ if (best && birthtime >= best.birthtime) continue;
1500
1543
  let text;
1501
1544
  try { text = readFileSync(fp, 'utf8'); } catch { continue; }
1502
1545
  if (!text.includes(targetUuid)) continue;
@@ -1505,31 +1548,33 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1505
1548
  try {
1506
1549
  const d = JSON.parse(l);
1507
1550
  if (d.uuid === targetUuid && d.sessionId) {
1508
- let mtime = 0;
1509
- try { mtime = statSync(fp).mtimeMs; } catch { /* ignore */ }
1510
- candidates.push({ parentSessionId: d.sessionId, parentJsonlPath: fp, mtime });
1551
+ best = { parentSessionId: d.sessionId, parentJsonlPath: fp, birthtime };
1511
1552
  break;
1512
1553
  }
1513
1554
  } catch { /* skip */ }
1514
1555
  }
1515
1556
  }
1516
- if (!candidates.length) return null;
1517
- candidates.sort((a, b) => a.mtime - b.mtime);
1518
- const { parentSessionId, parentJsonlPath } = candidates[0];
1519
- return { parentSessionId, parentJsonlPath };
1557
+ if (!best) return null;
1558
+ return { parentSessionId: best.parentSessionId, parentJsonlPath: best.parentJsonlPath };
1520
1559
  }
1521
1560
  function lookupParentSession(sessionId) {
1522
1561
  if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
1523
1562
  const meta = loadSessionMetadata()[sessionId];
1524
- const result = { parentSessionId: null, parentJsonlPath: null };
1563
+ const result = { parentSessionId: null, parentJsonlPath: null, isCompact: false };
1525
1564
  if (meta?.jsonlPath) {
1526
- const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
1565
+ const compactAnchor = meta.logicalParentUuid || findCompactAnchorUuid(meta.jsonlPath);
1566
+ result.isCompact = !!compactAnchor;
1567
+ const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
1527
1568
  if (anchorUuid) {
1528
- const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
1529
- if (hit) Object.assign(result, hit);
1569
+ let selfBirthtime;
1570
+ try { selfBirthtime = statSync(meta.jsonlPath).birthtimeMs; } catch { /* ignore */ }
1571
+ if (selfBirthtime != null) {
1572
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime);
1573
+ if (hit) Object.assign(result, hit);
1574
+ }
1530
1575
  }
1531
1576
  }
1532
- if (result.parentSessionId) parentSessionCache.set(sessionId, result);
1577
+ parentSessionCache.set(sessionId, result);
1533
1578
  return result;
1534
1579
  }
1535
1580
  app.get('/api/sessions/:sessionId/parent', (req, res) => {
@@ -1678,6 +1723,112 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1678
1723
  res.json({ toolUseId: req.params.toolUseId, content });
1679
1724
  });
1680
1725
 
1726
+ const toolStatsCache = new Map();
1727
+
1728
+ function buildToolStats(jsonlPath) {
1729
+ const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
1730
+ const seenResults = new Set();
1731
+ const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
1732
+ const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
1733
+ const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
1734
+
1735
+ const content = readFileSync(jsonlPath, 'utf8');
1736
+ for (const line of content.split('\n')) {
1737
+ if (!line) continue;
1738
+ let obj;
1739
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1740
+
1741
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
1742
+ for (const block of obj.message.content) {
1743
+ if (block.type === 'tool_use' && block.name && block.id) {
1744
+ const isSkill = block.name === 'Skill';
1745
+ const displayName = isSkill && block.input?.skill
1746
+ ? `Skill(${block.input.skill})`
1747
+ : block.name === 'Agent' && block.input?.subagent_type
1748
+ ? `Agent(${block.input.subagent_type})`
1749
+ : block.name;
1750
+ toolUseById[block.id] = { displayName, isSkill };
1751
+ }
1752
+ }
1753
+ } else if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
1754
+ const promptId = obj.promptId;
1755
+ for (const block of obj.message.content) {
1756
+ if (block.type !== 'tool_result' || !block.tool_use_id) continue;
1757
+ const entry = toolUseById[block.tool_use_id];
1758
+ if (!entry) continue;
1759
+ const { displayName, isSkill } = entry;
1760
+ seenResults.add(block.tool_use_id);
1761
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
1762
+ toolMap[displayName].count++;
1763
+ const raw = typeof block.content === 'string' ? block.content
1764
+ : Array.isArray(block.content) ? block.content.map(b => b.text || '').join('\n') : '';
1765
+ const bytes = raw.length;
1766
+ toolMap[displayName].outputBytes += bytes;
1767
+ if (promptId) {
1768
+ promptOutputBytes[promptId] = (promptOutputBytes[promptId] || 0) + bytes;
1769
+ if (isSkill) {
1770
+ if (!skillPromptIds[promptId]) skillPromptIds[promptId] = [];
1771
+ skillPromptIds[promptId].push(displayName);
1772
+ }
1773
+ }
1774
+ const lower = raw.toLowerCase();
1775
+ const failed = /^error/i.test(raw.trimStart())
1776
+ || /exit code [1-9]/.test(lower)
1777
+ || lower.includes('command failed')
1778
+ || (lower.includes('failed') && lower.includes('error'));
1779
+ if (failed) toolMap[displayName].failed++;
1780
+ else toolMap[displayName].success++;
1781
+ }
1782
+ }
1783
+ }
1784
+
1785
+ // Count tool_use blocks that never got a tool_result
1786
+ for (const [id, { displayName }] of Object.entries(toolUseById)) {
1787
+ if (seenResults.has(id)) continue;
1788
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
1789
+ toolMap[displayName].count++;
1790
+ }
1791
+
1792
+ // Approximate Skill impact: replace tiny dispatch bytes with the full turn's output
1793
+ for (const [promptId, skillNames] of Object.entries(skillPromptIds)) {
1794
+ const turnBytes = promptOutputBytes[promptId] || 0;
1795
+ for (const name of skillNames) {
1796
+ if (toolMap[name]) toolMap[name].outputBytes = turnBytes;
1797
+ }
1798
+ }
1799
+
1800
+ let totalCalls = 0, totalFailed = 0, totalOutputBytes = 0;
1801
+ for (const s of Object.values(toolMap)) {
1802
+ totalCalls += s.count;
1803
+ totalFailed += s.failed;
1804
+ totalOutputBytes += s.outputBytes || 0;
1805
+ }
1806
+ const uniqueTools = Object.keys(toolMap).length;
1807
+
1808
+ const tools = [];
1809
+ for (const [name, stats] of Object.entries(toolMap)) {
1810
+ const impact = totalOutputBytes > 0 ? Math.round((stats.outputBytes || 0) / totalOutputBytes * 100) : 0;
1811
+ const displayName = name.startsWith('mcp__') ? name.split('__').slice(2).join('__') || name : name;
1812
+ tools.push({ name: displayName, count: stats.count, success: stats.success, failed: stats.failed, impact });
1813
+ }
1814
+
1815
+ return { totalCalls, uniqueTools, totalFailed, tools };
1816
+ }
1817
+
1818
+ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
1819
+ const metadata = loadSessionMetadata();
1820
+ const meta = metadata[req.params.sessionId];
1821
+ const jsonlPath = meta?.jsonlPath;
1822
+ if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
1823
+ try {
1824
+ const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
1825
+ if (!data) return res.status(404).json({ error: 'could not parse session' });
1826
+ res.json({ sessionId: req.params.sessionId, ...data });
1827
+ } catch (e) {
1828
+ res.status(500).json({ error: e.message });
1829
+ }
1830
+ });
1831
+
1681
1832
  app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
1682
1833
  const metadata = loadSessionMetadata();
1683
1834
  const meta = metadata[req.params.sessionId];