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 +8 -2
- package/package.json +1 -1
- package/public/app.js +121 -4
- package/public/index.html +19 -0
- package/public/style.css +144 -0
- package/server.js +191 -40
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
package/public/app.js
CHANGED
|
@@ -494,10 +494,21 @@ function renderActivityChip() {
|
|
|
494
494
|
.join('');
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
-
|
|
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
|
|
937
|
-
if (planSession &&
|
|
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 =
|
|
940
|
-
|
|
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
|
-
|
|
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)
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
1517
|
-
|
|
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
|
|
1565
|
+
const compactAnchor = meta.logicalParentUuid || findCompactAnchorUuid(meta.jsonlPath);
|
|
1566
|
+
result.isCompact = !!compactAnchor;
|
|
1567
|
+
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
1527
1568
|
if (anchorUuid) {
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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];
|