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/lib/parsers.js +103 -10
- 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 +329 -47
- package/public/index.html +19 -0
- package/public/style.css +235 -4
- package/server.js +334 -87
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
|
|
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
|
-
|
|
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 (
|
|
937
|
+
if (existsSync(teamTaskDir)) {
|
|
904
938
|
const counts = getTaskCounts(teamTaskDir);
|
|
905
|
-
existing.
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
|
937
|
-
if (planSession &&
|
|
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 =
|
|
940
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
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)
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
// the earliest `uuid` in this session also exists (same uuid+timestamp)
|
|
1470
|
-
// in the parent's JSONL.
|
|
1471
|
-
// We try (1) first, then fall back to (2).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
1517
|
-
|
|
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
|
|
1630
|
+
const compactAnchor = meta.logicalParentUuid || findCompactAnchorUuid(meta.jsonlPath);
|
|
1631
|
+
result.isCompact = !!compactAnchor;
|
|
1632
|
+
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
1527
1633
|
if (anchorUuid) {
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|