zozul-cli 0.3.7 → 0.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.
Files changed (47) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/DEVELOPMENT.md +16 -9
  3. package/dist/cli/commands.d.ts.map +1 -1
  4. package/dist/cli/commands.js +4 -0
  5. package/dist/cli/commands.js.map +1 -1
  6. package/dist/dashboard/index.html +164 -42
  7. package/dist/hooks/server.js +6 -0
  8. package/dist/hooks/server.js.map +1 -1
  9. package/dist/parser/ingest.d.ts +2 -0
  10. package/dist/parser/ingest.d.ts.map +1 -1
  11. package/dist/parser/ingest.js +8 -3
  12. package/dist/parser/ingest.js.map +1 -1
  13. package/dist/parser/jsonl.d.ts +9 -3
  14. package/dist/parser/jsonl.d.ts.map +1 -1
  15. package/dist/parser/jsonl.js +35 -11
  16. package/dist/parser/jsonl.js.map +1 -1
  17. package/dist/parser/types.d.ts +2 -0
  18. package/dist/parser/types.d.ts.map +1 -1
  19. package/dist/parser/watcher.d.ts.map +1 -1
  20. package/dist/parser/watcher.js +37 -10
  21. package/dist/parser/watcher.js.map +1 -1
  22. package/dist/storage/db.d.ts +2 -0
  23. package/dist/storage/db.d.ts.map +1 -1
  24. package/dist/storage/db.js +24 -0
  25. package/dist/storage/db.js.map +1 -1
  26. package/dist/storage/repo.d.ts +8 -2
  27. package/dist/storage/repo.d.ts.map +1 -1
  28. package/dist/storage/repo.js +64 -42
  29. package/dist/storage/repo.js.map +1 -1
  30. package/dist/sync/sync.test.js +12 -0
  31. package/dist/sync/sync.test.js.map +1 -1
  32. package/dist/sync/transform.d.ts +2 -0
  33. package/dist/sync/transform.d.ts.map +1 -1
  34. package/dist/sync/transform.js +2 -0
  35. package/dist/sync/transform.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli/commands.ts +5 -0
  38. package/src/dashboard/index.html +164 -42
  39. package/src/hooks/server.ts +7 -0
  40. package/src/parser/ingest.ts +10 -5
  41. package/src/parser/jsonl.ts +43 -7
  42. package/src/parser/types.ts +2 -0
  43. package/src/parser/watcher.ts +34 -9
  44. package/src/storage/db.ts +24 -0
  45. package/src/storage/repo.ts +72 -48
  46. package/src/sync/sync.test.ts +12 -0
  47. package/src/sync/transform.ts +4 -0
@@ -58,6 +58,21 @@
58
58
  .source-badge.local { border-color: var(--blue); color: var(--blue); }
59
59
  .source-badge.clickable { cursor: pointer; }
60
60
  .source-badge.clickable:hover { opacity: 0.7; }
