claude-code-kanban 4.3.0 → 4.5.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/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');
@@ -159,6 +159,10 @@ function isAgentFresh(agent) {
159
159
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
160
160
  }
161
161
 
162
+ function isAgentLive(agent) {
163
+ return agent.status === 'active' || agent.status === 'idle';
164
+ }
165
+
162
166
  // Claude Code records gitBranch from the launch-time repo and never updates it
163
167
  // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
164
168
  // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
@@ -216,7 +220,7 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
216
220
  for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
217
221
  try {
218
222
  const agent = readAgentJsonl(path.join(agentDir, file));
219
- if (isTeam && (agent.status === 'active' || agent.status === 'idle')) {
223
+ if (isTeam && isAgentLive(agent)) {
220
224
  result.hasActive = true;
221
225
  if (agent.status === 'active') result.hasRunning = true;
222
226
  } else if (isAgentFresh(agent)) {
@@ -456,6 +460,9 @@ function refreshSessionMetadataPath(jsonlPath) {
456
460
  if (info.cwd) existing.cwd = info.cwd;
457
461
  if (info.gitBranch) existing.gitBranch = info.gitBranch;
458
462
  if (info.customTitle) existing.customTitle = info.customTitle;
463
+ // Direct assign (not guarded) so a /goal clear propagates as null.
464
+ existing.goal = info.goal || null;
465
+ if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
459
466
  return true;
460
467
  }
461
468
 
@@ -540,7 +547,9 @@ function loadSessionMetadata() {
540
547
  cwd: sessionInfo.cwd || null,
541
548
  gitBranch: sessionInfo.gitBranch || null,
542
549
  customTitle: sessionInfo.customTitle || null,
543
- jsonlPath: jsonlPath
550
+ goal: sessionInfo.goal || null,
551
+ jsonlPath: jsonlPath,
552
+ logicalParentUuid: sessionInfo.logicalParentUuid || null
544
553
  };
545
554
  sessionIds.push(sessionId);
546
555
  }
@@ -688,6 +697,7 @@ function buildSessionObject(id, meta, overrides = {}) {
688
697
  description: meta.description || null,
689
698
  gitBranch: resolveSessionGitBranch(meta),
690
699
  customTitle: meta.customTitle || null,
700
+ goal: meta.goal || null,
691
701
  taskCount: 0,
692
702
  completed: 0,
693
703
  inProgress: 0,
@@ -727,6 +737,7 @@ app.get('/api/sessions', async (req, res) => {
727
737
 
728
738
  const pinnedParam = req.query.pinned;
729
739
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
740
+ const activeFilter = req.query.filter === 'active';
730
741
 
731
742
  const metadata = loadSessionMetadata();
732
743
  const sessionsMap = new Map();
@@ -749,6 +760,24 @@ app.get('/api/sessions', async (req, res) => {
749
760
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
750
761
  const stale = logAge > AGENT_STALE_MS;
751
762
 
763
+ const isTeam = isTeamSession(entry.name);
764
+ const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
765
+ const resolvedAgentDir = path.join(AGENT_ACTIVITY_DIR, teamConfig?.leadSessionId || entry.name);
766
+ const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
767
+
768
+ // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
769
+ // Mirrors the post-filter predicate using only signals already computed above.
770
+ if (activeFilter && !pinnedIds.has(entry.name)) {
771
+ const hasRecentLog = logAge <= SESSION_STALE_MS;
772
+ const cheaplyActive = logStat.hasMessages && (
773
+ hasRecentLog
774
+ || agentStatus.hasActive
775
+ || !!agentStatus.waitingForUser
776
+ || (pending > 0 || inProgress > 0)
777
+ );
778
+ if (!cheaplyActive) continue;
779
+ }
780
+
752
781
  // Use newest of: task file mtime, JSONL mtime, directory mtime
753
782
  let modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
754
783
  if (logMtime) {
@@ -756,17 +785,9 @@ app.get('/api/sessions', async (req, res) => {
756
785
  if (jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
757
786
  }
758
787
 
759
- const isTeam = isTeamSession(entry.name);
760
- const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
761
788
  const memberCount = teamConfig?.members?.length || 0;
762
789
  const planInfo = getPlanInfo(meta.slug);
763
790
 
764
- const resolvedAgentDir = (() => {
765
- const rid = teamConfig?.leadSessionId || entry.name;
766
- return path.join(AGENT_ACTIVITY_DIR, rid);
767
- })();
768
- const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
769
-
770
791
  sessionsMap.set(entry.name, buildSessionObject(entry.name, meta, {
771
792
  _logStat: logStat,
772
793
  taskCount,
@@ -842,14 +863,24 @@ app.get('/api/sessions', async (req, res) => {
842
863
  const logMtime = logStat.mtime;
843
864
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
844
865
  const stale = logAge > AGENT_STALE_MS;
866
+ const metaIsTeam = isTeamSession(sessionId);
867
+ const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
868
+ const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
869
+
870
+ // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
871
+ if (activeFilter && !pinnedIds.has(sessionId)) {
872
+ const hasRecentLog = logAge <= SESSION_STALE_MS;
873
+ const cheaplyActive = logStat.hasMessages && (
874
+ hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
875
+ );
876
+ if (!cheaplyActive) continue;
877
+ }
878
+
845
879
  let modifiedAt = meta.created || null;
846
880
  if (logMtime) {
847
881
  const jsonlMtime = new Date(logMtime).toISOString();
848
882
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
849
883
  }
850
- const metaIsTeam = isTeamSession(sessionId);
851
- const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
852
- const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
853
884
  sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
854
885
  _logStat: logStat,
855
886
  modifiedAt: modifiedAt || new Date(0).toISOString(),
@@ -898,16 +929,21 @@ app.get('/api/sessions', async (req, res) => {
898
929
  existing.memberCount = cfg.members?.length || 0;
899
930
  existing.name = existing.name || cfg.name || dir.name;
900
931
  teamLeaderIds.add(leaderId);
901
- // Attach team-named task directory if present
932
+ // Attach team-named task directory if present.
933
+ // Prefer team task dir over an empty session-UUID task dir — when a team session has
934
+ // both a UUID-named dir (often empty: just .lock/.highwatermark) and a team-named dir
935
+ // holding the real tasks, the leader card otherwise shows 0/0.
902
936
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
903
- if (!existing.tasksDir && existsSync(teamTaskDir)) {
937
+ if (existsSync(teamTaskDir)) {
904
938
  const counts = getTaskCounts(teamTaskDir);
905
- existing.taskCount = counts.taskCount;
906
- existing.completed = counts.completed;
907
- existing.inProgress = counts.inProgress;
908
- existing.pending = counts.pending;
909
- existing.tasksDir = teamTaskDir;
910
- existing.sharedTaskList = dir.name;
939
+ if (!existing.tasksDir || counts.taskCount > (existing.taskCount || 0)) {
940
+ existing.taskCount = counts.taskCount;
941
+ existing.completed = counts.completed;
942
+ existing.inProgress = counts.inProgress;
943
+ existing.pending = counts.pending;
944
+ existing.tasksDir = teamTaskDir;
945
+ existing.sharedTaskList = dir.name;
946
+ }
911
947
  }
912
948
  // Re-check agent status with isTeam=true
913
949
  const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
@@ -933,14 +969,28 @@ app.get('/api/sessions', async (req, res) => {
933
969
  if (group.length < 2) continue;
934
970
  group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
935
971
  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) {
972
+ const linkedSession = group.find(s => s !== planSession && !s.hasPlan && new Date(s.modifiedAt) >= new Date(planSession?.modifiedAt || 0));
973
+ if (planSession && linkedSession) {
938
974
  planSession.hasWaitingForUser = false;
939
- planSession.planImplementationSessionId = implSession.id;
940
- implSession.planSourceSessionId = planSession.id;
975
+ planSession.planImplementationSessionId = linkedSession.id;
976
+ linkedSession.planSourceSessionId = planSession.id;
941
977
  }
942
978
  }
943
979
 
980
+ // Suppress parent sessions that have a compact continuation — compaction is involuntary
981
+ // (context limit hit), not an intentional fork. Only the continuation is shown.
982
+ const compactSuppressed = new Set();
983
+ for (const [sid] of sessionsMap) {
984
+ const compactAnchor = metadata[sid]?.logicalParentUuid;
985
+ if (!compactAnchor) continue;
986
+ const parent = lookupParentSession(sid);
987
+ if (parent.parentSessionId && sessionsMap.has(parent.parentSessionId)) {
988
+ compactSuppressed.add(parent.parentSessionId);
989
+ sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
990
+ }
991
+ }
992
+ for (const sid of compactSuppressed) sessionsMap.delete(sid);
993
+
944
994
  // Backfill contextStatus for already-built sessions that are pinned
945
995
  for (const pid of pinnedIds) {
946
996
  const s = sessionsMap.get(pid);
@@ -968,6 +1018,22 @@ app.get('/api/sessions', async (req, res) => {
968
1018
  }));
969
1019
  }
970
1020
 
1021
+ // Server-side activity filter (mirrors the client predicate in public/app.js).
1022
+ // Pinned IDs bypass — they should always be in the response.
1023
+ if (activeFilter) {
1024
+ const isActive = (s) =>
1025
+ s.hasMessages && (
1026
+ (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1027
+ || s.hasActiveAgents
1028
+ || s.hasWaitingForUser
1029
+ || s.hasRecentLog
1030
+ );
1031
+ for (const [id, s] of sessionsMap) {
1032
+ if (pinnedIds.has(id)) continue;
1033
+ if (!isActive(s)) sessionsMap.delete(id);
1034
+ }
1035
+ }
1036
+
971
1037
  // Convert map to array and sort by most recently modified
972
1038
  let sessions = Array.from(sessionsMap.values());
973
1039
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -1226,7 +1292,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1226
1292
  const agentTs = agent.updatedAt || agent.startedAt;
1227
1293
  const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
1228
1294
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
1229
- if (agent.status === 'active' || agent.status === 'idle') {
1295
+ if (isAgentLive(agent)) {
1230
1296
  const agentName = agentDisplayName(agent);
1231
1297
  const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
1232
1298
  if (!isTeamMember) {
@@ -1238,7 +1304,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1238
1304
  agents.push(agent);
1239
1305
  } catch (e) { /* skip invalid */ }
1240
1306
  }
1241
- const liveAgents = agents.filter(a => a.status === 'active' || a.status === 'idle');
1307
+ const liveAgents = agents.filter(isAgentLive);
1242
1308
  if (liveAgents.length && meta.jsonlPath) {
1243
1309
  try {
1244
1310
  const terminated = getTerminatedTeammates(meta.jsonlPath);
@@ -1264,7 +1330,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1264
1330
  getSessionDigest(meta.jsonlPath);
1265
1331
  if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
1266
1332
  for (const agent of liveAgents) {
1267
- if (agent.status !== 'active' && agent.status !== 'idle') continue;
1333
+ if (!isAgentLive(agent)) continue;
1268
1334
  let reason = null;
1269
1335
  if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
1270
1336
  else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
@@ -1282,13 +1348,16 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1282
1348
 
1283
1349
  const dirty = new Set();
1284
1350
 
1285
- const agentsNeedingPrompt = agents.filter(a => !a.prompt && !a.promptUnavailable);
1286
- const agentsNeedingName = agents.filter(a => !a.agentName && !a.agentNameUnavailable);
1287
- const agentsNeedingDesc = agents.filter(a => !a.description && !a.descriptionUnavailable);
1288
- if ((agentsNeedingPrompt.length || agentsNeedingName.length || agentsNeedingDesc.length) && meta.jsonlPath) {
1289
- let byAgentId = {};
1290
- let nameByAgentId = {};
1291
- let descByAgentId = {};
1351
+ // Agents may be missing prompt/name/description because the parent's agent_progress
1352
+ // event or the subagent's own transcript hadn't been written yet at last poll. While
1353
+ // the agent is still active, keep retrying instead of latching *Unavailable permanently
1354
+ // (same pattern as agentsNeedingModel below). Each field shares the same resolve flow:
1355
+ // look up in progressMap by agentId, fall back to per-field extractor, persist only
1356
+ // on actual change.
1357
+ const byAgentId = {};
1358
+ const nameByAgentId = {};
1359
+ const descByAgentId = {};
1360
+ if (meta.jsonlPath) {
1292
1361
  try {
1293
1362
  const progressMap = getProgressMap(meta.jsonlPath);
1294
1363
  for (const entry of Object.values(progressMap)) {
@@ -1297,33 +1366,52 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1297
1366
  if (entry.description && !descByAgentId[entry.agentId]) descByAgentId[entry.agentId] = entry.description;
1298
1367
  }
1299
1368
  } catch (_) {}
1300
- for (const agent of agentsNeedingPrompt) {
1301
- const prompt = byAgentId[agent.agentId]
1302
- || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1303
- if (prompt) agent.prompt = prompt;
1304
- else agent.promptUnavailable = true;
1305
- dirty.add(agent);
1306
- }
1307
- for (const agent of agentsNeedingName) {
1308
- if (nameByAgentId[agent.agentId]) agent.agentName = nameByAgentId[agent.agentId];
1309
- else agent.agentNameUnavailable = true;
1310
- dirty.add(agent);
1311
- }
1312
- for (const agent of agentsNeedingDesc) {
1313
- if (descByAgentId[agent.agentId]) agent.description = descByAgentId[agent.agentId];
1314
- else agent.descriptionUnavailable = true;
1315
- dirty.add(agent);
1369
+ }
1370
+ const reconcileFields = [
1371
+ {
1372
+ field: 'prompt',
1373
+ flag: 'promptUnavailable',
1374
+ lookup: (a) => {
1375
+ if (byAgentId[a.agentId]) return byAgentId[a.agentId];
1376
+ try { return extractPromptFromTranscript(subagentJsonlPath(meta, a.agentId)); } catch (_) { return null; }
1377
+ },
1378
+ },
1379
+ { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
1380
+ { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => descByAgentId[a.agentId] || null },
1381
+ ];
1382
+ if (meta.jsonlPath) {
1383
+ for (const { field, flag, lookup } of reconcileFields) {
1384
+ for (const agent of agents) {
1385
+ if (agent[field]) continue;
1386
+ if (agent[flag] && !isAgentLive(agent)) continue;
1387
+ const value = lookup(agent);
1388
+ if (value) {
1389
+ agent[field] = value;
1390
+ delete agent[flag];
1391
+ dirty.add(agent);
1392
+ } else if (!isAgentLive(agent) && !agent[flag]) {
1393
+ agent[flag] = true;
1394
+ dirty.add(agent);
1395
+ }
1396
+ }
1316
1397
  }
1317
1398
  }
1318
1399
 
1319
- const agentsNeedingModel = agents.filter(a => !a.model && !a.modelUnavailable);
1400
+ // Retry stopped agents even if modelUnavailable was set — it may have been marked
1401
+ // unavailable while the agent was still active and its JSONL wasn't ready yet.
1402
+ const agentsNeedingModel = agents.filter(a => !a.model && (!a.modelUnavailable || a.status === 'stopped'));
1320
1403
  if (agentsNeedingModel.length && meta.jsonlPath) {
1321
1404
  for (const agent of agentsNeedingModel) {
1322
1405
  let model = null;
1323
1406
  try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1324
- if (model) agent.model = model;
1325
- else agent.modelUnavailable = true;
1326
- dirty.add(agent);
1407
+ if (model) {
1408
+ agent.model = model;
1409
+ delete agent.modelUnavailable;
1410
+ dirty.add(agent);
1411
+ } else if (agent.status === 'stopped' && !agent.modelUnavailable) {
1412
+ agent.modelUnavailable = true;
1413
+ dirty.add(agent);
1414
+ }
1327
1415
  }
1328
1416
  }
1329
1417
 
@@ -1462,41 +1550,61 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
1462
1550
  return found || primary;
1463
1551
  }
1464
1552
 
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).
1553
+ // Claude Code creates child sessions in two ways:
1554
+ // Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
1555
+ // Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
1556
+ // Birthtime (not mtime) identifies the parent mtime changes on resume, birthtime is immutable.
1472
1557
  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
1558
  const FORK_ANCHOR_SCAN_LINES = 10;
1476
1559
  function findForkAnchorUuid(jsonlPath) {
1477
1560
  let text;
1478
1561
  try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
1479
- let firstUuid = null;
1480
- let scanned = 0;
1562
+ let firstUuid = null, scanned = 0;
1481
1563
  for (const l of text.split('\n')) {
1482
1564
  if (!l) continue;
1483
1565
  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 */ }
1566
+ try { const d = JSON.parse(l); if (!firstUuid && d.uuid) firstUuid = d.uuid; } catch { /* skip malformed */ }
1489
1567
  }
1490
1568
  return firstUuid;
1491
1569
  }
1492
- function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1570
+ // Fallback when metadata cache lacks logicalParentUuid (older entries, cold cache).
1571
+ // Hot path reads from metadata directly; this never runs from the suppression loop.
1572
+ // Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
1573
+ // always sits in the preamble before the first user/assistant record.
1574
+ const COMPACT_ANCHOR_READ_MAX = 1048576;
1575
+ function findCompactAnchorUuid(jsonlPath) {
1576
+ let fd;
1577
+ try {
1578
+ fd = openSync(jsonlPath, 'r');
1579
+ const buf = Buffer.alloc(COMPACT_ANCHOR_READ_MAX);
1580
+ const n = readSync(fd, buf, 0, COMPACT_ANCHOR_READ_MAX, 0);
1581
+ const text = buf.toString('utf8', 0, n);
1582
+ const lastNl = text.lastIndexOf('\n');
1583
+ const complete = lastNl >= 0 ? text.slice(0, lastNl) : text;
1584
+ for (const l of complete.split('\n')) {
1585
+ if (!l) continue;
1586
+ try {
1587
+ const d = JSON.parse(l);
1588
+ if (d.type === 'user' || d.type === 'assistant') return null;
1589
+ if (d.subtype === 'compact_boundary' && d.logicalParentUuid) return d.logicalParentUuid;
1590
+ } catch { /* skip malformed */ }
1591
+ }
1592
+ return null;
1593
+ } catch { return null; }
1594
+ finally { if (fd !== undefined) { try { closeSync(fd); } catch {} } }
1595
+ }
1596
+ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, maxBirthtimeMs) {
1493
1597
  let files;
1494
1598
  try { files = readdirSync(projectDir); } catch { return null; }
1495
- const candidates = [];
1599
+ let best = null;
1496
1600
  for (const f of files) {
1497
1601
  if (!f.endsWith('.jsonl')) continue;
1498
1602
  const fp = path.join(projectDir, f);
1499
1603
  if (fp === excludeJsonlPath) continue;
1604
+ let birthtime = 0;
1605
+ try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
1606
+ if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
1607
+ if (best && birthtime >= best.birthtime) continue;
1500
1608
  let text;
1501
1609
  try { text = readFileSync(fp, 'utf8'); } catch { continue; }
1502
1610
  if (!text.includes(targetUuid)) continue;
@@ -1505,31 +1613,33 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1505
1613
  try {
1506
1614
  const d = JSON.parse(l);
1507
1615
  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 });
1616
+ best = { parentSessionId: d.sessionId, parentJsonlPath: fp, birthtime };
1511
1617
  break;
1512
1618
  }
1513
1619
  } catch { /* skip */ }
1514
1620
  }
1515
1621
  }
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 };
1622
+ if (!best) return null;
1623
+ return { parentSessionId: best.parentSessionId, parentJsonlPath: best.parentJsonlPath };
1520
1624
  }
1521
1625
  function lookupParentSession(sessionId) {
1522
1626
  if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
1523
1627
  const meta = loadSessionMetadata()[sessionId];
1524
- const result = { parentSessionId: null, parentJsonlPath: null };
1628
+ const result = { parentSessionId: null, parentJsonlPath: null, isCompact: false };
1525
1629
  if (meta?.jsonlPath) {
1526
- const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
1630
+ const compactAnchor = meta.logicalParentUuid || findCompactAnchorUuid(meta.jsonlPath);
1631
+ result.isCompact = !!compactAnchor;
1632
+ const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
1527
1633
  if (anchorUuid) {
1528
- const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
1529
- if (hit) Object.assign(result, hit);
1634
+ let selfBirthtime;
1635
+ try { selfBirthtime = statSync(meta.jsonlPath).birthtimeMs; } catch { /* ignore */ }
1636
+ if (selfBirthtime != null) {
1637
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime);
1638
+ if (hit) Object.assign(result, hit);
1639
+ }
1530
1640
  }
1531
1641
  }
1532
- if (result.parentSessionId) parentSessionCache.set(sessionId, result);
1642
+ parentSessionCache.set(sessionId, result);
1533
1643
  return result;
1534
1644
  }
1535
1645
  app.get('/api/sessions/:sessionId/parent', (req, res) => {
@@ -1678,6 +1788,117 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1678
1788
  res.json({ toolUseId: req.params.toolUseId, content });
1679
1789
  });
1680
1790
 
1791
+ const toolStatsCache = new Map();
1792
+
1793
+ function buildToolStats(jsonlPath) {
1794
+ const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
1795
+ const seenResults = new Set();
1796
+ const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
1797
+ const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
1798
+ const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
1799
+
1800
+ const content = readFileSync(jsonlPath, 'utf8');
1801
+ for (const line of content.split('\n')) {
1802
+ if (!line) continue;
1803
+ let obj;
1804
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1805
+
1806
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
1807
+ for (const block of obj.message.content) {
1808
+ if (block.type === 'tool_use' && block.name && block.id) {
1809
+ const isSkill = block.name === 'Skill';
1810
+ const displayName = isSkill && block.input?.skill
1811
+ ? `Skill(${block.input.skill})`
1812
+ : block.name === 'Agent' && block.input?.subagent_type
1813
+ ? `Agent(${block.input.subagent_type})`
1814
+ : block.name;
1815
+ toolUseById[block.id] = { displayName, isSkill };
1816
+ }
1817
+ }
1818
+ } else if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
1819
+ const promptId = obj.promptId;
1820
+ for (const block of obj.message.content) {
1821
+ if (block.type !== 'tool_result' || !block.tool_use_id) continue;
1822
+ const entry = toolUseById[block.tool_use_id];
1823
+ if (!entry) continue;
1824
+ const { displayName, isSkill } = entry;
1825
+ seenResults.add(block.tool_use_id);
1826
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
1827
+ toolMap[displayName].count++;
1828
+ const raw = typeof block.content === 'string' ? block.content
1829
+ : Array.isArray(block.content) ? block.content.map(b => b.text || '').join('\n') : '';
1830
+ const bytes = raw.length;
1831
+ toolMap[displayName].outputBytes += bytes;
1832
+ if (promptId) {
1833
+ promptOutputBytes[promptId] = (promptOutputBytes[promptId] || 0) + bytes;
1834
+ if (isSkill) {
1835
+ if (!skillPromptIds[promptId]) skillPromptIds[promptId] = [];
1836
+ skillPromptIds[promptId].push(displayName);
1837
+ }
1838
+ }
1839
+ const isRejected = typeof obj.toolUseResult === 'string' && /rejected/i.test(obj.toolUseResult);
1840
+ if (isRejected) toolMap[displayName].rejected++;
1841
+ else {
1842
+ const lower = raw.toLowerCase();
1843
+ const failed = /^error/i.test(raw.trimStart())
1844
+ || /exit code [1-9]/.test(lower)
1845
+ || lower.includes('command failed')
1846
+ || (lower.includes('failed') && lower.includes('error'));
1847
+ if (failed) toolMap[displayName].failed++;
1848
+ else toolMap[displayName].success++;
1849
+ }
1850
+ }
1851
+ }
1852
+ }
1853
+
1854
+ // Count tool_use blocks that never got a tool_result
1855
+ for (const [id, { displayName }] of Object.entries(toolUseById)) {
1856
+ if (seenResults.has(id)) continue;
1857
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
1858
+ toolMap[displayName].count++;
1859
+ }
1860
+
1861
+ // Approximate Skill impact: replace tiny dispatch bytes with the full turn's output
1862
+ for (const [promptId, skillNames] of Object.entries(skillPromptIds)) {
1863
+ const turnBytes = promptOutputBytes[promptId] || 0;
1864
+ for (const name of skillNames) {
1865
+ if (toolMap[name]) toolMap[name].outputBytes = turnBytes;
1866
+ }
1867
+ }
1868
+
1869
+ let totalCalls = 0, totalFailed = 0, totalRejected = 0, totalOutputBytes = 0;
1870
+ for (const s of Object.values(toolMap)) {
1871
+ totalCalls += s.count;
1872
+ totalFailed += s.failed;
1873
+ totalRejected += s.rejected;
1874
+ totalOutputBytes += s.outputBytes || 0;
1875
+ }
1876
+ const uniqueTools = Object.keys(toolMap).length;
1877
+
1878
+ const tools = [];
1879
+ for (const [name, stats] of Object.entries(toolMap)) {
1880
+ const impact = totalOutputBytes > 0 ? Math.round((stats.outputBytes || 0) / totalOutputBytes * 100) : 0;
1881
+ const displayName = name.startsWith('mcp__') ? name.split('__').slice(2).join('__') || name : name;
1882
+ tools.push({ name: displayName, count: stats.count, success: stats.success, failed: stats.failed, rejected: stats.rejected, impact });
1883
+ }
1884
+
1885
+ return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
1886
+ }
1887
+
1888
+ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
1889
+ const metadata = loadSessionMetadata();
1890
+ const meta = metadata[req.params.sessionId];
1891
+ const jsonlPath = meta?.jsonlPath;
1892
+ if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
1893
+ try {
1894
+ const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
1895
+ if (!data) return res.status(404).json({ error: 'could not parse session' });
1896
+ res.json({ sessionId: req.params.sessionId, ...data });
1897
+ } catch (e) {
1898
+ res.status(500).json({ error: e.message });
1899
+ }
1900
+ });
1901
+
1681
1902
  app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
1682
1903
  const metadata = loadSessionMetadata();
1683
1904
  const meta = metadata[req.params.sessionId];
@@ -2251,13 +2472,38 @@ cleanupContextStatus();
2251
2472
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
2252
2473
  setInterval(cleanupContextStatus, 30 * 60 * 1000);
2253
2474
 
2254
- const server = app.listen(PORT, () => {
2475
+ // Warm the metadata + loop-info caches in the background so the first user
2476
+ // request lands warm. The cheap-probe in /api/sessions skips per-session
2477
+ // enrichment for inactive sessions, so we no longer drive a full self-request
2478
+ // here — that was 690× wasted work for an active-filter first hit.
2479
+ // Yields to the event loop periodically so any inbound request isn't starved.
2480
+ async function prewarmCaches() {
2481
+ const t0 = Date.now();
2482
+ try {
2483
+ const metadata = loadSessionMetadata();
2484
+
2485
+ let i = 0;
2486
+ for (const meta of Object.values(metadata)) {
2487
+ if (meta?.jsonlPath) {
2488
+ try { refreshLoopInfoState(meta.jsonlPath); } catch {}
2489
+ }
2490
+ if (++i % 50 === 0) await new Promise(r => setImmediate(r));
2491
+ }
2492
+
2493
+ console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
2494
+ } catch (e) {
2495
+ console.warn('[prewarm] failed:', e.message);
2496
+ }
2497
+ }
2498
+
2499
+ const server = app.listen(PORT, () => {
2255
2500
  const actualPort = server.address().port;
2256
2501
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
2257
2502
 
2258
2503
  if (process.argv.includes('--open')) {
2259
2504
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
2260
2505
  }
2506
+ setImmediate(prewarmCaches);
2261
2507
  });
2262
2508
 
2263
2509
  server.on('error', (err) => {
@@ -2270,6 +2516,7 @@ const server = app.listen(PORT, () => {
2270
2516
  if (process.argv.includes('--open')) {
2271
2517
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
2272
2518
  }
2519
+ setImmediate(prewarmCaches);
2273
2520
  });
2274
2521
  } else {
2275
2522
  throw err;