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/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-CEXrdfKk.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-teams-dashboard",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Real-time monitoring dashboard for Claude Code agent teams",
5
5
  "type": "module",
6
6
  "author": "pingshian0131",
@@ -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.getSnapshot());
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.getSnapshot();
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.getSnapshot();
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.getSnapshot();
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
- if (limitParam) {
75
- const limit = parseInt(limitParam, 10);
76
- if (!isNaN(limit) && limit > 0) {
77
- entries = entries.slice(-limit);
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
- // Regular conversation: use sessionId as agentId
237
+ // Check if this session belongs to a team lead
215
238
  const sessionId = parsed.sessionId ?? '';
216
239
  if (!sessionId)
217
240
  continue;
218
- fullAgentId = `session:${sessionId}`;
219
- slug = parsed.slug || sessionId.slice(0, 8);
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
- // Subagent JSONL: use agentId from the file
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: parsed.agentId ?? '',
258
- slug: parsed.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 config = teams.get(teamName) ?? { name: teamName, members: [] };
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
- return agentEntries.get(agentId) ?? [];
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')).getSnapshot().teams) {
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.getSnapshot().teams) {
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.getSnapshot().teams) {
105
+ for (const team of cache.getLeanSnapshot().teams) {
106
106
  await cache.refreshTasks(team.config.name);
107
107
  }
108
- const snapshot = JSON.stringify(cache.getSnapshot());
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.getSnapshot());
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.getSnapshot();
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 full snapshot
57
+ // Listen for cache changes and broadcast lean snapshot + deltas
58
58
  cache.onChange.on('change', () => {
59
- const snap = cache.getSnapshot();
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
  }