61
+ .view-toggle {
62
+ background: none;
63
+ border: 1px solid var(--border);
64
+ color: var(--text-dim);
65
+ padding: 4px 10px;
66
+ border-radius: 6px;
67
+ cursor: pointer;
68
+ font-family: var(--mono);
69
+ font-size: 13px;
70
+ font-weight: 600;
71
+ min-width: 32px;
72
+ transition: border-color 0.15s, color 0.15s;
73
+ }
74
+ .view-toggle:hover { border-color: var(--accent); color: var(--text); }
75
+ .view-toggle.tokens { color: var(--blue, #64b5f6); border-color: var(--blue, #64b5f6); }
61
76
  .auto-btn {
62
77
  background: none;
63
78
  border: 1px solid var(--border);
@@ -437,6 +452,7 @@
437
452
  <span class="subtitle">Agent Observability</span>
438
453
  <div class="header-right">
439
454
  <span class="source-badge" id="source-badge" style="display:none" onclick="toggleDataSource()">Local</span>
455
+ <button class="view-toggle" id="view-mode-toggle" onclick="toggleViewMode()" title="Switch between cost and token view">$</button>
440
456
  <button class="auto-btn" onclick="manualRefresh()" title="Auto-refresh every 10s">
441
457
  <span class="live-dot"></span>
442
458
  Auto
@@ -458,9 +474,9 @@
458
474
  <div id="view-summary" class="view active">
459
475
  <div class="time-selector"></div>
460
476
  <div class="stats-grid" id="summary-stats"></div>
461
- <div class="chart-card"><h3>Daily Cost (30d)</h3><canvas id="chart-daily-cost"></canvas></div>
477
+ <div class="chart-card"><h3 id="chart-title">Daily Cost (30d)</h3><canvas id="chart-daily-cost"></canvas></div>
462
478
  <div class="panel">
463
- <div class="panel-header"><span>Cost by Project</span></div>
479
+ <div class="panel-header"><span id="project-panel-title">Cost by Project</span></div>
464
480
  <ul class="project-list" id="project-breakdown"></ul>
465
481
  </div>
466
482
  </div>
@@ -512,7 +528,7 @@
512
528
  <th class="sortable" data-sort="sessions:project_path">Project</th>
513
529
  <th class="sortable" data-sort="sessions:started_at">Started</th>
514
530
  <th class="sortable" data-sort="sessions:total_duration_ms">Duration</th>
515
- <th class="sortable" data-sort="sessions:total_turns">Turns</th>
531
+ <th class="sortable" data-sort="sessions:user_turns">Prompts</th>
516
532
  <th class="sortable desc" data-sort="sessions:total_cost_usd">Cost</th>
517
533
  <th class="sortable" data-sort="sessions:model">Model</th>
518
534
  </tr></thead>
@@ -640,6 +656,7 @@ let remoteAvailable = false;
640
656
  let autoRefreshTimer = null;
641
657
  let currentView = 'summary';
642
658
  let currentTimeWindow = '7d';
659
+ let viewMode = 'cost'; // 'cost' or 'tokens'
643
660
  let previousView = 'tasks';
644
661
  let allTaskGroups = [];
645
662
  let allTagStats = [];
@@ -712,7 +729,16 @@ function toggleDataSource() {
712
729
  async function fetchJson(path) {
713
730
  if (dataSource === 'remote' && typeof ZOZUL_CONFIG !== 'undefined') {
714
731
  try {
715
- const url = ZOZUL_CONFIG.remote.baseUrl + path.replace('/api/', '/');
732
+ let remotePath = path.replace('/api/', '/');
733
+ // Ensure trailing slash before query params to avoid 307 redirects (which drop headers)
734
+ const qIdx = remotePath.indexOf('?');
735
+ if (qIdx === -1) {
736
+ if (!remotePath.endsWith('/')) remotePath += '/';
737
+ } else {
738
+ const base = remotePath.slice(0, qIdx);
739
+ if (!base.endsWith('/')) remotePath = base + '/' + remotePath.slice(qIdx);
740
+ }
741
+ const url = ZOZUL_CONFIG.remote.baseUrl + remotePath;
716
742
  const res = await fetch(url, { headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey } });
717
743
  if (res.ok) return res.json();
718
744
  } catch {}
@@ -771,6 +797,24 @@ function fmtCost(n) {
771
797
  const v = n ?? 0;
772
798
  return v >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(4);
773
799
  }
800
+ function fmtTokens(n) {
801
+ const v = n ?? 0;
802
+ if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M';
803
+ if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K';
804
+ return v.toLocaleString();
805
+ }
806
+ function fmtMetric(cost, tokens) {
807
+ if (viewMode === 'tokens') return tokens != null ? fmtTokens(tokens) : '\u2014';
808
+ return fmtCost(cost);
809
+ }
810
+ function toggleViewMode() {
811
+ viewMode = viewMode === 'cost' ? 'tokens' : 'cost';
812
+ const btn = document.getElementById('view-mode-toggle');
813
+ btn.textContent = viewMode === 'cost' ? '$' : 'T';
814
+ btn.classList.toggle('tokens', viewMode === 'tokens');
815
+ btn.title = viewMode === 'cost' ? 'Switch to token view' : 'Switch to cost view';
816
+ loadViewData(currentView);
817
+ }
774
818
  function fmtDuration(ms) {
775
819
  if (!ms || ms <= 0) return '\u2014';
776
820
  const s = Math.floor(ms / 1000);
@@ -836,9 +880,13 @@ function timeQueryString() {
836
880
  return 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(now.toISOString());
837
881
  }
838
882
 
883
+ function chartStepForWindow() {
884
+ return currentTimeWindow === '24h' ? '1h' : '1d';
885
+ }
886
+
839
887
  function renderTimeSelectors() {
840
888
  document.querySelectorAll('.time-selector').forEach(el => {
841
- el.innerHTML = ['7d', '30d', 'all'].map(w =>
889
+ el.innerHTML = ['24h', '7d', '30d', 'all'].map(w =>
842
890
  '<button class="time-btn' + (w === currentTimeWindow ? ' active' : '') + '" data-window="' + w + '">' +
843
891
  (w === 'all' ? 'All' : w) + '</button>'
844
892
  ).join('');
@@ -934,18 +982,23 @@ async function loadSummary() {
934
982
  const timeQs = timeQueryString();
935
983
  const timeParam = timeQs ? '?' + timeQs : '';
936
984
  const timeSep = timeQs ? '&' + timeQs : '';
937
- const [stats, tasks, costSeries, sessionsResp] = await Promise.all([
985
+ const chartRange = currentTimeWindow === 'all' ? '90d' : currentTimeWindow;
986
+ const chartStep = currentTimeWindow === '24h' ? '1h' : '1d';
987
+ const [stats, tasks, costSeries, tokenSeries, sessionsResp] = await Promise.all([
938
988
  fetchJson('/api/stats' + timeParam),
939
989
  fetchJson('/api/tasks'),
940
- fetchJson('/api/metrics/cost?range=' + (currentTimeWindow === 'all' ? '90d' : currentTimeWindow) + '&step=1d'),
990
+ fetchJson('/api/metrics/cost?range=' + chartRange + '&step=' + chartStep),
991
+ fetchJson('/api/metrics/tokens?range=' + chartRange + '&step=' + chartStep),
941
992
  fetchJson('/api/sessions?limit=500&offset=0' + timeSep),
942
993
  ]);
994
+ const totalTokens = (stats.total_input_tokens ?? 0) + (stats.total_output_tokens ?? 0) + (stats.total_cache_read_tokens ?? 0);
943
995
  renderSummaryStats({
944
996
  totalCost: stats.total_cost_usd ?? 0,
997
+ totalTokens,
945
998
  totalSessions: stats.total_sessions ?? 0,
946
999
  totalTasks: tasks.length ?? 0,
947
1000
  });
948
- renderDailyCostChart(costSeries);
1001
+ renderUsageChart(costSeries, tokenSeries);
949
1002
  const sessions = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
950
1003
  renderProjectBreakdown(sessions);
951
1004
  } catch (e) {
@@ -953,18 +1006,52 @@ async function loadSummary() {
953
1006
  }
954
1007
  }
955
1008
 
956
- function renderDailyCostChart(costData) {
957
- if (!costData || !costData.length) return;
958
- const labels = costData.map(d => {
1009
+ function renderUsageChart(costData, tokenData) {
1010
+ const isTokens = viewMode === 'tokens';
1011
+ const isHourly = currentTimeWindow === '24h';
1012
+ const sourceData = isTokens ? tokenData : costData;
1013
+
1014
+ const titleEl = document.getElementById('chart-title');
1015
+ if (titleEl) {
1016
+ const period = isHourly ? 'Hourly' : 'Daily';
1017
+ titleEl.textContent = isTokens ? period + ' Tokens' : period + ' Cost';
1018
+ }
1019
+
1020
+ if (!sourceData || !sourceData.length) return;
1021
+
1022
+ const labels = sourceData.map(d => {
959
1023
  const dt = new Date(d.timestamp);
960
- return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
1024
+ return isHourly
1025
+ ? dt.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true })
1026
+ : dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
961
1027
  });
962
- const data = costData.map(d => d.cost);
1028
+
1029
+ let data, label, bgColor, borderColor, tooltipFmt, yFmt;
1030
+ if (isTokens) {
1031
+ data = sourceData.map(d => (d.input ?? 0) + (d.output ?? 0) + (d.cache_read ?? 0));
1032
+ label = 'Tokens';
1033
+ bgColor = 'rgba(100,181,246,0.3)';
1034
+ borderColor = '#64b5f6';
1035
+ tooltipFmt = ctx => fmtTokens(ctx.parsed.y);
1036
+ yFmt = v => fmtTokens(v);
1037
+ } else {
1038
+ data = sourceData.map(d => d.cost);
1039
+ label = 'Cost (USD)';
1040
+ bgColor = 'rgba(255,152,0,0.3)';
1041
+ borderColor = '#ff9800';
1042
+ tooltipFmt = ctx => '$' + ctx.parsed.y.toFixed(4);
1043
+ yFmt = v => '$' + v.toFixed(2);
1044
+ }
963
1045
 
964
1046
  const existing = chartInstances['chart-daily-cost'];
965
1047
  if (existing) {
966
1048
  existing.data.labels = labels;
967
1049
  existing.data.datasets[0].data = data;
1050
+ existing.data.datasets[0].label = label;
1051
+ existing.data.datasets[0].backgroundColor = bgColor;
1052
+ existing.data.datasets[0].borderColor = borderColor;
1053
+ existing.options.plugins.tooltip.callbacks.label = tooltipFmt;
1054
+ existing.options.scales.y.ticks.callback = yFmt;
968
1055
  existing.update('none');
969
1056
  return;
970
1057
  }
@@ -976,10 +1063,10 @@ function renderDailyCostChart(costData) {
976
1063
  data: {
977
1064
  labels,
978
1065
  datasets: [{
979
- label: 'Cost (USD)',
1066
+ label,
980
1067
  data,
981
- backgroundColor: 'rgba(255,152,0,0.3)',
982
- borderColor: '#ff9800',
1068
+ backgroundColor: bgColor,
1069
+ borderColor,
983
1070
  borderWidth: 1,
984
1071
  borderRadius: 3,
985
1072
  }]
@@ -988,11 +1075,11 @@ function renderDailyCostChart(costData) {
988
1075
  responsive: true,
989
1076
  plugins: {
990
1077
  legend: { display: false },
991
- tooltip: { callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(4) } },
1078
+ tooltip: { callbacks: { label: tooltipFmt } },
992
1079
  },
993
1080
  scales: {
994
1081
  x: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } },
995
- y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: v => '$' + v.toFixed(2) } },
1082
+ y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: yFmt } },
996
1083
  }
997
1084
  }
998
1085
  });
@@ -1009,26 +1096,34 @@ function renderProjectBreakdown(sessions) {
1009
1096
  const byProject = {};
1010
1097
  for (const s of sessions) {
1011
1098
  const proj = s.project_path || 'Unknown';
1012
- if (!byProject[proj]) byProject[proj] = 0;
1013
- byProject[proj] += s.total_cost_usd || 0;
1014
- }
1015
- const sorted = Object.entries(byProject).sort((a, b) => b[1] - a[1]);
1016
- const maxCost = sorted[0]?.[1] || 1;
1017
-
1018
- el.innerHTML = sorted.slice(0, 10).map(([proj, cost]) => {
1019
- const pct = Math.round(cost / maxCost * 100);
1099
+ if (!byProject[proj]) byProject[proj] = { cost: 0, tokens: 0 };
1100
+ byProject[proj].cost += s.total_cost_usd || 0;
1101
+ byProject[proj].tokens += (s.total_input_tokens || 0) + (s.total_output_tokens || 0) + (s.total_cache_read_tokens || 0);
1102
+ }
1103
+ const isTokens = viewMode === 'tokens';
1104
+ const panelTitle = document.getElementById('project-panel-title');
1105
+ if (panelTitle) panelTitle.textContent = isTokens ? 'Tokens by Project' : 'Cost by Project';
1106
+ const sorted = Object.entries(byProject).sort((a, b) => (isTokens ? b[1].tokens - a[1].tokens : b[1].cost - a[1].cost));
1107
+ const maxVal = (isTokens ? sorted[0]?.[1].tokens : sorted[0]?.[1].cost) || 1;
1108
+
1109
+ el.innerHTML = sorted.slice(0, 10).map(([proj, vals]) => {
1110
+ const val = isTokens ? vals.tokens : vals.cost;
1111
+ const pct = Math.round(val / maxVal * 100);
1020
1112
  return '<li class="project-item">' +
1021
1113
  '<span class="project-name" title="' + escHtml(proj) + '">' + escHtml(basename(proj)) + '</span>' +
1022
1114
  '<div class="project-bar-wrap"><div class="project-bar" style="width:' + pct + '%"></div></div>' +
1023
- '<span class="project-cost">' + fmtCost(cost) + '</span>' +
1115
+ '<span class="project-cost">' + (isTokens ? fmtTokens(val) : fmtCost(val)) + '</span>' +
1024
1116
  '</li>';
1025
1117
  }).join('');
1026
1118
  }
1027
1119
 
1028
1120
  function renderSummaryStats(s) {
1029
1121
  const grid = document.getElementById('summary-stats');
1122
+ const primary = viewMode === 'tokens'
1123
+ ? { label: 'Total Tokens', value: fmtTokens(s.totalTokens) }
1124
+ : { label: 'Total Cost', value: fmtCost(s.totalCost) };
1030
1125
  grid.innerHTML = [
1031
- { label: 'Total Cost', value: fmtCost(s.totalCost), hero: true },
1126
+ { label: primary.label, value: primary.value, hero: true },
1032
1127
  { label: 'Total Sessions', value: fmtNum(s.totalSessions), hero: true },
1033
1128
  { label: 'Total Tasks', value: fmtNum(s.totalTasks), hero: true },
1034
1129
  ].map(c =>
@@ -1066,7 +1161,7 @@ function renderTaskTable(groups) {
1066
1161
  return '<tr class="clickable" onclick="showTaskDetail(\'' + escHtml(g.tags).replace(/'/g, "\\'") + '\')">' +
1067
1162
  '<td>' + pills + '</td>' +
1068
1163
  '<td>' + fmtDuration(g.total_duration_ms) + '</td>' +
1069
- '<td><span class="badge badge-cost">' + fmtCost(g.total_cost_usd) + '</span></td>' +
1164
+ '<td><span class="badge badge-cost">' + fmtMetric(g.total_cost_usd, g.total_tokens) + '</span></td>' +
1070
1165
  '<td>' + fmtNum(g.human_interventions) + '</td>' +
1071
1166
  '<td title="' + fmtAbsolute(g.last_seen) + '">' + fmtRelative(g.last_seen) + '</td>' +
1072
1167
  '</tr>';
@@ -1110,7 +1205,7 @@ async function showTaskDetail(tagSet) {
1110
1205
  } else {
1111
1206
  stats = await fetchJson('/api/tasks/stats?' + qs);
1112
1207
  }
1113
- const turns = await fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0');
1208
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0'));
1114
1209
 
1115
1210
  statsEl.innerHTML = [
1116
1211
  { label: 'Task', value: '<span style="font-size:14px">' + pills + '</span>' },
@@ -1170,13 +1265,13 @@ function taskDetailQs() {
1170
1265
 
1171
1266
  async function taskDetailNext() {
1172
1267
  taskDetailOffset += PAGE_SIZE;
1173
- const turns = await fetchJson('/api/tasks/turns?' + taskDetailQs() + '&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset);
1268
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + taskDetailQs() + '&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset));
1174
1269
  renderTaskDetailTurns(turns);
1175
1270
  }
1176
1271
 
1177
1272
  async function taskDetailPrev() {
1178
1273
  taskDetailOffset = Math.max(0, taskDetailOffset - PAGE_SIZE);
1179
- const turns = await fetchJson('/api/tasks/turns?' + taskDetailQs() + '&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset);
1274
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + taskDetailQs() + '&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset));
1180
1275
  renderTaskDetailTurns(turns);
1181
1276
  }
1182
1277
 
@@ -1186,8 +1281,10 @@ async function loadTags() {
1186
1281
  try {
1187
1282
  const tasks = await fetchJson('/api/tasks');
1188
1283
  if (!tasks.length) { allTagStats = []; renderTagTable([]); return; }
1284
+ const timeQs = timeQueryString();
1285
+ const timeSep = timeQs ? '&' + timeQs : '';
1189
1286
  const statsList = await Promise.all(
1190
- tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any')
1287
+ tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any' + timeSep)
1191
1288
  .then(s => ({ ...s, tag: t.task, last_seen: t.last_tagged }))
1192
1289
  .catch(() => ({ tag: t.task, turn_count: t.turn_count, human_interventions: 0, total_duration_ms: 0, total_cost_usd: 0, last_seen: t.last_tagged }))
1193
1290
  )
@@ -1198,6 +1295,7 @@ async function loadTags() {
1198
1295
  human_interventions: s.user_turns ?? s.human_interventions ?? 0,
1199
1296
  total_duration_ms: s.total_duration_ms ?? 0,
1200
1297
  total_cost_usd: s.total_cost_usd ?? 0,
1298
+ total_tokens: (s.total_input_tokens ?? 0) + (s.total_output_tokens ?? 0) + (s.total_cache_read_tokens ?? 0),
1201
1299
  last_seen: s.last_seen,
1202
1300
  }));
1203
1301
  renderTagTable(sortData(allTagStats, sortState.tags));
@@ -1216,7 +1314,7 @@ function renderTagTable(stats) {
1216
1314
  '<tr class="clickable" onclick="showTagDetail(\'' + escHtml(t.tag).replace(/'/g, "\\'") + '\')">' +
1217
1315
  '<td><span class="tag-pill">' + escHtml(t.tag) + '</span></td>' +
1218
1316
  '<td>' + fmtDuration(t.total_duration_ms) + '</td>' +
1219
- '<td><span class="badge badge-cost">' + fmtCost(t.total_cost_usd) + '</span></td>' +
1317
+ '<td><span class="badge badge-cost">' + fmtMetric(t.total_cost_usd, t.total_tokens) + '</span></td>' +
1220
1318
  '<td>' + fmtNum(t.human_interventions) + '</td>' +
1221
1319
  '<td title="' + fmtAbsolute(t.last_seen) + '">' + fmtRelative(t.last_seen) + '</td>' +
1222
1320
  '</tr>'
@@ -1357,30 +1455,54 @@ async function showSession(id, returnTo) {
1357
1455
  document.getElementById('session-detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
1358
1456
  document.getElementById('session-detail-turns').innerHTML = '';
1359
1457
 
1360
- const [session, turns] = await Promise.all([
1458
+ const [session, turns, subagents] = await Promise.all([
1361
1459
  fetchJson('/api/sessions/' + id),
1362
1460
  fetchJson('/api/sessions/' + id + '/turns'),
1461
+ fetchJson('/api/sessions/' + id + '/subagents').catch(() => []),
1363
1462
  ]);
1364
1463
 
1365
- document.getElementById('session-detail-stats').innerHTML = [
1464
+ const stats = [
1366
1465
  { label: 'Session ID', value: '<span class="session-id" onclick="copyText(\'' + session.id + '\')" title="Click to copy">' + shortId(session.id) + ' <span class="copy-icon">&#x2398;</span></span>' },
1367
1466
  { label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
1368
1467
  { label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
1369
1468
  { label: 'Duration', value: fmtDuration(session.total_duration_ms) },
1370
- { label: 'Turns', value: session.total_turns },
1469
+ { label: 'Prompts', value: session.user_turns ?? session.total_turns },
1371
1470
  { label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
1372
1471
  { label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
1373
1472
  { label: 'Cost', value: fmtCost(session.total_cost_usd) },
1374
- ].map(c =>
1473
+ ];
1474
+ if (subagents.length > 0) {
1475
+ stats.push({ label: 'Agents', value: String(subagents.length) });
1476
+ }
1477
+ document.getElementById('session-detail-stats').innerHTML = stats.map(c =>
1375
1478
  '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
1376
1479
  ).join('');
1377
1480
 
1378
1481
  const turnsDiv = document.getElementById('session-detail-turns');
1379
- if (!turns.length) {
1482
+ let html = '';
1483
+
1484
+ if (subagents.length > 0) {
1485
+ html += '<div style="margin-bottom:16px"><h3 style="font-size:13px;color:var(--text-dim);margin:0 0 8px">Agent Sub-Sessions</h3>' +
1486
+ '<table class="data-table" style="width:100%"><thead><tr>' +
1487
+ '<th>Agent Type</th><th>Duration</th><th>Turns</th><th>Started</th>' +
1488
+ '</tr></thead><tbody>' +
1489
+ subagents.map(s =>
1490
+ '<tr class="clickable" onclick="showSession(\'' + s.id + '\', \'sessions\')">' +
1491
+ '<td><span class="tag-pill">' + escHtml(s.agent_type ?? 'unknown') + '</span></td>' +
1492
+ '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
1493
+ '<td>' + (s.total_turns ?? 0) + '</td>' +
1494
+ '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
1495
+ '</tr>'
1496
+ ).join('') +
1497
+ '</tbody></table></div>';
1498
+ }
1499
+
1500
+ if (!turns.length && !subagents.length) {
1380
1501
  turnsDiv.innerHTML = '<div class="empty">No turns recorded.</div>';
1381
1502
  return;
1382
1503
  }
1383
- turnsDiv.innerHTML = turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
1504
+ html += turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
1505
+ turnsDiv.innerHTML = html;
1384
1506
  }
1385
1507
 
1386
1508
  // ── Shared turn rendering ──
@@ -1511,8 +1633,8 @@ function renderSessionsTable(sessions) {
1511
1633
  '<td' + projAttr + '>' + escHtml(proj) + '</td>' +
1512
1634
  '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
1513
1635
  '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
1514
- '<td>' + (s.total_turns ?? 0) + '</td>' +
1515
- '<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
1636
+ '<td>' + (s.user_turns ?? s.total_turns ?? 0) + '</td>' +
1637
+ '<td><span class="badge badge-cost">' + fmtMetric(s.total_cost_usd, (s.total_input_tokens || 0) + (s.total_output_tokens || 0) + (s.total_cache_read_tokens || 0)) + '</span></td>' +
1516
1638
  '<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
1517
1639
  '</tr>';
1518
1640
  }).join('');
@@ -218,6 +218,13 @@ function handleApiRoute(url: string, repo: SessionRepo, res: http.ServerResponse
218
218
  return;
219
219
  }
220
220
 
221
+ const subagentsMatch = path.match(/^\/api\/sessions\/([^/]+)\/subagents$/);
222
+ if (subagentsMatch) {
223
+ const subs = repo.getSubSessions(subagentsMatch[1]);
224
+ sendJson(res, 200, subs);
225
+ return;
226
+ }
227
+
221
228
  if (path === "/api/metrics/tokens") {
222
229
  const { from, to, stepSeconds } = parseTimeRange(url);
223
230
  sendJson(res, 200, repo.getTokenTimeSeries(from, to, stepSeconds));
@@ -1,5 +1,5 @@
1
1
  import type { SessionRepo } from "../storage/repo.js";
2
- import { discoverSessionFiles, parseSessionFile } from "./jsonl.js";
2
+ import { discoverSessionFiles, parseSessionFile, type DiscoveredFile } from "./jsonl.js";
3
3
  import type { ParsedSession } from "./types.js";
4
4
  import { getActiveContext } from "../context/index.js";
5
5
 
@@ -15,8 +15,8 @@ export async function ingestAllSessions(
15
15
  let ingested = 0;
16
16
  let skipped = 0;
17
17
 
18
- for (const { filePath, projectPath } of files) {
19
- const parsed = await parseSessionFile(filePath, projectPath);
18
+ for (const { filePath, projectPath, parentSessionId, agentType } of files) {
19
+ const parsed = await parseSessionFile(filePath, projectPath, { parentSessionId, agentType });
20
20
 
21
21
  if (!opts.force) {
22
22
  const existing = repo.getSession(parsed.sessionId);
@@ -40,9 +40,12 @@ export async function ingestSessionFile(
40
40
  repo: SessionRepo,
41
41
  filePath: string,
42
42
  projectPath?: string,
43
- opts: { noTag?: boolean } = {},
43
+ opts: { noTag?: boolean; parentSessionId?: string; agentType?: string } = {},
44
44
  ): Promise<ParsedSession> {
45
- const parsed = await parseSessionFile(filePath, projectPath);
45
+ const parsed = await parseSessionFile(filePath, projectPath, {
46
+ parentSessionId: opts.parentSessionId,
47
+ agentType: opts.agentType,
48
+ });
46
49
  persistSession(repo, parsed, opts);
47
50
  return parsed;
48
51
  }
@@ -51,6 +54,8 @@ function persistSession(repo: SessionRepo, parsed: ParsedSession, opts: { noTag?
51
54
  repo.upsertSession({
52
55
  id: parsed.sessionId,
53
56
  project_path: parsed.projectPath,
57
+ parent_session_id: parsed.parentSessionId,
58
+ agent_type: parsed.agentType,
54
59
  started_at: parsed.startedAt,
55
60
  ended_at: parsed.endedAt,
56
61
  total_input_tokens: parsed.totalInputTokens,
@@ -23,8 +23,15 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.
23
23
  * Discover all session JSONL files across all projects.
24
24
  * Files are stored directly in each project directory (not in a sessions/ subdir).
25
25
  */
26
- export function discoverSessionFiles(): { filePath: string; projectPath: string }[] {
27
- const results: { filePath: string; projectPath: string }[] = [];
26
+ export type DiscoveredFile = {
27
+ filePath: string;
28
+ projectPath: string;
29
+ parentSessionId?: string;
30
+ agentType?: string;
31
+ };
32
+
33
+ export function discoverSessionFiles(): DiscoveredFile[] {
34
+ const results: DiscoveredFile[] = [];
28
35
 
29
36
  if (!fs.existsSync(PROJECTS_DIR)) return results;
30
37
 
@@ -36,11 +43,37 @@ export function discoverSessionFiles(): { filePath: string; projectPath: string
36
43
  const decodedProject = decodeProjectPath(projectDir);
37
44
 
38
45
  for (const file of fs.readdirSync(projectDirPath)) {
39
- if (!UUID_RE.test(file)) continue;
40
- results.push({
41
- filePath: path.join(projectDirPath, file),
42
- projectPath: decodedProject,
43
- });
46
+ // Main session JSONL files
47
+ if (UUID_RE.test(file)) {
48
+ results.push({
49
+ filePath: path.join(projectDirPath, file),
50
+ projectPath: decodedProject,
51
+ });
52
+ }
53
+
54
+ // Check for subagents directory inside UUID-named session dirs
55
+ const uuidDirMatch = file.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
56
+ if (uuidDirMatch) {
57
+ const subagentsDir = path.join(projectDirPath, file, "subagents");
58
+ if (fs.existsSync(subagentsDir)) {
59
+ const parentSessionId = uuidDirMatch[1];
60
+ for (const agentFile of fs.readdirSync(subagentsDir)) {
61
+ if (!agentFile.endsWith(".jsonl")) continue;
62
+ let agentType: string | undefined;
63
+ try {
64
+ const metaPath = path.join(subagentsDir, agentFile.replace(".jsonl", ".meta.json"));
65
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
66
+ agentType = meta.agentType;
67
+ } catch { /* no meta or invalid JSON */ }
68
+ results.push({
69
+ filePath: path.join(subagentsDir, agentFile),
70
+ projectPath: decodedProject,
71
+ parentSessionId,
72
+ agentType,
73
+ });
74
+ }
75
+ }
76
+ }
44
77
  }
45
78
  }
46
79
 
@@ -57,6 +90,7 @@ export function discoverSessionFiles(): { filePath: string; projectPath: string
57
90
  export async function parseSessionFile(
58
91
  filePath: string,
59
92
  projectPath?: string,
93
+ opts?: { parentSessionId?: string; agentType?: string },
60
94
  ): Promise<ParsedSession> {
61
95
  const entries = await readJsonlFile(filePath);
62
96
  const sessionId = path.basename(filePath, ".jsonl");
@@ -165,6 +199,8 @@ export async function parseSessionFile(
165
199
  return {
166
200
  sessionId,
167
201
  projectPath: projectPath ?? null,
202
+ parentSessionId: opts?.parentSessionId ?? null,
203
+ agentType: opts?.agentType ?? null,
168
204
  startedAt: startedAt ?? new Date().toISOString(),
169
205
  endedAt,
170
206
  model,
@@ -54,6 +54,8 @@ export interface SessionEntry {
54
54
  export interface ParsedSession {
55
55
  sessionId: string;
56
56
  projectPath: string | null;
57
+ parentSessionId: string | null;
58
+ agentType: string | null;
57
59
  startedAt: string;
58
60
  endedAt: string | null;
59
61
  model: string | null;
@@ -30,9 +30,9 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
30
30
  if (catchUp) {
31
31
  const files = discoverSessionFiles();
32
32
  let caught = 0;
33
- for (const { filePath, projectPath } of files) {
33
+ for (const { filePath, projectPath, parentSessionId, agentType } of files) {
34
34
  try {
35
- await ingestSessionFile(repo, filePath, projectPath);
35
+ await ingestSessionFile(repo, filePath, projectPath, { parentSessionId, agentType });
36
36
  caught++;
37
37
  } catch {
38
38
  // Ignore parse errors on individual files
@@ -61,7 +61,11 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
61
61
  timers.delete(filePath);
62
62
  try {
63
63
  const projectPath = decodeProjectPath(filePath);
64
- await ingestSessionFile(repo, filePath, projectPath ?? undefined);
64
+ const subagentInfo = extractSubagentInfo(filePath);
65
+ await ingestSessionFile(repo, filePath, projectPath ?? undefined, {
66
+ parentSessionId: subagentInfo?.parentSessionId,
67
+ agentType: subagentInfo?.agentType,
68
+ });
65
69
  if (verbose) {
66
70
  process.stderr.write(`[watcher] ingested: ${filePath}\n`);
67
71
  }
@@ -103,14 +107,35 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
103
107
  };
104
108
  }
105
109
 
110
+ /**
111
+ * Extract parent session ID and agent type from a subagent file path.
112
+ * Path format: .../<parent-uuid>/subagents/<agent-id>.jsonl
113
+ */
114
+ function extractSubagentInfo(filePath: string): { parentSessionId: string; agentType: string | undefined } | null {
115
+ const match = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/subagents\/([^/]+)\.jsonl$/i);
116
+ if (!match) return null;
117
+ const parentSessionId = match[1];
118
+ let agentType: string | undefined;
119
+ try {
120
+ const metaPath = filePath.replace(".jsonl", ".meta.json");
121
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
122
+ agentType = meta.agentType;
123
+ } catch { /* missing or invalid meta */ }
124
+ return { parentSessionId, agentType };
125
+ }
126
+
106
127
  /**
107
128
  * Extract the decoded project path from an absolute JSONL file path.
108
- * ~/.claude/projects/<encoded>/<uuid>.jsonl
109
- * where <encoded> has "/" replaced with "-".
129
+ * Handles both main sessions and subagent files:
130
+ * ~/.claude/projects/<encoded>/<uuid>.jsonl
131
+ * ~/.claude/projects/<encoded>/<uuid>/subagents/<agent-id>.jsonl
110
132
  */
111
133
  function decodeProjectPath(filePath: string): string | null {
112
- // Match project dir directly containing the UUID file
113
- const match = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
114
- if (!match) return null;
115
- return match[1].replace(/-/g, "/");
134
+ // Main session: projects/<encoded>/<uuid>.jsonl
135
+ const parentMatch = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
136
+ if (parentMatch) return parentMatch[1].replace(/-/g, "/");
137
+ // Subagent: projects/<encoded>/<uuid>/subagents/<agent-id>.jsonl
138
+ const subMatch = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\/subagents\/[^/]+\.jsonl$/i);
139
+ if (subMatch) return subMatch[1].replace(/-/g, "/");
140
+ return null;
116
141
  }
package/src/storage/db.ts CHANGED
@@ -116,11 +116,35 @@ function migrate(db: Database.Database): void {
116
116
  last_synced_at TEXT
117
117
  );
118
118
  `);
119
+
120
+ // Additive migrations (safe to re-run; errors mean column already exists)
121
+ const addColumns = [
122
+ `ALTER TABLE sessions ADD COLUMN parent_session_id TEXT`,
123
+ `ALTER TABLE sessions ADD COLUMN agent_type TEXT`,
124
+ ];
125
+ for (const sql of addColumns) {
126
+ try { db.exec(sql); } catch { /* column already exists */ }
127
+ }
128
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)`);
129
+
130
+ // Deduplicate otel_metrics and add unique constraint to match backend
131
+ try {
132
+ db.exec(`
133
+ DELETE FROM otel_metrics WHERE id NOT IN (
134
+ SELECT MIN(id) FROM otel_metrics
135
+ GROUP BY session_id, name, timestamp, json_extract(attributes, '$.type')
136
+ );
137
+ CREATE UNIQUE INDEX uq_otel_metrics_session_name_ts_type
138
+ ON otel_metrics(session_id, name, timestamp, json_extract(attributes, '$.type'));
139
+ `);
140
+ } catch { /* index already exists */ }
119
141
  }
120
142
 
121
143
  export type SessionRow = {
122
144
  id: string;
123
145
  project_path: string | null;
146
+ parent_session_id: string | null;
147
+ agent_type: string | null;
124
148
  started_at: string;
125
149
  ended_at: string | null;
126
150
  total_input_tokens: number;