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/lib/parsers.js +171 -15
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +1 -1
- package/public/app.js +278 -56
- package/public/style.css +212 -81
- package/server.js +164 -59
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
|
|
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 (
|
|
938
|
+
if (existsSync(teamTaskDir)) {
|
|
906
939
|
const counts = getTaskCounts(teamTaskDir);
|
|
907
|
-
existing.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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
|
-
|
|
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;
|