agent-teams-dashboard 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CsXA0cmK.js +51 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server-dist/server/teamsApi.js +8 -11
- package/server-dist/server/teamsCache.js +136 -11
- package/server-dist/server/teamsWatcher.js +5 -5
- package/server-dist/server/wsServer.js +11 -4
- package/dist/assets/index-CEXrdfKk.js +0 -51
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Agent Teams Dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CsXA0cmK.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CsK61Xi-.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -13,12 +13,12 @@ export async function handleTeamsApi(req, res) {
|
|
|
13
13
|
const path = url.pathname;
|
|
14
14
|
// GET /api/snapshot
|
|
15
15
|
if (path === '/api/snapshot') {
|
|
16
|
-
json(res, cache.
|
|
16
|
+
json(res, cache.getLeanSnapshot());
|
|
17
17
|
return true;
|
|
18
18
|
}
|
|
19
19
|
// GET /api/teams
|
|
20
20
|
if (path === '/api/teams') {
|
|
21
|
-
const snapshot = cache.
|
|
21
|
+
const snapshot = cache.getLeanSnapshot();
|
|
22
22
|
json(res, snapshot.teams);
|
|
23
23
|
return true;
|
|
24
24
|
}
|
|
@@ -26,7 +26,7 @@ export async function handleTeamsApi(req, res) {
|
|
|
26
26
|
const tasksMatch = path.match(/^\/api\/teams\/([^/]+)\/tasks$/);
|
|
27
27
|
if (tasksMatch) {
|
|
28
28
|
const teamId = decodeURIComponent(tasksMatch[1]);
|
|
29
|
-
const snapshot = cache.
|
|
29
|
+
const snapshot = cache.getLeanSnapshot();
|
|
30
30
|
const team = snapshot.teams.find((t) => t.config.name === teamId);
|
|
31
31
|
if (!team) {
|
|
32
32
|
notFound(res);
|
|
@@ -39,7 +39,7 @@ export async function handleTeamsApi(req, res) {
|
|
|
39
39
|
const teamMatch = path.match(/^\/api\/teams\/([^/]+)$/);
|
|
40
40
|
if (teamMatch) {
|
|
41
41
|
const teamId = decodeURIComponent(teamMatch[1]);
|
|
42
|
-
const snapshot = cache.
|
|
42
|
+
const snapshot = cache.getLeanSnapshot();
|
|
43
43
|
const team = snapshot.teams.find((t) => t.config.name === teamId);
|
|
44
44
|
if (!team) {
|
|
45
45
|
notFound(res);
|
|
@@ -69,14 +69,11 @@ export async function handleTeamsApi(req, res) {
|
|
|
69
69
|
const agentMatch = path.match(/^\/api\/agents\/([^/]+)\/activity$/);
|
|
70
70
|
if (agentMatch) {
|
|
71
71
|
const agentId = decodeURIComponent(agentMatch[1]);
|
|
72
|
-
let entries = cache.getAgentActivity(agentId);
|
|
73
72
|
const limitParam = url.searchParams.get('limit');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
}
|
|
73
|
+
const offsetParam = url.searchParams.get('offset');
|
|
74
|
+
const limit = limitParam ? parseInt(limitParam, 10) : undefined;
|
|
75
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : undefined;
|
|
76
|
+
const entries = cache.getAgentActivity(agentId, limit && !isNaN(limit) && limit > 0 ? limit : undefined, offset && !isNaN(offset) && offset >= 0 ? offset : undefined);
|
|
80
77
|
json(res, entries);
|
|
81
78
|
return true;
|
|
82
79
|
}
|
|
@@ -7,6 +7,7 @@ const TEAMS_DIR = join(CLAUDE_DIR, 'teams');
|
|
|
7
7
|
const TASKS_DIR = join(CLAUDE_DIR, 'tasks');
|
|
8
8
|
const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
|
|
9
9
|
const MAX_ENTRIES_PER_AGENT = 200;
|
|
10
|
+
const MAX_FILE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // Only read JSONL files modified within 7 days
|
|
10
11
|
// In-memory caches
|
|
11
12
|
const teams = new Map();
|
|
12
13
|
const tasks = new Map();
|
|
@@ -14,6 +15,10 @@ const agentEntries = new Map();
|
|
|
14
15
|
const agentOffsets = new Map();
|
|
15
16
|
const teamFileMtimes = new Map(); // team name -> latest mtime (ms)
|
|
16
17
|
const knownProjectDirs = new Set(); // all project dir names under ~/.claude/projects/
|
|
18
|
+
const changedAgentIds = new Set(); // agentIds with new entries since last clear
|
|
19
|
+
const newEntriesBuffer = new Map(); // agentId -> entries added since last clear
|
|
20
|
+
const teamLeadSessions = new Map(); // leadSessionId -> team info
|
|
21
|
+
const subagentToMember = new Map(); // subagent filePath -> memberAgentId
|
|
17
22
|
export const onChange = new EventEmitter();
|
|
18
23
|
// --- Helpers ---
|
|
19
24
|
async function safeReaddir(dir) {
|
|
@@ -63,6 +68,20 @@ export async function refreshTeams() {
|
|
|
63
68
|
};
|
|
64
69
|
teams.set(config.name, config);
|
|
65
70
|
currentNames.add(config.name);
|
|
71
|
+
// Build team lead session mapping for subagent resolution
|
|
72
|
+
const leadSessionId = parsed.leadSessionId;
|
|
73
|
+
if (leadSessionId && Array.isArray(parsed.members)) {
|
|
74
|
+
const members = parsed.members
|
|
75
|
+
.filter((m) => m.agentType !== 'team-lead' && m.prompt)
|
|
76
|
+
.map((m) => ({
|
|
77
|
+
agentId: m.agentId ?? '',
|
|
78
|
+
name: m.name ?? '',
|
|
79
|
+
prompt: (m.prompt ?? '').slice(0, 300), // first 300 chars is enough for matching
|
|
80
|
+
}));
|
|
81
|
+
if (members.length > 0) {
|
|
82
|
+
teamLeadSessions.set(leadSessionId, { teamName: config.name, members });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
66
85
|
// Track config file mtime
|
|
67
86
|
const configStat = await safeFileStat(configPath);
|
|
68
87
|
if (configStat) {
|
|
@@ -159,18 +178,22 @@ export async function scanAgentJsonl() {
|
|
|
159
178
|
// Subagent JSONL: agent-*.jsonl under session/subagents/
|
|
160
179
|
const subagentsDir = join(entryPath, 'subagents');
|
|
161
180
|
const files = await safeReaddir(subagentsDir);
|
|
181
|
+
const parentSessionId = entry; // the session UUID folder name
|
|
162
182
|
for (const file of files) {
|
|
163
183
|
if (!file.startsWith('agent-') || !file.endsWith('.jsonl'))
|
|
164
184
|
continue;
|
|
165
|
-
await readNewEntries(join(subagentsDir, file), false, projDir);
|
|
185
|
+
await readNewEntries(join(subagentsDir, file), false, projDir, parentSessionId);
|
|
166
186
|
}
|
|
167
187
|
}
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
|
-
async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
190
|
+
async function readNewEntries(filePath, isSessionFile, projectDir, parentSessionId) {
|
|
171
191
|
const fileStat = await safeFileStat(filePath);
|
|
172
192
|
if (!fileStat)
|
|
173
193
|
return;
|
|
194
|
+
// Skip files not modified within the recency window
|
|
195
|
+
if (Date.now() - fileStat.mtimeMs > MAX_FILE_AGE_MS)
|
|
196
|
+
return;
|
|
174
197
|
const currentOffset = agentOffsets.get(filePath) ?? 0;
|
|
175
198
|
const fileSize = fileStat.size;
|
|
176
199
|
if (fileSize <= currentOffset)
|
|
@@ -211,12 +234,20 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
|
211
234
|
slug = agentName;
|
|
212
235
|
}
|
|
213
236
|
else {
|
|
214
|
-
//
|
|
237
|
+
// Check if this session belongs to a team lead
|
|
215
238
|
const sessionId = parsed.sessionId ?? '';
|
|
216
239
|
if (!sessionId)
|
|
217
240
|
continue;
|
|
218
|
-
|
|
219
|
-
|
|
241
|
+
const teamInfo = teamLeadSessions.get(sessionId);
|
|
242
|
+
if (teamInfo) {
|
|
243
|
+
fullAgentId = `team-lead@${teamInfo.teamName}`;
|
|
244
|
+
slug = 'team-lead';
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// Regular conversation: use sessionId as agentId
|
|
248
|
+
fullAgentId = `session:${sessionId}`;
|
|
249
|
+
slug = parsed.slug || sessionId.slice(0, 8);
|
|
250
|
+
}
|
|
220
251
|
}
|
|
221
252
|
const entry = {
|
|
222
253
|
agentId: fullAgentId,
|
|
@@ -241,6 +272,13 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
|
241
272
|
agentEntries.set(fullAgentId, arr);
|
|
242
273
|
}
|
|
243
274
|
arr.push(entry);
|
|
275
|
+
changedAgentIds.add(fullAgentId);
|
|
276
|
+
let buf = newEntriesBuffer.get(fullAgentId);
|
|
277
|
+
if (!buf) {
|
|
278
|
+
buf = [];
|
|
279
|
+
newEntriesBuffer.set(fullAgentId, buf);
|
|
280
|
+
}
|
|
281
|
+
buf.push(entry);
|
|
244
282
|
if (arr.length > MAX_ENTRIES_PER_AGENT) {
|
|
245
283
|
arr.splice(0, arr.length - MAX_ENTRIES_PER_AGENT);
|
|
246
284
|
}
|
|
@@ -252,10 +290,51 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
|
252
290
|
}
|
|
253
291
|
continue;
|
|
254
292
|
}
|
|
255
|
-
//
|
|
293
|
+
// Skip non-message entries for subagent files too
|
|
294
|
+
if (!parsed.type || (parsed.type !== 'user' && parsed.type !== 'assistant'))
|
|
295
|
+
continue;
|
|
296
|
+
// Subagent JSONL: resolve team member agentId if possible
|
|
297
|
+
let resolvedAgentId = parsed.agentId ?? '';
|
|
298
|
+
let resolvedSlug = parsed.slug ?? '';
|
|
299
|
+
// Try to resolve subagent hash -> team member agentId
|
|
300
|
+
if (resolvedAgentId && parentSessionId) {
|
|
301
|
+
const cached = subagentToMember.get(filePath);
|
|
302
|
+
if (cached) {
|
|
303
|
+
// Successfully matched to a team member
|
|
304
|
+
resolvedSlug = cached.split('@')[0];
|
|
305
|
+
resolvedAgentId = cached;
|
|
306
|
+
}
|
|
307
|
+
else if (cached === undefined) {
|
|
308
|
+
// Not yet checked — try matching
|
|
309
|
+
// Try to match user message to team member prompt
|
|
310
|
+
const teamInfo = teamLeadSessions.get(parentSessionId);
|
|
311
|
+
if (teamInfo) {
|
|
312
|
+
if (parsed.type === 'user') {
|
|
313
|
+
const msgContent = Array.isArray(parsed.message?.content)
|
|
314
|
+
? parsed.message.content.map((c) => c.text ?? '').join('')
|
|
315
|
+
: String(parsed.message?.content ?? '');
|
|
316
|
+
let matched = false;
|
|
317
|
+
for (const member of teamInfo.members) {
|
|
318
|
+
if (member.prompt && msgContent.includes(member.prompt.slice(0, 100))) {
|
|
319
|
+
subagentToMember.set(filePath, member.agentId);
|
|
320
|
+
resolvedAgentId = member.agentId;
|
|
321
|
+
resolvedSlug = member.name;
|
|
322
|
+
matched = true;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// No match: mark as checked so we don't retry, but keep original hash agentId
|
|
327
|
+
if (!matched) {
|
|
328
|
+
subagentToMember.set(filePath, '');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// For assistant entries before we've seen a user entry, don't set fallback yet
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
256
335
|
const entry = {
|
|
257
|
-
agentId:
|
|
258
|
-
slug:
|
|
336
|
+
agentId: resolvedAgentId,
|
|
337
|
+
slug: resolvedSlug,
|
|
259
338
|
sessionId: parsed.sessionId ?? '',
|
|
260
339
|
type: parsed.type ?? 'assistant',
|
|
261
340
|
message: {
|
|
@@ -278,6 +357,13 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
|
278
357
|
agentEntries.set(entry.agentId, arr);
|
|
279
358
|
}
|
|
280
359
|
arr.push(entry);
|
|
360
|
+
changedAgentIds.add(entry.agentId);
|
|
361
|
+
let buf = newEntriesBuffer.get(entry.agentId);
|
|
362
|
+
if (!buf) {
|
|
363
|
+
buf = [];
|
|
364
|
+
newEntriesBuffer.set(entry.agentId, buf);
|
|
365
|
+
}
|
|
366
|
+
buf.push(entry);
|
|
281
367
|
if (arr.length > MAX_ENTRIES_PER_AGENT) {
|
|
282
368
|
arr.splice(0, arr.length - MAX_ENTRIES_PER_AGENT);
|
|
283
369
|
}
|
|
@@ -289,7 +375,9 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
|
|
|
289
375
|
}
|
|
290
376
|
// --- Snapshot assembly ---
|
|
291
377
|
function buildTeamOverview(teamName) {
|
|
292
|
-
const
|
|
378
|
+
const stored = teams.get(teamName) ?? { name: teamName, members: [] };
|
|
379
|
+
// Clone so we can add dynamic members without mutating the cache
|
|
380
|
+
const config = { ...stored, members: [...stored.members] };
|
|
293
381
|
const teamTasks = tasks.get(teamName) ?? [];
|
|
294
382
|
const taskStats = {
|
|
295
383
|
total: teamTasks.length,
|
|
@@ -299,6 +387,7 @@ function buildTeamOverview(teamName) {
|
|
|
299
387
|
};
|
|
300
388
|
// Build agentSlugs: map agentId -> slug from agentEntries
|
|
301
389
|
const agentSlugs = {};
|
|
390
|
+
const knownMemberIds = new Set(config.members.map(m => m.agentId));
|
|
302
391
|
for (const member of config.members) {
|
|
303
392
|
// Look for agent entries matching this member's agentId
|
|
304
393
|
const entries = agentEntries.get(member.agentId);
|
|
@@ -480,9 +569,45 @@ export function getSnapshot() {
|
|
|
480
569
|
}
|
|
481
570
|
return { teams: teamOverviews, unmatchedAgents, agentActivity: activity, projects: buildProjectOverviews() };
|
|
482
571
|
}
|
|
572
|
+
export function getLeanSnapshot() {
|
|
573
|
+
const teamOverviews = [];
|
|
574
|
+
const matchedAgentIds = new Set();
|
|
575
|
+
for (const teamName of teams.keys()) {
|
|
576
|
+
const overview = buildTeamOverview(teamName);
|
|
577
|
+
teamOverviews.push(overview);
|
|
578
|
+
for (const member of overview.config.members) {
|
|
579
|
+
matchedAgentIds.add(member.agentId);
|
|
580
|
+
matchedAgentIds.add(member.agentId.split('@')[0]);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const unmatchedAgents = [];
|
|
584
|
+
for (const [agentId, entries] of agentEntries) {
|
|
585
|
+
if (!matchedAgentIds.has(agentId) && entries.length > 0) {
|
|
586
|
+
const last = entries[entries.length - 1];
|
|
587
|
+
unmatchedAgents.push({ agentId, slug: last.slug, sessionId: last.sessionId });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return { teams: teamOverviews, unmatchedAgents, projects: buildProjectOverviews() };
|
|
591
|
+
}
|
|
592
|
+
export function getAndClearNewEntries() {
|
|
593
|
+
const result = new Map(newEntriesBuffer);
|
|
594
|
+
changedAgentIds.clear();
|
|
595
|
+
newEntriesBuffer.clear();
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
483
598
|
// --- Query ---
|
|
484
|
-
export function getAgentActivity(agentId) {
|
|
485
|
-
|
|
599
|
+
export function getAgentActivity(agentId, limit, offset) {
|
|
600
|
+
const entries = agentEntries.get(agentId) ?? [];
|
|
601
|
+
if (offset !== undefined && limit !== undefined) {
|
|
602
|
+
// offset counts from the end: offset=0 means latest `limit` entries
|
|
603
|
+
const end = entries.length - offset;
|
|
604
|
+
const start = Math.max(0, end - limit);
|
|
605
|
+
return entries.slice(start, Math.max(start, end));
|
|
606
|
+
}
|
|
607
|
+
if (limit !== undefined) {
|
|
608
|
+
return entries.slice(-limit);
|
|
609
|
+
}
|
|
610
|
+
return entries;
|
|
486
611
|
}
|
|
487
612
|
export function getAgentSessions(agentId) {
|
|
488
613
|
const entries = agentEntries.get(agentId);
|
|
@@ -32,7 +32,7 @@ function startTasksWatcher() {
|
|
|
32
32
|
const onTaskChange = debounce(async (_event, filename) => {
|
|
33
33
|
if (!filename) {
|
|
34
34
|
// Full refresh if no filename
|
|
35
|
-
for (const team of (await import('./teamsCache.js')).
|
|
35
|
+
for (const team of (await import('./teamsCache.js')).getLeanSnapshot().teams) {
|
|
36
36
|
await cache.refreshTasks(team.config.name);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -65,7 +65,7 @@ function startTeamsWatcher() {
|
|
|
65
65
|
const onTeamChange = debounce(async () => {
|
|
66
66
|
await cache.refreshTeams();
|
|
67
67
|
// Also refresh tasks in case new team appeared
|
|
68
|
-
for (const team of cache.
|
|
68
|
+
for (const team of cache.getLeanSnapshot().teams) {
|
|
69
69
|
await cache.refreshTasks(team.config.name);
|
|
70
70
|
}
|
|
71
71
|
cache.onChange.emit('change');
|
|
@@ -102,10 +102,10 @@ function startFullRefreshPoller() {
|
|
|
102
102
|
let lastSnapshot = '';
|
|
103
103
|
fullRefreshTimer = setInterval(async () => {
|
|
104
104
|
await cache.refreshTeams();
|
|
105
|
-
for (const team of cache.
|
|
105
|
+
for (const team of cache.getLeanSnapshot().teams) {
|
|
106
106
|
await cache.refreshTasks(team.config.name);
|
|
107
107
|
}
|
|
108
|
-
const snapshot = JSON.stringify(cache.
|
|
108
|
+
const snapshot = JSON.stringify(cache.getLeanSnapshot());
|
|
109
109
|
if (snapshot !== lastSnapshot) {
|
|
110
110
|
lastSnapshot = snapshot;
|
|
111
111
|
cache.onChange.emit('change');
|
|
@@ -117,7 +117,7 @@ function startJsonlPoller() {
|
|
|
117
117
|
let lastSnapshot = '';
|
|
118
118
|
jsonlTimer = setInterval(async () => {
|
|
119
119
|
await cache.scanAgentJsonl();
|
|
120
|
-
const snapshot = JSON.stringify(cache.
|
|
120
|
+
const snapshot = JSON.stringify(cache.getLeanSnapshot());
|
|
121
121
|
if (snapshot !== lastSnapshot) {
|
|
122
122
|
lastSnapshot = snapshot;
|
|
123
123
|
cache.onChange.emit('change');
|
|
@@ -20,8 +20,8 @@ export function initWebSocket(server) {
|
|
|
20
20
|
ws.isAlive = true;
|
|
21
21
|
const clientCount = wss.clients.size;
|
|
22
22
|
console.log(`[ws] Client connected (total: ${clientCount})`);
|
|
23
|
-
// Send initial snapshot
|
|
24
|
-
const snapshot = cache.
|
|
23
|
+
// Send initial lean snapshot (no agentActivity)
|
|
24
|
+
const snapshot = cache.getLeanSnapshot();
|
|
25
25
|
ws.send(JSON.stringify({ type: 'snapshot', data: snapshot }));
|
|
26
26
|
ws.on('pong', () => {
|
|
27
27
|
ws.isAlive = true;
|
|
@@ -54,9 +54,16 @@ export function initWebSocket(server) {
|
|
|
54
54
|
wss.on('close', () => {
|
|
55
55
|
clearInterval(heartbeat);
|
|
56
56
|
});
|
|
57
|
-
// Listen for cache changes and broadcast
|
|
57
|
+
// Listen for cache changes and broadcast lean snapshot + deltas
|
|
58
58
|
cache.onChange.on('change', () => {
|
|
59
|
-
const snap = cache.
|
|
59
|
+
const snap = cache.getLeanSnapshot();
|
|
60
60
|
broadcast({ type: 'snapshot', data: snap });
|
|
61
|
+
// Broadcast only new entries as deltas
|
|
62
|
+
const newEntries = cache.getAndClearNewEntries();
|
|
63
|
+
for (const [agentId, entries] of newEntries) {
|
|
64
|
+
if (entries.length > 0) {
|
|
65
|
+
broadcast({ type: 'agent_entries_delta', agentId, entries });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
61
68
|
});
|
|
62
69
|
}
|