rufloui 0.3.1

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.
Files changed (56) hide show
  1. package/'1' +0 -0
  2. package/.env.example +46 -0
  3. package/CHANGELOG.md +87 -0
  4. package/CLAUDE.md +287 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/Webhooks) +0 -0
  8. package/docs/plans/2026-03-11-github-webhooks.md +957 -0
  9. package/docs/screenshot-swarm-monitor.png +0 -0
  10. package/frontend +0 -0
  11. package/index.html +13 -0
  12. package/package.json +56 -0
  13. package/public/vite.svg +4 -0
  14. package/src/backend/__tests__/webhook-github.test.ts +934 -0
  15. package/src/backend/jsonl-monitor.ts +430 -0
  16. package/src/backend/server.ts +2972 -0
  17. package/src/backend/telegram-bot.ts +511 -0
  18. package/src/backend/webhook-github.ts +350 -0
  19. package/src/frontend/App.tsx +461 -0
  20. package/src/frontend/api.ts +281 -0
  21. package/src/frontend/components/ErrorBoundary.tsx +98 -0
  22. package/src/frontend/components/Layout.tsx +431 -0
  23. package/src/frontend/components/ui/Button.tsx +111 -0
  24. package/src/frontend/components/ui/Card.tsx +51 -0
  25. package/src/frontend/components/ui/StatusBadge.tsx +60 -0
  26. package/src/frontend/main.tsx +63 -0
  27. package/src/frontend/pages/AgentVizPanel.tsx +428 -0
  28. package/src/frontend/pages/AgentsPanel.tsx +445 -0
  29. package/src/frontend/pages/ConfigPanel.tsx +661 -0
  30. package/src/frontend/pages/Dashboard.tsx +482 -0
  31. package/src/frontend/pages/HiveMindPanel.tsx +355 -0
  32. package/src/frontend/pages/HooksPanel.tsx +240 -0
  33. package/src/frontend/pages/LogsPanel.tsx +261 -0
  34. package/src/frontend/pages/MemoryPanel.tsx +444 -0
  35. package/src/frontend/pages/NeuralPanel.tsx +301 -0
  36. package/src/frontend/pages/PerformancePanel.tsx +198 -0
  37. package/src/frontend/pages/SessionsPanel.tsx +428 -0
  38. package/src/frontend/pages/SetupWizard.tsx +181 -0
  39. package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
  40. package/src/frontend/pages/SwarmPanel.tsx +322 -0
  41. package/src/frontend/pages/TasksPanel.tsx +535 -0
  42. package/src/frontend/pages/WebhooksPanel.tsx +335 -0
  43. package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
  44. package/src/frontend/store.ts +185 -0
  45. package/src/frontend/styles/global.css +113 -0
  46. package/src/frontend/test-setup.ts +1 -0
  47. package/src/frontend/tour/TourContext.tsx +161 -0
  48. package/src/frontend/tour/tourSteps.ts +181 -0
  49. package/src/frontend/tour/tourStyles.css +116 -0
  50. package/src/frontend/types.ts +239 -0
  51. package/src/frontend/utils/formatTime.test.ts +83 -0
  52. package/src/frontend/utils/formatTime.ts +23 -0
  53. package/tsconfig.json +23 -0
  54. package/vite.config.ts +26 -0
  55. package/vitest.config.ts +17 -0
  56. package/{,+ +0 -0
@@ -0,0 +1,430 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+
5
+ export interface SessionNode {
6
+ id: string
7
+ sessionId: string
8
+ agentId?: string
9
+ slug?: string
10
+ agentType?: string
11
+ status: 'active' | 'idle' | 'done' | 'error'
12
+ currentTool?: string
13
+ currentFile?: string
14
+ lastActivity?: string
15
+ taskId?: string
16
+ children: SessionNode[]
17
+ }
18
+
19
+ export interface MonitoredSession {
20
+ sessionId: string
21
+ taskId: string
22
+ tree: SessionNode
23
+ startedAt: string
24
+ }
25
+
26
+ interface FileState {
27
+ path: string
28
+ bytesRead: number
29
+ watcher: fs.StatWatcher | null
30
+ }
31
+
32
+ interface Monitor {
33
+ sessionId: string
34
+ taskId: string
35
+ tree: SessionNode
36
+ files: Map<string, FileState>
37
+ subagentDir: string
38
+ subagentDirWatcher: fs.StatWatcher | null
39
+ broadcastFn: (type: string, payload: unknown) => void
40
+ startedAt: string
41
+ }
42
+
43
+ const monitors = new Map<string, Monitor>()
44
+
45
+ function getProjectSlug(): string {
46
+ const cwd = process.cwd()
47
+ // On Windows: C:\GitHub\rufloui → C--GitHub-rufloui
48
+ // On Unix: /home/user/project → -home-user-project
49
+ const normalized = cwd.replace(/\\/g, '/')
50
+ const match = normalized.match(/^([A-Za-z]):\/(.*)$/)
51
+ if (match) {
52
+ const drive = match[1]
53
+ const rest = match[2].replace(/\//g, '-')
54
+ return `${drive}--${rest}`
55
+ }
56
+ return normalized.replace(/\//g, '-').replace(/^-/, '')
57
+ }
58
+
59
+ function getSessionDir(): string {
60
+ const homeDir = os.homedir()
61
+ const slug = getProjectSlug()
62
+ return path.join(homeDir, '.claude', 'projects', slug)
63
+ }
64
+
65
+ function parseNewLines(buffer: string): object[] {
66
+ const lines = buffer.split('\n').filter(Boolean)
67
+ const parsed: object[] = []
68
+ for (const line of lines) {
69
+ try {
70
+ parsed.push(JSON.parse(line))
71
+ } catch { /* skip malformed lines */ }
72
+ }
73
+ return parsed
74
+ }
75
+
76
+ function extractFileFromInput(input: Record<string, unknown>): string | undefined {
77
+ if (typeof input.file_path === 'string') return input.file_path
78
+ if (typeof input.path === 'string') return input.path
79
+ if (typeof input.command === 'string') {
80
+ // Try to extract file path from command
81
+ const m = (input.command as string).match(/["']?([A-Za-z]:\\[^\s"']+|\/[^\s"']+\.\w+)["']?/)
82
+ if (m) return m[1]
83
+ }
84
+ if (typeof input.pattern === 'string') return input.pattern
85
+ return undefined
86
+ }
87
+
88
+ function processEvents(events: object[], node: SessionNode): string[] {
89
+ const newAgentIds: string[] = []
90
+
91
+ for (const evt of events) {
92
+ const e = evt as Record<string, unknown>
93
+ const timestamp = (e.timestamp as string) || new Date().toISOString()
94
+
95
+ // Extract slug if present
96
+ if (e.slug && typeof e.slug === 'string') {
97
+ node.slug = e.slug
98
+ }
99
+
100
+ if (e.type === 'assistant') {
101
+ const msg = e.message as Record<string, unknown> | undefined
102
+ if (!msg?.content) continue
103
+ const content = msg.content as Array<Record<string, unknown>>
104
+ for (const block of content) {
105
+ if (block.type === 'tool_use') {
106
+ node.status = 'active'
107
+ node.currentTool = block.name as string
108
+ node.lastActivity = timestamp
109
+ if (block.input && typeof block.input === 'object') {
110
+ const file = extractFileFromInput(block.input as Record<string, unknown>)
111
+ if (file) node.currentFile = file
112
+ }
113
+ // Detect Agent subagent spawn
114
+ if (block.name === 'Agent') {
115
+ const input = block.input as Record<string, unknown>
116
+ if (input.subagent_type) {
117
+ node.currentTool = `Agent(${input.subagent_type})`
118
+ }
119
+ }
120
+ } else if (block.type === 'text') {
121
+ node.status = 'active'
122
+ node.lastActivity = timestamp
123
+ }
124
+ }
125
+ // Check stop_reason
126
+ const stopReason = (msg.stop_reason as string) || ''
127
+ if (stopReason === 'end_turn') {
128
+ node.status = 'idle'
129
+ }
130
+ } else if (e.type === 'user') {
131
+ const msg = e.message as Record<string, unknown> | undefined
132
+ if (!msg?.content) continue
133
+ const content = msg.content as Array<Record<string, unknown>>
134
+ for (const block of content) {
135
+ if (block.type === 'tool_result') {
136
+ node.lastActivity = timestamp
137
+ }
138
+ }
139
+ } else if (e.type === 'result') {
140
+ node.status = 'done'
141
+ node.currentTool = undefined
142
+ node.currentFile = undefined
143
+ node.lastActivity = timestamp
144
+ }
145
+
146
+ // Check for toolUseResult with agentId (subagent completed)
147
+ if (e.toolUseResult && typeof e.toolUseResult === 'object') {
148
+ const tur = e.toolUseResult as Record<string, unknown>
149
+ if (tur.agentId && typeof tur.agentId === 'string') {
150
+ newAgentIds.push(tur.agentId)
151
+ }
152
+ }
153
+ }
154
+
155
+ return newAgentIds
156
+ }
157
+
158
+ function tailRead(fileState: FileState): string {
159
+ try {
160
+ const stat = fs.statSync(fileState.path)
161
+ if (stat.size <= fileState.bytesRead) return ''
162
+ const fd = fs.openSync(fileState.path, 'r')
163
+ const len = stat.size - fileState.bytesRead
164
+ const buf = Buffer.alloc(len)
165
+ fs.readSync(fd, buf, 0, len, fileState.bytesRead)
166
+ fs.closeSync(fd)
167
+ fileState.bytesRead = stat.size
168
+ return buf.toString('utf-8')
169
+ } catch {
170
+ return ''
171
+ }
172
+ }
173
+
174
+ function startFileWatch(monitor: Monitor, filePath: string, node: SessionNode) {
175
+ if (monitor.files.has(filePath)) return
176
+ if (!fs.existsSync(filePath)) return
177
+
178
+ const fileState: FileState = { path: filePath, bytesRead: 0, watcher: null }
179
+ monitor.files.set(filePath, fileState)
180
+
181
+ // Initial read
182
+ const initial = tailRead(fileState)
183
+ if (initial) {
184
+ const events = parseNewLines(initial)
185
+ const newAgentIds = processEvents(events, node)
186
+ for (const agentId of newAgentIds) {
187
+ addSubagent(monitor, node, agentId)
188
+ }
189
+ monitor.broadcastFn('viz:update', { sessionId: monitor.sessionId, tree: monitor.tree })
190
+ }
191
+
192
+ // Poll with fs.watchFile (reliable on Windows)
193
+ fileState.watcher = fs.watchFile(filePath, { interval: 1000 }, () => {
194
+ const newData = tailRead(fileState)
195
+ if (!newData) return
196
+ const events = parseNewLines(newData)
197
+ const newAgentIds = processEvents(events, node)
198
+ for (const agentId of newAgentIds) {
199
+ addSubagent(monitor, node, agentId)
200
+ }
201
+ monitor.broadcastFn('viz:update', { sessionId: monitor.sessionId, tree: monitor.tree })
202
+ })
203
+ }
204
+
205
+ function addSubagent(monitor: Monitor, parentNode: SessionNode, agentId: string) {
206
+ // Check if already tracked
207
+ if (parentNode.children.some(c => c.agentId === agentId)) return
208
+
209
+ // Find subagent JSONL — try various naming patterns
210
+ const patterns = [
211
+ `agent-a${agentId}.jsonl`,
212
+ `agent-${agentId}.jsonl`,
213
+ ]
214
+ let subagentPath: string | undefined
215
+ for (const p of patterns) {
216
+ const candidate = path.join(monitor.subagentDir, p)
217
+ if (fs.existsSync(candidate)) {
218
+ subagentPath = candidate
219
+ break
220
+ }
221
+ }
222
+
223
+ const childNode: SessionNode = {
224
+ id: agentId,
225
+ sessionId: monitor.sessionId,
226
+ agentId,
227
+ status: subagentPath ? 'active' : 'idle',
228
+ taskId: monitor.taskId,
229
+ children: [],
230
+ }
231
+ parentNode.children.push(childNode)
232
+
233
+ if (subagentPath) {
234
+ startFileWatch(monitor, subagentPath, childNode)
235
+ }
236
+ }
237
+
238
+ function scanSubagentDir(monitor: Monitor) {
239
+ if (!fs.existsSync(monitor.subagentDir)) return
240
+ try {
241
+ const files = fs.readdirSync(monitor.subagentDir)
242
+ for (const file of files) {
243
+ if (!file.endsWith('.jsonl')) continue
244
+ // Extract agentId from filename like agent-a<id>.jsonl or agent-<id>.jsonl
245
+ const m = file.match(/^agent-a?(.+)\.jsonl$/)
246
+ if (!m) continue
247
+ const agentId = m[1]
248
+ // Check if already tracked in tree
249
+ const alreadyTracked = monitor.tree.children.some(c => {
250
+ const cId = c.agentId || ''
251
+ return cId === agentId || cId === `a${agentId}` || `a${cId}` === agentId
252
+ })
253
+ if (!alreadyTracked) {
254
+ const fullAgentId = file.replace('agent-', '').replace('.jsonl', '')
255
+ const childNode: SessionNode = {
256
+ id: fullAgentId,
257
+ sessionId: monitor.sessionId,
258
+ agentId: fullAgentId,
259
+ status: 'active',
260
+ taskId: monitor.taskId,
261
+ children: [],
262
+ }
263
+ monitor.tree.children.push(childNode)
264
+ startFileWatch(monitor, path.join(monitor.subagentDir, file), childNode)
265
+ }
266
+ }
267
+ } catch { /* dir may not exist yet */ }
268
+ }
269
+
270
+ export function startMonitoring(
271
+ sessionId: string,
272
+ taskId: string,
273
+ broadcastFn: (type: string, payload: unknown) => void
274
+ ): void {
275
+ if (monitors.has(sessionId)) return
276
+
277
+ const sessionDir = getSessionDir()
278
+ const jsonlPath = path.join(sessionDir, `${sessionId}.jsonl`)
279
+ const subagentDir = path.join(sessionDir, sessionId, 'subagents')
280
+
281
+ const rootNode: SessionNode = {
282
+ id: sessionId,
283
+ sessionId,
284
+ status: 'active',
285
+ taskId,
286
+ children: [],
287
+ }
288
+
289
+ const monitor: Monitor = {
290
+ sessionId,
291
+ taskId,
292
+ tree: rootNode,
293
+ files: new Map(),
294
+ subagentDir,
295
+ subagentDirWatcher: null,
296
+ broadcastFn,
297
+ startedAt: new Date().toISOString(),
298
+ }
299
+ monitors.set(sessionId, monitor)
300
+
301
+ // Start watching main JSONL (may not exist yet, poll until it does)
302
+ const waitForFile = setInterval(() => {
303
+ if (fs.existsSync(jsonlPath)) {
304
+ clearInterval(waitForFile)
305
+ startFileWatch(monitor, jsonlPath, rootNode)
306
+ }
307
+ }, 1000)
308
+
309
+ // Also poll for subagent directory appearance
310
+ const waitForSubagents = setInterval(() => {
311
+ if (!monitors.has(sessionId)) {
312
+ clearInterval(waitForSubagents)
313
+ return
314
+ }
315
+ scanSubagentDir(monitor)
316
+ }, 2000)
317
+
318
+ // Store interval refs for cleanup
319
+ ;(monitor as unknown as Record<string, NodeJS.Timeout>)._waitFile = waitForFile
320
+ ;(monitor as unknown as Record<string, NodeJS.Timeout>)._waitSub = waitForSubagents
321
+
322
+ broadcastFn('viz:update', { sessionId, tree: rootNode })
323
+ }
324
+
325
+ export function stopMonitoring(sessionId: string): void {
326
+ const monitor = monitors.get(sessionId)
327
+ if (!monitor) return
328
+
329
+ // Mark tree as done
330
+ function markDone(node: SessionNode) {
331
+ if (node.status === 'active' || node.status === 'idle') {
332
+ node.status = 'done'
333
+ }
334
+ node.children.forEach(markDone)
335
+ }
336
+ markDone(monitor.tree)
337
+ monitor.broadcastFn('viz:update', { sessionId, tree: monitor.tree })
338
+
339
+ // Clean up watchers
340
+ for (const fs_ of monitor.files.values()) {
341
+ if (fs_.watcher) fs.unwatchFile(fs_.path)
342
+ }
343
+ const m = monitor as unknown as Record<string, NodeJS.Timeout>
344
+ if (m._waitFile) clearInterval(m._waitFile)
345
+ if (m._waitSub) clearInterval(m._waitSub)
346
+
347
+ monitors.delete(sessionId)
348
+ }
349
+
350
+ export function getSessionTree(sessionId: string): SessionNode | null {
351
+ return monitors.get(sessionId)?.tree ?? null
352
+ }
353
+
354
+ export interface LogEntry {
355
+ timestamp: string
356
+ type: string
357
+ tool?: string
358
+ content: string
359
+ }
360
+
361
+ export function getNodeLogs(sessionId: string, nodeId: string, tail = 100): LogEntry[] {
362
+ const sessionDir = getSessionDir()
363
+ // Determine file path based on nodeId
364
+ let filePath: string
365
+ if (nodeId === sessionId) {
366
+ // Root session
367
+ filePath = path.join(sessionDir, `${sessionId}.jsonl`)
368
+ } else {
369
+ // Subagent — try various naming patterns
370
+ const subDir = path.join(sessionDir, sessionId, 'subagents')
371
+ const candidates = [
372
+ path.join(subDir, `agent-${nodeId}.jsonl`),
373
+ path.join(subDir, `agent-a${nodeId}.jsonl`),
374
+ ]
375
+ filePath = candidates.find(p => fs.existsSync(p)) || candidates[0]
376
+ }
377
+ // Also check active monitors for file paths
378
+ const monitor = monitors.get(sessionId)
379
+ if (monitor) {
380
+ for (const [fPath] of monitor.files) {
381
+ if (fPath.includes(nodeId)) { filePath = fPath; break }
382
+ }
383
+ }
384
+ if (!fs.existsSync(filePath)) return []
385
+
386
+ try {
387
+ const raw = fs.readFileSync(filePath, 'utf-8')
388
+ const lines = raw.split('\n').filter(Boolean)
389
+ const entries: LogEntry[] = []
390
+ for (const line of lines) {
391
+ try {
392
+ const obj = JSON.parse(line) as Record<string, unknown>
393
+ const ts = (obj.timestamp as string) || ''
394
+ const type = (obj.type as string) || ''
395
+ if (type === 'assistant') {
396
+ const msg = obj.message as Record<string, unknown> | undefined
397
+ if (!msg?.content) continue
398
+ for (const block of msg.content as Array<Record<string, unknown>>) {
399
+ if (block.type === 'tool_use') {
400
+ const input = block.input as Record<string, unknown> | undefined
401
+ const summary = input?.file_path || input?.command || input?.pattern || input?.query || ''
402
+ entries.push({
403
+ timestamp: ts, type: 'tool_use',
404
+ tool: block.name as string,
405
+ content: String(summary).slice(0, 200),
406
+ })
407
+ } else if (block.type === 'text') {
408
+ const text = (block.text as string || '').slice(0, 300)
409
+ if (text.trim()) {
410
+ entries.push({ timestamp: ts, type: 'text', content: text })
411
+ }
412
+ }
413
+ }
414
+ } else if (type === 'result') {
415
+ entries.push({ timestamp: ts, type: 'result', content: 'Session completed' })
416
+ }
417
+ } catch { /* skip malformed */ }
418
+ }
419
+ return entries.slice(-tail)
420
+ } catch { return [] }
421
+ }
422
+
423
+ export function getAllMonitoredSessions(): MonitoredSession[] {
424
+ return Array.from(monitors.values()).map(m => ({
425
+ sessionId: m.sessionId,
426
+ taskId: m.taskId,
427
+ tree: m.tree,
428
+ startedAt: m.startedAt,
429
+ }))
430
+ }