agent-teams-dashboard 0.1.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.
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Agent Teams Dashboard</title>
7
+ <script type="module" crossorigin src="/assets/index-DEOAkXJf.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BwUrrF1R.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "agent-teams-dashboard",
3
+ "version": "0.1.0",
4
+ "description": "Real-time monitoring dashboard for Claude Code agent teams",
5
+ "type": "module",
6
+ "author": "pingshian0131",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pingshian0131/agent-teams-dashboard.git"
11
+ },
12
+ "homepage": "https://github.com/pingshian0131/agent-teams-dashboard",
13
+ "keywords": [
14
+ "claude-code",
15
+ "agent",
16
+ "teams",
17
+ "dashboard",
18
+ "monitoring",
19
+ "websocket"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20.11.0"
23
+ },
24
+ "bin": {
25
+ "agent-teams-dashboard": "bin/agent-teams-dashboard.js"
26
+ },
27
+ "files": [
28
+ "dist/",
29
+ "server-dist/",
30
+ "bin/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "dev": "vite",
36
+ "build": "vite build",
37
+ "build:server": "tsc -p tsconfig.build-server.json",
38
+ "server": "tsx server/index.ts",
39
+ "preview": "vite preview",
40
+ "prepublishOnly": "npm run build && npm run build:server"
41
+ },
42
+ "dependencies": {
43
+ "react": "^19",
44
+ "react-dom": "^19",
45
+ "ws": "^8"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22",
49
+ "@types/react": "^19",
50
+ "@types/react-dom": "^19",
51
+ "@types/ws": "^8",
52
+ "@vitejs/plugin-react": "^4",
53
+ "tsx": "^4",
54
+ "typescript": "^5",
55
+ "vite": "^6"
56
+ }
57
+ }
@@ -0,0 +1,96 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, extname } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+ import { handleTeamsApi } from './teamsApi.js';
6
+ import { initWebSocket } from './wsServer.js';
7
+ import { startWatching } from './teamsWatcher.js';
8
+ import * as cache from './teamsCache.js';
9
+ const PORT = Number(process.env.PORT ?? 3001);
10
+ const DIST_DIR = process.env.DIST_DIR ?? join(import.meta.dirname, '..', 'dist');
11
+ const MIME_TYPES = {
12
+ '.html': 'text/html',
13
+ '.js': 'application/javascript',
14
+ '.css': 'text/css',
15
+ '.json': 'application/json',
16
+ '.png': 'image/png',
17
+ '.svg': 'image/svg+xml',
18
+ '.ico': 'image/x-icon',
19
+ '.woff': 'font/woff',
20
+ '.woff2': 'font/woff2',
21
+ };
22
+ async function serveStatic(url, res) {
23
+ if (!existsSync(DIST_DIR))
24
+ return false;
25
+ let filePath = join(DIST_DIR, url === '/' ? 'index.html' : url);
26
+ // SPA fallback: if file doesn't exist and it's not an API/asset route, serve index.html
27
+ if (!existsSync(filePath)) {
28
+ const ext = extname(filePath);
29
+ if (!ext) {
30
+ filePath = join(DIST_DIR, 'index.html');
31
+ }
32
+ else {
33
+ return false;
34
+ }
35
+ }
36
+ try {
37
+ const data = await readFile(filePath);
38
+ const ext = extname(filePath);
39
+ const mime = MIME_TYPES[ext] ?? 'application/octet-stream';
40
+ res.writeHead(200, { 'Content-Type': mime });
41
+ res.end(data);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ const server = createServer(async (req, res) => {
49
+ const url = req.url ?? '/';
50
+ // CORS headers for dev
51
+ res.setHeader('Access-Control-Allow-Origin', '*');
52
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
53
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
54
+ if (req.method === 'OPTIONS') {
55
+ res.writeHead(204);
56
+ res.end();
57
+ return;
58
+ }
59
+ // API routes
60
+ try {
61
+ const handled = await handleTeamsApi(req, res);
62
+ if (handled)
63
+ return;
64
+ }
65
+ catch (err) {
66
+ console.error('[api] Error handling request:', err);
67
+ res.writeHead(500, { 'Content-Type': 'application/json' });
68
+ res.end(JSON.stringify({ error: 'Internal server error' }));
69
+ return;
70
+ }
71
+ // Static file serving (production)
72
+ const served = await serveStatic(url, res);
73
+ if (served)
74
+ return;
75
+ // 404
76
+ res.writeHead(404, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({ error: 'Not found' }));
78
+ });
79
+ async function start() {
80
+ // Initialize cache
81
+ await cache.refreshAll();
82
+ console.log(`[cache] Initial load complete`);
83
+ // Start HTTP server
84
+ server.listen(PORT, () => {
85
+ console.log(`[server] Listening on http://localhost:${PORT}`);
86
+ });
87
+ // Attach WebSocket
88
+ initWebSocket(server);
89
+ // Start file watchers
90
+ startWatching();
91
+ console.log('[watcher] File watchers started');
92
+ }
93
+ start().catch((err) => {
94
+ console.error('[server] Failed to start:', err);
95
+ process.exit(1);
96
+ });
@@ -0,0 +1,68 @@
1
+ import * as cache from './teamsCache.js';
2
+ function json(res, data, status = 200) {
3
+ res.writeHead(status, { 'Content-Type': 'application/json' });
4
+ res.end(JSON.stringify(data));
5
+ }
6
+ function notFound(res) {
7
+ json(res, { error: 'Not found' }, 404);
8
+ }
9
+ export async function handleTeamsApi(req, res) {
10
+ if (!req.url?.startsWith('/api/'))
11
+ return false;
12
+ const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
13
+ const path = url.pathname;
14
+ // GET /api/snapshot
15
+ if (path === '/api/snapshot') {
16
+ json(res, cache.getSnapshot());
17
+ return true;
18
+ }
19
+ // GET /api/teams
20
+ if (path === '/api/teams') {
21
+ const snapshot = cache.getSnapshot();
22
+ json(res, snapshot.teams);
23
+ return true;
24
+ }
25
+ // GET /api/teams/:id/tasks
26
+ const tasksMatch = path.match(/^\/api\/teams\/([^/]+)\/tasks$/);
27
+ if (tasksMatch) {
28
+ const teamId = decodeURIComponent(tasksMatch[1]);
29
+ const snapshot = cache.getSnapshot();
30
+ const team = snapshot.teams.find((t) => t.config.name === teamId);
31
+ if (!team) {
32
+ notFound(res);
33
+ return true;
34
+ }
35
+ json(res, team.tasks);
36
+ return true;
37
+ }
38
+ // GET /api/teams/:id
39
+ const teamMatch = path.match(/^\/api\/teams\/([^/]+)$/);
40
+ if (teamMatch) {
41
+ const teamId = decodeURIComponent(teamMatch[1]);
42
+ const snapshot = cache.getSnapshot();
43
+ const team = snapshot.teams.find((t) => t.config.name === teamId);
44
+ if (!team) {
45
+ notFound(res);
46
+ return true;
47
+ }
48
+ json(res, team);
49
+ return true;
50
+ }
51
+ // GET /api/agents/:agentId/activity
52
+ const agentMatch = path.match(/^\/api\/agents\/([^/]+)\/activity$/);
53
+ if (agentMatch) {
54
+ const agentId = decodeURIComponent(agentMatch[1]);
55
+ let entries = cache.getAgentActivity(agentId);
56
+ const limitParam = url.searchParams.get('limit');
57
+ if (limitParam) {
58
+ const limit = parseInt(limitParam, 10);
59
+ if (!isNaN(limit) && limit > 0) {
60
+ entries = entries.slice(-limit);
61
+ }
62
+ }
63
+ json(res, entries);
64
+ return true;
65
+ }
66
+ notFound(res);
67
+ return true;
68
+ }
@@ -0,0 +1,291 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { EventEmitter } from 'node:events';
5
+ const CLAUDE_DIR = join(homedir(), '.claude');
6
+ const TEAMS_DIR = join(CLAUDE_DIR, 'teams');
7
+ const TASKS_DIR = join(CLAUDE_DIR, 'tasks');
8
+ const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
9
+ const MAX_ENTRIES_PER_AGENT = 200;
10
+ // In-memory caches
11
+ const teams = new Map();
12
+ const tasks = new Map();
13
+ const agentEntries = new Map();
14
+ const agentOffsets = new Map();
15
+ const teamFileMtimes = new Map(); // team name -> latest mtime (ms)
16
+ export const onChange = new EventEmitter();
17
+ // --- Helpers ---
18
+ async function safeReaddir(dir) {
19
+ try {
20
+ return await readdir(dir);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ async function safeReadFile(path) {
27
+ try {
28
+ return await readFile(path, 'utf-8');
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ async function safeFileStat(path) {
35
+ try {
36
+ return await stat(path);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ // --- Teams ---
43
+ export async function refreshTeams() {
44
+ const teamDirs = await safeReaddir(TEAMS_DIR);
45
+ const currentNames = new Set();
46
+ for (const dir of teamDirs) {
47
+ const configPath = join(TEAMS_DIR, dir, 'config.json');
48
+ const raw = await safeReadFile(configPath);
49
+ if (!raw)
50
+ continue;
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ const config = {
54
+ name: parsed.name ?? dir,
55
+ members: Array.isArray(parsed.members)
56
+ ? parsed.members.map((m) => ({
57
+ name: m.name ?? '',
58
+ agentId: m.agentId ?? '',
59
+ agentType: m.agentType ?? '',
60
+ }))
61
+ : [],
62
+ };
63
+ teams.set(config.name, config);
64
+ currentNames.add(config.name);
65
+ // Track config file mtime
66
+ const configStat = await safeFileStat(configPath);
67
+ if (configStat) {
68
+ const prev = teamFileMtimes.get(config.name) ?? 0;
69
+ if (configStat.mtimeMs > prev) {
70
+ teamFileMtimes.set(config.name, configStat.mtimeMs);
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // skip malformed config
76
+ }
77
+ }
78
+ // Remove teams that no longer exist on disk
79
+ for (const name of teams.keys()) {
80
+ if (!currentNames.has(name)) {
81
+ teams.delete(name);
82
+ teamFileMtimes.delete(name);
83
+ }
84
+ }
85
+ }
86
+ // --- Tasks ---
87
+ export async function refreshTasks(teamId) {
88
+ const taskDir = join(TASKS_DIR, teamId);
89
+ const files = await safeReaddir(taskDir);
90
+ const teamTasks = [];
91
+ for (const file of files) {
92
+ if (!file.endsWith('.json'))
93
+ continue;
94
+ const filePath = join(taskDir, file);
95
+ const raw = await safeReadFile(filePath);
96
+ if (!raw)
97
+ continue;
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ teamTasks.push({
101
+ id: String(parsed.id ?? ''),
102
+ subject: parsed.subject ?? '',
103
+ description: parsed.description ?? '',
104
+ activeForm: parsed.activeForm ?? '',
105
+ status: parsed.status ?? 'pending',
106
+ blocks: Array.isArray(parsed.blocks) ? parsed.blocks : [],
107
+ blockedBy: Array.isArray(parsed.blockedBy) ? parsed.blockedBy : [],
108
+ owner: parsed.owner,
109
+ });
110
+ // Track task file mtime for lastActivity
111
+ const taskStat = await safeFileStat(filePath);
112
+ if (taskStat) {
113
+ const prev = teamFileMtimes.get(teamId) ?? 0;
114
+ if (taskStat.mtimeMs > prev) {
115
+ teamFileMtimes.set(teamId, taskStat.mtimeMs);
116
+ }
117
+ }
118
+ }
119
+ catch {
120
+ // skip malformed task
121
+ }
122
+ }
123
+ // Sort by numeric id
124
+ teamTasks.sort((a, b) => Number(a.id) - Number(b.id));
125
+ tasks.set(teamId, teamTasks);
126
+ }
127
+ async function refreshAllTasks() {
128
+ // Refresh tasks for known teams
129
+ for (const name of teams.keys()) {
130
+ await refreshTasks(name);
131
+ }
132
+ // Also check tasks dir for any team dirs not yet in teams map
133
+ const taskDirs = await safeReaddir(TASKS_DIR);
134
+ for (const dir of taskDirs) {
135
+ if (!tasks.has(dir)) {
136
+ await refreshTasks(dir);
137
+ }
138
+ }
139
+ }
140
+ // --- Agent JSONL scanning ---
141
+ export async function scanAgentJsonl() {
142
+ const projectDirs = await safeReaddir(PROJECTS_DIR);
143
+ for (const projDir of projectDirs) {
144
+ const projPath = join(PROJECTS_DIR, projDir);
145
+ const sessionDirs = await safeReaddir(projPath);
146
+ for (const sessionDir of sessionDirs) {
147
+ const subagentsDir = join(projPath, sessionDir, 'subagents');
148
+ const files = await safeReaddir(subagentsDir);
149
+ for (const file of files) {
150
+ if (!file.startsWith('agent-') || !file.endsWith('.jsonl'))
151
+ continue;
152
+ const filePath = join(subagentsDir, file);
153
+ await readNewEntries(filePath);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ async function readNewEntries(filePath) {
159
+ const fileStat = await safeFileStat(filePath);
160
+ if (!fileStat)
161
+ return;
162
+ const currentOffset = agentOffsets.get(filePath) ?? 0;
163
+ const fileSize = fileStat.size;
164
+ if (fileSize <= currentOffset)
165
+ return;
166
+ const raw = await safeReadFile(filePath);
167
+ if (!raw)
168
+ return;
169
+ // Read only from the offset position
170
+ const newContent = raw.slice(currentOffset);
171
+ agentOffsets.set(filePath, fileSize);
172
+ const lines = newContent.split('\n').filter(Boolean);
173
+ for (const line of lines) {
174
+ try {
175
+ const parsed = JSON.parse(line);
176
+ const entry = {
177
+ agentId: parsed.agentId ?? '',
178
+ slug: parsed.slug ?? '',
179
+ sessionId: parsed.sessionId ?? '',
180
+ type: parsed.type ?? 'assistant',
181
+ message: {
182
+ role: parsed.message?.role ?? '',
183
+ content: Array.isArray(parsed.message?.content)
184
+ ? parsed.message.content
185
+ : typeof parsed.message?.content === 'string'
186
+ ? [{ type: 'text', text: parsed.message.content }]
187
+ : [],
188
+ model: parsed.message?.model,
189
+ },
190
+ timestamp: parsed.timestamp ?? '',
191
+ };
192
+ if (!entry.agentId)
193
+ continue;
194
+ let entries = agentEntries.get(entry.agentId);
195
+ if (!entries) {
196
+ entries = [];
197
+ agentEntries.set(entry.agentId, entries);
198
+ }
199
+ entries.push(entry);
200
+ // Trim to max entries
201
+ if (entries.length > MAX_ENTRIES_PER_AGENT) {
202
+ const excess = entries.length - MAX_ENTRIES_PER_AGENT;
203
+ entries.splice(0, excess);
204
+ }
205
+ }
206
+ catch {
207
+ // skip malformed line
208
+ }
209
+ }
210
+ }
211
+ // --- Snapshot assembly ---
212
+ function buildTeamOverview(teamName) {
213
+ const config = teams.get(teamName) ?? { name: teamName, members: [] };
214
+ const teamTasks = tasks.get(teamName) ?? [];
215
+ const taskStats = {
216
+ total: teamTasks.length,
217
+ pending: teamTasks.filter(t => t.status === 'pending').length,
218
+ inProgress: teamTasks.filter(t => t.status === 'in_progress').length,
219
+ completed: teamTasks.filter(t => t.status === 'completed').length,
220
+ };
221
+ // Build agentSlugs: map agentId -> slug from agentEntries
222
+ const agentSlugs = {};
223
+ for (const member of config.members) {
224
+ // Look for agent entries matching this member's agentId
225
+ const entries = agentEntries.get(member.agentId);
226
+ if (entries && entries.length > 0) {
227
+ agentSlugs[member.agentId] = entries[entries.length - 1].slug;
228
+ }
229
+ // Also try short agentId (before @)
230
+ const shortId = member.agentId.split('@')[0];
231
+ if (shortId !== member.agentId) {
232
+ const shortEntries = agentEntries.get(shortId);
233
+ if (shortEntries && shortEntries.length > 0) {
234
+ agentSlugs[member.agentId] = shortEntries[shortEntries.length - 1].slug;
235
+ }
236
+ }
237
+ }
238
+ // Find last activity timestamp from agent JSONL entries
239
+ let lastActivity = '';
240
+ for (const member of config.members) {
241
+ const entries = agentEntries.get(member.agentId) ?? agentEntries.get(member.agentId.split('@')[0]);
242
+ if (entries && entries.length > 0) {
243
+ const ts = entries[entries.length - 1].timestamp;
244
+ if (ts > lastActivity)
245
+ lastActivity = ts;
246
+ }
247
+ }
248
+ // Fallback: use file modification times (config + task files)
249
+ if (!lastActivity) {
250
+ const mtime = teamFileMtimes.get(teamName);
251
+ if (mtime) {
252
+ lastActivity = new Date(mtime).toISOString();
253
+ }
254
+ }
255
+ return { config, tasks: teamTasks, taskStats, agentSlugs, lastActivity };
256
+ }
257
+ export function getSnapshot() {
258
+ const teamOverviews = [];
259
+ const matchedAgentIds = new Set();
260
+ for (const teamName of teams.keys()) {
261
+ const overview = buildTeamOverview(teamName);
262
+ teamOverviews.push(overview);
263
+ for (const member of overview.config.members) {
264
+ matchedAgentIds.add(member.agentId);
265
+ matchedAgentIds.add(member.agentId.split('@')[0]);
266
+ }
267
+ }
268
+ // Find unmatched agents
269
+ const unmatchedAgents = [];
270
+ for (const [agentId, entries] of agentEntries) {
271
+ if (!matchedAgentIds.has(agentId) && entries.length > 0) {
272
+ const last = entries[entries.length - 1];
273
+ unmatchedAgents.push({
274
+ agentId,
275
+ slug: last.slug,
276
+ sessionId: last.sessionId,
277
+ });
278
+ }
279
+ }
280
+ return { teams: teamOverviews, unmatchedAgents };
281
+ }
282
+ // --- Query ---
283
+ export function getAgentActivity(agentId) {
284
+ return agentEntries.get(agentId) ?? [];
285
+ }
286
+ // --- Full refresh ---
287
+ export async function refreshAll() {
288
+ await refreshTeams();
289
+ await refreshAllTasks();
290
+ await scanAgentJsonl();
291
+ }
@@ -0,0 +1,128 @@
1
+ import { watch, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import * as cache from './teamsCache.js';
5
+ const CLAUDE_DIR = join(homedir(), '.claude');
6
+ const TEAMS_DIR = join(CLAUDE_DIR, 'teams');
7
+ const TASKS_DIR = join(CLAUDE_DIR, 'tasks');
8
+ const DEBOUNCE_MS = 200;
9
+ const JSONL_POLL_MS = 2000;
10
+ const DIR_CHECK_MS = 5000;
11
+ let tasksWatcher = null;
12
+ let teamsWatcher = null;
13
+ let jsonlTimer = null;
14
+ let dirCheckTimer = null;
15
+ // --- Debounce helper ---
16
+ function debounce(fn, ms) {
17
+ let timer = null;
18
+ return ((...args) => {
19
+ if (timer)
20
+ clearTimeout(timer);
21
+ timer = setTimeout(() => fn(...args), ms);
22
+ });
23
+ }
24
+ // --- Tasks watcher ---
25
+ function startTasksWatcher() {
26
+ if (tasksWatcher)
27
+ return;
28
+ if (!existsSync(TASKS_DIR))
29
+ return;
30
+ const onTaskChange = debounce(async (_event, filename) => {
31
+ if (!filename) {
32
+ // Full refresh if no filename
33
+ for (const team of (await import('./teamsCache.js')).getSnapshot().teams) {
34
+ await cache.refreshTasks(team.config.name);
35
+ }
36
+ }
37
+ else {
38
+ // filename could be "teamName/1.json" or just "teamName"
39
+ const teamId = filename.split('/')[0];
40
+ if (teamId) {
41
+ await cache.refreshTasks(teamId);
42
+ }
43
+ }
44
+ cache.onChange.emit('change');
45
+ }, DEBOUNCE_MS);
46
+ try {
47
+ tasksWatcher = watch(TASKS_DIR, { recursive: true }, onTaskChange);
48
+ tasksWatcher.on('error', () => {
49
+ tasksWatcher?.close();
50
+ tasksWatcher = null;
51
+ });
52
+ }
53
+ catch {
54
+ // watch not supported or dir not accessible
55
+ }
56
+ }
57
+ // --- Teams watcher ---
58
+ function startTeamsWatcher() {
59
+ if (teamsWatcher)
60
+ return;
61
+ if (!existsSync(TEAMS_DIR))
62
+ return;
63
+ const onTeamChange = debounce(async () => {
64
+ await cache.refreshTeams();
65
+ // Also refresh tasks in case new team appeared
66
+ for (const team of cache.getSnapshot().teams) {
67
+ await cache.refreshTasks(team.config.name);
68
+ }
69
+ cache.onChange.emit('change');
70
+ }, DEBOUNCE_MS);
71
+ try {
72
+ teamsWatcher = watch(TEAMS_DIR, { recursive: true }, onTeamChange);
73
+ teamsWatcher.on('error', () => {
74
+ teamsWatcher?.close();
75
+ teamsWatcher = null;
76
+ });
77
+ }
78
+ catch {
79
+ // watch not supported
80
+ }
81
+ }
82
+ // --- Directory existence poller (for dirs that may not yet exist) ---
83
+ function startDirCheckPoller() {
84
+ dirCheckTimer = setInterval(() => {
85
+ if (!tasksWatcher && existsSync(TASKS_DIR)) {
86
+ startTasksWatcher();
87
+ }
88
+ if (!teamsWatcher && existsSync(TEAMS_DIR)) {
89
+ startTeamsWatcher();
90
+ }
91
+ // Stop polling once both watchers are up
92
+ if (tasksWatcher && teamsWatcher && dirCheckTimer) {
93
+ clearInterval(dirCheckTimer);
94
+ dirCheckTimer = null;
95
+ }
96
+ }, DIR_CHECK_MS);
97
+ }
98
+ // --- Agent JSONL poller ---
99
+ function startJsonlPoller() {
100
+ jsonlTimer = setInterval(async () => {
101
+ await cache.scanAgentJsonl();
102
+ cache.onChange.emit('change');
103
+ }, JSONL_POLL_MS);
104
+ }
105
+ // --- Public API ---
106
+ export function startWatching() {
107
+ startTasksWatcher();
108
+ startTeamsWatcher();
109
+ startJsonlPoller();
110
+ // If either dir doesn't exist yet, poll for them
111
+ if (!tasksWatcher || !teamsWatcher) {
112
+ startDirCheckPoller();
113
+ }
114
+ }
115
+ export function stopWatching() {
116
+ tasksWatcher?.close();
117
+ tasksWatcher = null;
118
+ teamsWatcher?.close();
119
+ teamsWatcher = null;
120
+ if (jsonlTimer) {
121
+ clearInterval(jsonlTimer);
122
+ jsonlTimer = null;
123
+ }
124
+ if (dirCheckTimer) {
125
+ clearInterval(dirCheckTimer);
126
+ dirCheckTimer = null;
127
+ }
128
+ }