agent-teams-dashboard 0.3.1 → 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-B8t0Tabx.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.1",
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
  }
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
1
+ import { readdir, readFile, stat, open } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { EventEmitter } from 'node:events';
@@ -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,42 +178,80 @@ 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)
177
200
  return;
178
- const raw = await safeReadFile(filePath);
179
- if (!raw)
201
+ // Read only the new bytes from currentOffset to avoid byte/char offset mismatch
202
+ let newContent;
203
+ try {
204
+ const fh = await open(filePath, 'r');
205
+ try {
206
+ const buf = Buffer.alloc(fileSize - currentOffset);
207
+ await fh.read(buf, 0, buf.length, currentOffset);
208
+ newContent = buf.toString('utf-8');
209
+ }
210
+ finally {
211
+ await fh.close();
212
+ }
213
+ }
214
+ catch {
180
215
  return;
181
- const newContent = raw.slice(currentOffset);
216
+ }
182
217
  agentOffsets.set(filePath, fileSize);
183
218
  const lines = newContent.split('\n').filter(Boolean);
184
219
  for (const line of lines) {
185
220
  try {
186
221
  const parsed = JSON.parse(line);
187
- // For session files, only process entries that belong to a team
222
+ // For session files, process team entries AND regular conversation entries
188
223
  if (isSessionFile) {
189
224
  const teamName = parsed.teamName;
190
- if (!teamName)
225
+ // Skip non-message entries (file-history-snapshot, progress, queue-operation, system)
226
+ if (!parsed.type || (parsed.type !== 'user' && parsed.type !== 'assistant'))
191
227
  continue;
192
- const agentName = parsed.agentName || 'team-lead';
193
- // Use team agentId format: name@team
194
- const fullAgentId = `${agentName}@${teamName}`;
228
+ let fullAgentId;
229
+ let slug;
230
+ if (teamName) {
231
+ // Team session: use name@team format
232
+ const agentName = parsed.agentName || 'team-lead';
233
+ fullAgentId = `${agentName}@${teamName}`;
234
+ slug = agentName;
235
+ }
236
+ else {
237
+ // Check if this session belongs to a team lead
238
+ const sessionId = parsed.sessionId ?? '';
239
+ if (!sessionId)
240
+ continue;
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
+ }
251
+ }
195
252
  const entry = {
196
253
  agentId: fullAgentId,
197
- slug: agentName,
254
+ slug,
198
255
  sessionId: parsed.sessionId ?? '',
199
256
  type: parsed.type ?? 'assistant',
200
257
  message: {
@@ -215,15 +272,69 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
215
272
  agentEntries.set(fullAgentId, arr);
216
273
  }
217
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);
218
282
  if (arr.length > MAX_ENTRIES_PER_AGENT) {
219
283
  arr.splice(0, arr.length - MAX_ENTRIES_PER_AGENT);
220
284
  }
285
+ // Update slug from assistant entries (they carry the slug)
286
+ if (parsed.slug && arr.length > 0) {
287
+ for (const e of arr) {
288
+ e.slug = parsed.slug;
289
+ }
290
+ }
221
291
  continue;
222
292
  }
223
- // 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
+ }
224
335
  const entry = {
225
- agentId: parsed.agentId ?? '',
226
- slug: parsed.slug ?? '',
336
+ agentId: resolvedAgentId,
337
+ slug: resolvedSlug,
227
338
  sessionId: parsed.sessionId ?? '',
228
339
  type: parsed.type ?? 'assistant',
229
340
  message: {
@@ -246,6 +357,13 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
246
357
  agentEntries.set(entry.agentId, arr);
247
358
  }
248
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);
249
367
  if (arr.length > MAX_ENTRIES_PER_AGENT) {
250
368
  arr.splice(0, arr.length - MAX_ENTRIES_PER_AGENT);
251
369
  }
@@ -257,7 +375,9 @@ async function readNewEntries(filePath, isSessionFile, projectDir) {
257
375
  }
258
376
  // --- Snapshot assembly ---
259
377
  function buildTeamOverview(teamName) {
260
- 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] };
261
381
  const teamTasks = tasks.get(teamName) ?? [];
262
382
  const taskStats = {
263
383
  total: teamTasks.length,
@@ -267,6 +387,7 @@ function buildTeamOverview(teamName) {
267
387
  };
268
388
  // Build agentSlugs: map agentId -> slug from agentEntries
269
389
  const agentSlugs = {};
390
+ const knownMemberIds = new Set(config.members.map(m => m.agentId));
270
391
  for (const member of config.members) {
271
392
  // Look for agent entries matching this member's agentId
272
393
  const entries = agentEntries.get(member.agentId);
@@ -448,9 +569,45 @@ export function getSnapshot() {
448
569
  }
449
570
  return { teams: teamOverviews, unmatchedAgents, agentActivity: activity, projects: buildProjectOverviews() };
450
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
+ }
451
598
  // --- Query ---
452
- export function getAgentActivity(agentId) {
453
- 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;
454
611
  }
455
612
  export function getAgentSessions(agentId) {
456
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
  }