claude-code-kanban 4.4.0 → 4.6.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
@@ -22,6 +22,7 @@ const {
22
22
  extractModelFromTranscript,
23
23
  readFullToolResult,
24
24
  readUserImage,
25
+ readCachedImage,
25
26
  updateLoopInfo,
26
27
  buildLoopInfoFromState
27
28
  } = require('./lib/parsers');
@@ -159,6 +160,10 @@ function isAgentFresh(agent) {
159
160
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
160
161
  }
161
162
 
163
+ function isAgentLive(agent) {
164
+ return agent.status === 'active' || agent.status === 'idle';
165
+ }
166
+
162
167
  // Claude Code records gitBranch from the launch-time repo and never updates it
163
168
  // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
164
169
  // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
@@ -216,7 +221,7 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
216
221
  for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
217
222
  try {
218
223
  const agent = readAgentJsonl(path.join(agentDir, file));
219
- if (isTeam && (agent.status === 'active' || agent.status === 'idle')) {
224
+ if (isTeam && isAgentLive(agent)) {
220
225
  result.hasActive = true;
221
226
  if (agent.status === 'active') result.hasRunning = true;
222
227
  } else if (isAgentFresh(agent)) {
@@ -456,6 +461,8 @@ function refreshSessionMetadataPath(jsonlPath) {
456
461
  if (info.cwd) existing.cwd = info.cwd;
457
462
  if (info.gitBranch) existing.gitBranch = info.gitBranch;
458
463
  if (info.customTitle) existing.customTitle = info.customTitle;
464
+ // Direct assign (not guarded) so a /goal clear propagates as null.
465
+ existing.goal = info.goal || null;
459
466
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
460
467
  return true;
461
468
  }
@@ -541,6 +548,7 @@ function loadSessionMetadata() {
541
548
  cwd: sessionInfo.cwd || null,
542
549
  gitBranch: sessionInfo.gitBranch || null,
543
550
  customTitle: sessionInfo.customTitle || null,
551
+ goal: sessionInfo.goal || null,
544
552
  jsonlPath: jsonlPath,
545
553
  logicalParentUuid: sessionInfo.logicalParentUuid || null
546
554
  };
@@ -690,6 +698,7 @@ function buildSessionObject(id, meta, overrides = {}) {
690
698
  description: meta.description || null,
691
699
  gitBranch: resolveSessionGitBranch(meta),
692
700
  customTitle: meta.customTitle || null,
701
+ goal: meta.goal || null,
693
702
  taskCount: 0,
694
703
  completed: 0,
695
704
  inProgress: 0,
@@ -729,6 +738,7 @@ app.get('/api/sessions', async (req, res) => {
729
738
 
730
739
  const pinnedParam = req.query.pinned;
731
740
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
741
+ const activeFilter = req.query.filter === 'active';
732
742
 
733
743
  const metadata = loadSessionMetadata();
734
744
  const sessionsMap = new Map();
@@ -751,6 +761,24 @@ app.get('/api/sessions', async (req, res) => {
751
761
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
752
762
  const stale = logAge > AGENT_STALE_MS;
753
763
 
764
+ const isTeam = isTeamSession(entry.name);
765
+ const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
766
+ const resolvedAgentDir = path.join(AGENT_ACTIVITY_DIR, teamConfig?.leadSessionId || entry.name);
767
+ const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
768
+
769
+ // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
770
+ // Mirrors the post-filter predicate using only signals already computed above.
771
+ if (activeFilter && !pinnedIds.has(entry.name)) {
772
+ const hasRecentLog = logAge <= SESSION_STALE_MS;
773
+ const cheaplyActive = logStat.hasMessages && (
774
+ hasRecentLog
775
+ || agentStatus.hasActive
776
+ || !!agentStatus.waitingForUser
777
+ || (pending > 0 || inProgress > 0)
778
+ );
779
+ if (!cheaplyActive) continue;
780
+ }
781
+
754
782
  // Use newest of: task file mtime, JSONL mtime, directory mtime
755
783
  let modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
756
784
  if (logMtime) {
@@ -758,17 +786,9 @@ app.get('/api/sessions', async (req, res) => {
758
786
  if (jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
759
787
  }
760
788
 
761
- const isTeam = isTeamSession(entry.name);
762
- const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
763
789
  const memberCount = teamConfig?.members?.length || 0;
764
790
  const planInfo = getPlanInfo(meta.slug);
765
791
 
766
- const resolvedAgentDir = (() => {
767
- const rid = teamConfig?.leadSessionId || entry.name;
768
- return path.join(AGENT_ACTIVITY_DIR, rid);
769
- })();
770
- const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime, isTeam);
771
-
772
792
  sessionsMap.set(entry.name, buildSessionObject(entry.name, meta, {
773
793
  _logStat: logStat,
774
794
  taskCount,
@@ -844,14 +864,24 @@ app.get('/api/sessions', async (req, res) => {
844
864
  const logMtime = logStat.mtime;
845
865
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
846
866
  const stale = logAge > AGENT_STALE_MS;
867
+ const metaIsTeam = isTeamSession(sessionId);
868
+ const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
869
+ const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
870
+
871
+ // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
872
+ if (activeFilter && !pinnedIds.has(sessionId)) {
873
+ const hasRecentLog = logAge <= SESSION_STALE_MS;
874
+ const cheaplyActive = logStat.hasMessages && (
875
+ hasRecentLog || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
876
+ );
877
+ if (!cheaplyActive) continue;
878
+ }
879
+
847
880
  let modifiedAt = meta.created || null;
848
881
  if (logMtime) {
849
882
  const jsonlMtime = new Date(logMtime).toISOString();
850
883
  if (!modifiedAt || jsonlMtime > modifiedAt) modifiedAt = jsonlMtime;
851
884
  }
852
- const metaIsTeam = isTeamSession(sessionId);
853
- const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
854
- const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
855
885
  sessionsMap.set(sessionId, buildSessionObject(sessionId, meta, {
856
886
  _logStat: logStat,
857
887
  modifiedAt: modifiedAt || new Date(0).toISOString(),
@@ -900,16 +930,21 @@ app.get('/api/sessions', async (req, res) => {
900
930
  existing.memberCount = cfg.members?.length || 0;
901
931
  existing.name = existing.name || cfg.name || dir.name;
902
932
  teamLeaderIds.add(leaderId);
903
- // Attach team-named task directory if present
933
+ // Attach team-named task directory if present.
934
+ // Prefer team task dir over an empty session-UUID task dir — when a team session has
935
+ // both a UUID-named dir (often empty: just .lock/.highwatermark) and a team-named dir
936
+ // holding the real tasks, the leader card otherwise shows 0/0.
904
937
  const teamTaskDir = path.join(TASKS_DIR, dir.name);
905
- if (!existing.tasksDir && existsSync(teamTaskDir)) {
938
+ if (existsSync(teamTaskDir)) {
906
939
  const counts = getTaskCounts(teamTaskDir);
907
- existing.taskCount = counts.taskCount;
908
- existing.completed = counts.completed;
909
- existing.inProgress = counts.inProgress;
910
- existing.pending = counts.pending;
911
- existing.tasksDir = teamTaskDir;
912
- existing.sharedTaskList = dir.name;
940
+ if (!existing.tasksDir || counts.taskCount > (existing.taskCount || 0)) {
941
+ existing.taskCount = counts.taskCount;
942
+ existing.completed = counts.completed;
943
+ existing.inProgress = counts.inProgress;
944
+ existing.pending = counts.pending;
945
+ existing.tasksDir = teamTaskDir;
946
+ existing.sharedTaskList = dir.name;
947
+ }
913
948
  }
914
949
  // Re-check agent status with isTeam=true
915
950
  const agentDir = path.join(AGENT_ACTIVITY_DIR, leaderId);
@@ -984,6 +1019,22 @@ app.get('/api/sessions', async (req, res) => {
984
1019
  }));
985
1020
  }
986
1021
 
1022
+ // Server-side activity filter (mirrors the client predicate in public/app.js).
1023
+ // Pinned IDs bypass — they should always be in the response.
1024
+ if (activeFilter) {
1025
+ const isActive = (s) =>
1026
+ s.hasMessages && (
1027
+ (!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0))
1028
+ || s.hasActiveAgents
1029
+ || s.hasWaitingForUser
1030
+ || s.hasRecentLog
1031
+ );
1032
+ for (const [id, s] of sessionsMap) {
1033
+ if (pinnedIds.has(id)) continue;
1034
+ if (!isActive(s)) sessionsMap.delete(id);
1035
+ }
1036
+ }
1037
+
987
1038
  // Convert map to array and sort by most recently modified
988
1039
  let sessions = Array.from(sessionsMap.values());
989
1040
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
@@ -1242,7 +1293,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1242
1293
  const agentTs = agent.updatedAt || agent.startedAt;
1243
1294
  const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
1244
1295
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
1245
- if (agent.status === 'active' || agent.status === 'idle') {
1296
+ if (isAgentLive(agent)) {
1246
1297
  const agentName = agentDisplayName(agent);
1247
1298
  const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
1248
1299
  if (!isTeamMember) {
@@ -1254,7 +1305,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1254
1305
  agents.push(agent);
1255
1306
  } catch (e) { /* skip invalid */ }
1256
1307
  }
1257
- const liveAgents = agents.filter(a => a.status === 'active' || a.status === 'idle');
1308
+ const liveAgents = agents.filter(isAgentLive);
1258
1309
  if (liveAgents.length && meta.jsonlPath) {
1259
1310
  try {
1260
1311
  const terminated = getTerminatedTeammates(meta.jsonlPath);
@@ -1280,7 +1331,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1280
1331
  getSessionDigest(meta.jsonlPath);
1281
1332
  if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
1282
1333
  for (const agent of liveAgents) {
1283
- if (agent.status !== 'active' && agent.status !== 'idle') continue;
1334
+ if (!isAgentLive(agent)) continue;
1284
1335
  let reason = null;
1285
1336
  if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
1286
1337
  else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
@@ -1298,13 +1349,16 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1298
1349
 
1299
1350
  const dirty = new Set();
1300
1351
 
1301
- const agentsNeedingPrompt = agents.filter(a => !a.prompt && !a.promptUnavailable);
1302
- const agentsNeedingName = agents.filter(a => !a.agentName && !a.agentNameUnavailable);
1303
- const agentsNeedingDesc = agents.filter(a => !a.description && !a.descriptionUnavailable);
1304
- if ((agentsNeedingPrompt.length || agentsNeedingName.length || agentsNeedingDesc.length) && meta.jsonlPath) {
1305
- let byAgentId = {};
1306
- let nameByAgentId = {};
1307
- let descByAgentId = {};
1352
+ // Agents may be missing prompt/name/description because the parent's agent_progress
1353
+ // event or the subagent's own transcript hadn't been written yet at last poll. While
1354
+ // the agent is still active, keep retrying instead of latching *Unavailable permanently
1355
+ // (same pattern as agentsNeedingModel below). Each field shares the same resolve flow:
1356
+ // look up in progressMap by agentId, fall back to per-field extractor, persist only
1357
+ // on actual change.
1358
+ const byAgentId = {};
1359
+ const nameByAgentId = {};
1360
+ const descByAgentId = {};
1361
+ if (meta.jsonlPath) {
1308
1362
  try {
1309
1363
  const progressMap = getProgressMap(meta.jsonlPath);
1310
1364
  for (const entry of Object.values(progressMap)) {
@@ -1313,22 +1367,34 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1313
1367
  if (entry.description && !descByAgentId[entry.agentId]) descByAgentId[entry.agentId] = entry.description;
1314
1368
  }
1315
1369
  } catch (_) {}
1316
- for (const agent of agentsNeedingPrompt) {
1317
- const prompt = byAgentId[agent.agentId]
1318
- || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1319
- if (prompt) agent.prompt = prompt;
1320
- else agent.promptUnavailable = true;
1321
- dirty.add(agent);
1322
- }
1323
- for (const agent of agentsNeedingName) {
1324
- if (nameByAgentId[agent.agentId]) agent.agentName = nameByAgentId[agent.agentId];
1325
- else agent.agentNameUnavailable = true;
1326
- dirty.add(agent);
1327
- }
1328
- for (const agent of agentsNeedingDesc) {
1329
- if (descByAgentId[agent.agentId]) agent.description = descByAgentId[agent.agentId];
1330
- else agent.descriptionUnavailable = true;
1331
- dirty.add(agent);
1370
+ }
1371
+ const reconcileFields = [
1372
+ {
1373
+ field: 'prompt',
1374
+ flag: 'promptUnavailable',
1375
+ lookup: (a) => {
1376
+ if (byAgentId[a.agentId]) return byAgentId[a.agentId];
1377
+ try { return extractPromptFromTranscript(subagentJsonlPath(meta, a.agentId)); } catch (_) { return null; }
1378
+ },
1379
+ },
1380
+ { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
1381
+ { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => descByAgentId[a.agentId] || null },
1382
+ ];
1383
+ if (meta.jsonlPath) {
1384
+ for (const { field, flag, lookup } of reconcileFields) {
1385
+ for (const agent of agents) {
1386
+ if (agent[field]) continue;
1387
+ if (agent[flag] && !isAgentLive(agent)) continue;
1388
+ const value = lookup(agent);
1389
+ if (value) {
1390
+ agent[field] = value;
1391
+ delete agent[flag];
1392
+ dirty.add(agent);
1393
+ } else if (!isAgentLive(agent) && !agent[flag]) {
1394
+ agent[flag] = true;
1395
+ dirty.add(agent);
1396
+ }
1397
+ }
1332
1398
  }
1333
1399
  }
1334
1400
 
@@ -1758,7 +1824,7 @@ function buildToolStats(jsonlPath) {
1758
1824
  if (!entry) continue;
1759
1825
  const { displayName, isSkill } = entry;
1760
1826
  seenResults.add(block.tool_use_id);
1761
- if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
1827
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
1762
1828
  toolMap[displayName].count++;
1763
1829
  const raw = typeof block.content === 'string' ? block.content
1764
1830
  : Array.isArray(block.content) ? block.content.map(b => b.text || '').join('\n') : '';
@@ -1771,13 +1837,17 @@ function buildToolStats(jsonlPath) {
1771
1837
  skillPromptIds[promptId].push(displayName);
1772
1838
  }
1773
1839
  }
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++;
1840
+ const isRejected = typeof obj.toolUseResult === 'string' && /rejected/i.test(obj.toolUseResult);
1841
+ if (isRejected) toolMap[displayName].rejected++;
1842
+ else {
1843
+ const lower = raw.toLowerCase();
1844
+ const failed = /^error/i.test(raw.trimStart())
1845
+ || /exit code [1-9]/.test(lower)
1846
+ || lower.includes('command failed')
1847
+ || (lower.includes('failed') && lower.includes('error'));
1848
+ if (failed) toolMap[displayName].failed++;
1849
+ else toolMap[displayName].success++;
1850
+ }
1781
1851
  }
1782
1852
  }
1783
1853
  }
@@ -1785,7 +1855,7 @@ function buildToolStats(jsonlPath) {
1785
1855
  // Count tool_use blocks that never got a tool_result
1786
1856
  for (const [id, { displayName }] of Object.entries(toolUseById)) {
1787
1857
  if (seenResults.has(id)) continue;
1788
- if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, outputBytes: 0 };
1858
+ if (!toolMap[displayName]) toolMap[displayName] = { count: 0, success: 0, failed: 0, rejected: 0, outputBytes: 0 };
1789
1859
  toolMap[displayName].count++;
1790
1860
  }
1791
1861
 
@@ -1797,10 +1867,11 @@ function buildToolStats(jsonlPath) {
1797
1867
  }
1798
1868
  }
1799
1869
 
1800
- let totalCalls = 0, totalFailed = 0, totalOutputBytes = 0;
1870
+ let totalCalls = 0, totalFailed = 0, totalRejected = 0, totalOutputBytes = 0;
1801
1871
  for (const s of Object.values(toolMap)) {
1802
1872
  totalCalls += s.count;
1803
1873
  totalFailed += s.failed;
1874
+ totalRejected += s.rejected;
1804
1875
  totalOutputBytes += s.outputBytes || 0;
1805
1876
  }
1806
1877
  const uniqueTools = Object.keys(toolMap).length;
@@ -1809,10 +1880,10 @@ function buildToolStats(jsonlPath) {
1809
1880
  for (const [name, stats] of Object.entries(toolMap)) {
1810
1881
  const impact = totalOutputBytes > 0 ? Math.round((stats.outputBytes || 0) / totalOutputBytes * 100) : 0;
1811
1882
  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 });
1883
+ tools.push({ name: displayName, count: stats.count, success: stats.success, failed: stats.failed, rejected: stats.rejected, impact });
1813
1884
  }
1814
1885
 
1815
- return { totalCalls, uniqueTools, totalFailed, tools };
1886
+ return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
1816
1887
  }
1817
1888
 
1818
1889
  app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
@@ -1842,6 +1913,14 @@ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) =
1842
1913
  res.end(buf);
1843
1914
  });
1844
1915
 
1916
+ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
1917
+ const img = readCachedImage(req.params.sessionId, req.params.n);
1918
+ if (!img) return res.status(404).end();
1919
+ res.setHeader('Content-Type', img.mediaType);
1920
+ res.setHeader('Cache-Control', 'no-store');
1921
+ res.end(img.buffer);
1922
+ });
1923
+
1845
1924
  app.get('/api/version', (req, res) => {
1846
1925
  const pkg = require('./package.json');
1847
1926
  res.json({ version: pkg.version });
@@ -2402,13 +2481,38 @@ cleanupContextStatus();
2402
2481
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
2403
2482
  setInterval(cleanupContextStatus, 30 * 60 * 1000);
2404
2483
 
2405
- const server = app.listen(PORT, () => {
2484
+ // Warm the metadata + loop-info caches in the background so the first user
2485
+ // request lands warm. The cheap-probe in /api/sessions skips per-session
2486
+ // enrichment for inactive sessions, so we no longer drive a full self-request
2487
+ // here — that was 690× wasted work for an active-filter first hit.
2488
+ // Yields to the event loop periodically so any inbound request isn't starved.
2489
+ async function prewarmCaches() {
2490
+ const t0 = Date.now();
2491
+ try {
2492
+ const metadata = loadSessionMetadata();
2493
+
2494
+ let i = 0;
2495
+ for (const meta of Object.values(metadata)) {
2496
+ if (meta?.jsonlPath) {
2497
+ try { refreshLoopInfoState(meta.jsonlPath); } catch {}
2498
+ }
2499
+ if (++i % 50 === 0) await new Promise(r => setImmediate(r));
2500
+ }
2501
+
2502
+ console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
2503
+ } catch (e) {
2504
+ console.warn('[prewarm] failed:', e.message);
2505
+ }
2506
+ }
2507
+
2508
+ const server = app.listen(PORT, () => {
2406
2509
  const actualPort = server.address().port;
2407
2510
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
2408
2511
 
2409
2512
  if (process.argv.includes('--open')) {
2410
2513
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
2411
2514
  }
2515
+ setImmediate(prewarmCaches);
2412
2516
  });
2413
2517
 
2414
2518
  server.on('error', (err) => {
@@ -2421,6 +2525,7 @@ const server = app.listen(PORT, () => {
2421
2525
  if (process.argv.includes('--open')) {
2422
2526
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
2423
2527
  }
2528
+ setImmediate(prewarmCaches);
2424
2529
  });
2425
2530
  } else {
2426
2531
  throw err;