jfl 0.2.2 → 0.2.3

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 (70) hide show
  1. package/clawdbot-plugin/clawdbot.plugin.json +12 -1
  2. package/clawdbot-plugin/index.js +5 -5
  3. package/clawdbot-plugin/index.ts +5 -5
  4. package/dist/commands/context-hub.d.ts +4 -0
  5. package/dist/commands/context-hub.d.ts.map +1 -1
  6. package/dist/commands/context-hub.js +570 -81
  7. package/dist/commands/context-hub.js.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +42 -11
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/peter.d.ts +15 -0
  12. package/dist/commands/peter.d.ts.map +1 -0
  13. package/dist/commands/peter.js +198 -0
  14. package/dist/commands/peter.js.map +1 -0
  15. package/dist/commands/ralph.d.ts +3 -1
  16. package/dist/commands/ralph.d.ts.map +1 -1
  17. package/dist/commands/ralph.js +40 -5
  18. package/dist/commands/ralph.js.map +1 -1
  19. package/dist/commands/session.d.ts +2 -1
  20. package/dist/commands/session.d.ts.map +1 -1
  21. package/dist/commands/session.js +496 -49
  22. package/dist/commands/session.js.map +1 -1
  23. package/dist/commands/update.d.ts.map +1 -1
  24. package/dist/commands/update.js +25 -6
  25. package/dist/commands/update.js.map +1 -1
  26. package/dist/dashboard/components.d.ts +7 -0
  27. package/dist/dashboard/components.d.ts.map +1 -0
  28. package/dist/dashboard/components.js +163 -0
  29. package/dist/dashboard/components.js.map +1 -0
  30. package/dist/dashboard/index.d.ts +12 -0
  31. package/dist/dashboard/index.d.ts.map +1 -0
  32. package/dist/dashboard/index.js +132 -0
  33. package/dist/dashboard/index.js.map +1 -0
  34. package/dist/dashboard/pages.d.ts +7 -0
  35. package/dist/dashboard/pages.d.ts.map +1 -0
  36. package/dist/dashboard/pages.js +742 -0
  37. package/dist/dashboard/pages.js.map +1 -0
  38. package/dist/dashboard/styles.d.ts +7 -0
  39. package/dist/dashboard/styles.d.ts.map +1 -0
  40. package/dist/dashboard/styles.js +497 -0
  41. package/dist/dashboard/styles.js.map +1 -0
  42. package/dist/index.js +30 -3
  43. package/dist/index.js.map +1 -1
  44. package/dist/lib/map-event-bus.d.ts +48 -0
  45. package/dist/lib/map-event-bus.d.ts.map +1 -0
  46. package/dist/lib/map-event-bus.js +326 -0
  47. package/dist/lib/map-event-bus.js.map +1 -0
  48. package/dist/lib/peter-parker-bridge.d.ts +33 -0
  49. package/dist/lib/peter-parker-bridge.d.ts.map +1 -0
  50. package/dist/lib/peter-parker-bridge.js +116 -0
  51. package/dist/lib/peter-parker-bridge.js.map +1 -0
  52. package/dist/lib/peter-parker-config.d.ts +13 -0
  53. package/dist/lib/peter-parker-config.d.ts.map +1 -0
  54. package/dist/lib/peter-parker-config.js +86 -0
  55. package/dist/lib/peter-parker-config.js.map +1 -0
  56. package/dist/lib/service-utils.d.ts.map +1 -1
  57. package/dist/lib/service-utils.js +33 -17
  58. package/dist/lib/service-utils.js.map +1 -1
  59. package/dist/mcp/context-hub-mcp.js +122 -22
  60. package/dist/mcp/context-hub-mcp.js.map +1 -1
  61. package/dist/types/map.d.ts +33 -0
  62. package/dist/types/map.d.ts.map +1 -0
  63. package/dist/types/map.js +39 -0
  64. package/dist/types/map.js.map +1 -0
  65. package/dist/ui/event-dashboard.d.ts +12 -0
  66. package/dist/ui/event-dashboard.d.ts.map +1 -0
  67. package/dist/ui/event-dashboard.js +342 -0
  68. package/dist/ui/event-dashboard.js.map +1 -0
  69. package/package.json +1 -1
  70. package/scripts/test-map-eventbus.sh +357 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Dashboard page components
3
+ *
4
+ * @purpose Command center pages: journal-first overview, events, agents, projects, sessions, journal
5
+ */
6
+ export function getPagesJS() {
7
+ return `
8
+ // Shared SSE hook — one connection, all pages can use it
9
+ function useEventStream() {
10
+ const [events, setEvents] = useState([])
11
+ const [connected, setConnected] = useState(false)
12
+
13
+ useEffect(() => {
14
+ apiFetch('/api/events?limit=200')
15
+ .then(data => setEvents(data.events || []))
16
+ .catch(() => {})
17
+
18
+ const token = localStorage.getItem('jfl-token')
19
+ const url = new URL('/api/events/stream', window.location.origin)
20
+ url.searchParams.set('patterns', '*')
21
+ if (token) url.searchParams.set('token', token)
22
+ const es = new EventSource(url.toString())
23
+
24
+ es.onopen = () => setConnected(true)
25
+ es.onerror = () => setConnected(false)
26
+
27
+ function handleEvent(e) {
28
+ try {
29
+ const event = JSON.parse(e.data)
30
+ setEvents(prev => [event, ...prev].slice(0, 500))
31
+ } catch {}
32
+ }
33
+
34
+ es.onmessage = handleEvent
35
+ const types = [
36
+ 'peter:started', 'peter:task-selected', 'peter:task-completed', 'peter:all-complete',
37
+ 'task:started', 'task:completed', 'task:failed',
38
+ 'session:started', 'session:ended',
39
+ 'service:healthy', 'service:unhealthy',
40
+ 'journal:entry', 'decision:made',
41
+ 'build:completed', 'deploy:completed',
42
+ 'openclaw:tag', 'custom'
43
+ ]
44
+ types.forEach(t => es.addEventListener(t, handleEvent))
45
+
46
+ return () => es.close()
47
+ }, [])
48
+
49
+ return { events, connected }
50
+ }
51
+
52
+ // Fetch journal + knowledge from Context API
53
+ function useContextData() {
54
+ const [journal, setJournal] = useState([])
55
+ const [knowledge, setKnowledge] = useState([])
56
+ const [loading, setLoading] = useState(true)
57
+
58
+ useEffect(() => {
59
+ apiFetch('/api/context', {
60
+ method: 'POST',
61
+ body: JSON.stringify({ maxItems: 100 })
62
+ })
63
+ .then(data => {
64
+ const items = data.items || []
65
+ setJournal(items.filter(i => i.source === 'journal'))
66
+ setKnowledge(items.filter(i => i.source === 'knowledge'))
67
+ })
68
+ .catch(() => {})
69
+ .finally(() => setLoading(false))
70
+ }, [])
71
+
72
+ return { journal, knowledge, loading }
73
+ }
74
+
75
+ // Extract agents from events
76
+ function extractAgents(events) {
77
+ const agents = new Map()
78
+ for (const e of events) {
79
+ const src = e.source || ''
80
+ if (!src) continue
81
+
82
+ if (!agents.has(src)) {
83
+ agents.set(src, {
84
+ name: src,
85
+ model: e.data?.model || e.data?.modelTier || null,
86
+ role: e.data?.role || e.data?.agentRole || null,
87
+ runtime: e.data?.runtime || null,
88
+ status: 'idle',
89
+ lastEvent: e.type,
90
+ lastSeen: e.ts || e.timestamp,
91
+ eventCount: 0,
92
+ })
93
+ }
94
+
95
+ const agent = agents.get(src)
96
+ agent.eventCount++
97
+ agent.lastEvent = e.type
98
+ agent.lastSeen = e.ts || e.timestamp || agent.lastSeen
99
+
100
+ if (e.data?.model) agent.model = e.data.model
101
+ if (e.data?.modelTier) agent.model = e.data.modelTier
102
+ if (e.data?.role) agent.role = e.data.role
103
+ if (e.data?.agentRole) agent.role = e.data.agentRole
104
+ if (e.data?.runtime) agent.runtime = e.data.runtime
105
+
106
+ if (e.type?.includes('started') || e.type?.includes('start')) {
107
+ agent.status = 'active'
108
+ } else if (e.type?.includes('completed') || e.type?.includes('complete') || e.type?.includes('ended')) {
109
+ agent.status = 'idle'
110
+ } else if (e.type?.includes('failed')) {
111
+ agent.status = 'error'
112
+ }
113
+ }
114
+ return [...agents.values()].sort((a, b) => {
115
+ if (a.status === 'active' && b.status !== 'active') return -1
116
+ if (b.status === 'active' && a.status !== 'active') return 1
117
+ return (b.eventCount || 0) - (a.eventCount || 0)
118
+ })
119
+ }
120
+
121
+ // ================================================================
122
+ // COMMAND CENTER (Overview) — Journal-first
123
+ // ================================================================
124
+
125
+ function OverviewPage() {
126
+ const { events, connected } = useEventStream()
127
+ const { journal, knowledge, loading } = useContextData()
128
+ const [services, setServices] = useState({})
129
+ const [projects, setProjects] = useState([])
130
+ const [searchQuery, setSearchQuery] = useState('')
131
+ const [searchResults, setSearchResults] = useState(null)
132
+ const [searching, setSearching] = useState(false)
133
+
134
+ useEffect(() => {
135
+ apiFetch('/api/services').then(setServices).catch(() => {})
136
+ apiFetch('/api/projects').then(setProjects).catch(() => {})
137
+ }, [])
138
+
139
+ function doSearch() {
140
+ if (!searchQuery.trim()) return
141
+ setSearching(true)
142
+ apiFetch('/api/context/search', {
143
+ method: 'POST',
144
+ body: JSON.stringify({ query: searchQuery, maxItems: 15 })
145
+ })
146
+ .then(data => {
147
+ const items = (data.items || []).map(item => ({
148
+ type: item.type || item.source || 'unknown',
149
+ title: item.title || 'Untitled',
150
+ content: item.content || '',
151
+ summary: item.summary || '',
152
+ relevance_label: item.relevance > 0.7 ? 'high' : item.relevance > 0.4 ? 'medium' : 'low',
153
+ timestamp: item.timestamp
154
+ }))
155
+ setSearchResults(items)
156
+ })
157
+ .catch(() => setSearchResults([]))
158
+ .finally(() => setSearching(false))
159
+ }
160
+
161
+ const serviceEntries = Object.entries(services || {})
162
+ const okProjects = (projects || []).filter(p => p.status === 'OK').length
163
+ const totalProjects = (projects || []).length
164
+ const decisionCount = journal.filter(e => e.type === 'decision').length
165
+
166
+ return html\`
167
+ <div>
168
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
169
+ <h2 class="page-title" style="margin-bottom: 0;">Command Center</h2>
170
+ <\${LiveIndicator} connected=\${connected} />
171
+ </div>
172
+
173
+ <!-- Stats row -->
174
+ <div class="grid-4" style="margin-bottom: 1rem;">
175
+ <\${Card} title="Projects">
176
+ <div class="card-value" style="color: var(--success);">\${okProjects}<span style="font-size: 1rem; color: var(--text-dim);">/\${totalProjects}</span></div>
177
+ <//>
178
+ <\${Card} title="Journal Entries">
179
+ <div class="card-value">\${journal.length}</div>
180
+ <//>
181
+ <\${Card} title="Decisions">
182
+ <div class="card-value" style="color: var(--accent);">\${decisionCount}</div>
183
+ <//>
184
+ <\${Card} title="Services">
185
+ <div class="card-value">\${serviceEntries.length}</div>
186
+ <//>
187
+ </div>
188
+
189
+ <div style="display: grid; grid-template-columns: 1fr 320px; gap: 1rem;">
190
+
191
+ <!-- Left: Journal Feed -->
192
+ <div>
193
+ <\${Card} title="Recent Activity">
194
+ \${loading ? html\`<div class="loading">Loading journal</div>\` :
195
+ journal.length === 0 ? html\`
196
+ <div class="empty-state" style="padding: 1.5rem;">
197
+ <div style="color: var(--text-dim);">No journal entries yet</div>
198
+ <div style="color: var(--text-dim); font-size: 0.75rem; margin-top: 0.5rem;">
199
+ Journal entries appear as you work across sessions
200
+ </div>
201
+ </div>
202
+ \` : html\`
203
+ <div style="max-height: 360px; overflow-y: auto;">
204
+ \${journal.slice(0, 15).map(entry => html\`
205
+ <\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} />
206
+ \`)}
207
+ </div>
208
+ \`
209
+ }
210
+ <//>
211
+
212
+ <!-- Event stream (secondary, below journal) -->
213
+ \${events.length > 0 && html\`
214
+ <\${Card} title="Event Stream">
215
+ <div style="max-height: 200px; overflow-y: auto;">
216
+ <table>
217
+ <thead>
218
+ <tr>
219
+ <th style="width: 80px;">Time</th>
220
+ <th style="width: 160px;">Type</th>
221
+ <th>Source</th>
222
+ <th>Details</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ \${events.slice(0, 20).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
227
+ </tbody>
228
+ </table>
229
+ </div>
230
+ <//>
231
+ \`}
232
+ </div>
233
+
234
+ <!-- Right: Decisions + Services + Memory Search -->
235
+ <div>
236
+ <\${Card} title="Recent Decisions">
237
+ \${loading ? html\`<div class="loading">Loading</div>\` :
238
+ (() => {
239
+ const decisions = journal.filter(e => e.type === 'decision')
240
+ return decisions.length === 0 ? html\`
241
+ <div style="color: var(--text-dim); font-size: 0.8rem; padding: 0.5rem 0;">No decisions recorded</div>
242
+ \` : html\`
243
+ <div>
244
+ \${decisions.slice(0, 5).map(d => html\`
245
+ <div key=\${d.timestamp + d.title} class="decision-row">
246
+ <div class="decision-title">\${d.title}</div>
247
+ <div class="decision-meta">
248
+ \${d.timestamp ? new Date(d.timestamp).toLocaleDateString() : ''}
249
+ </div>
250
+ \${d.content && html\`<div class="decision-summary">\${d.content.slice(0, 100)}\${d.content.length > 100 ? '...' : ''}</div>\`}
251
+ </div>
252
+ \`)}
253
+ </div>
254
+ \`
255
+ })()
256
+ }
257
+ <//>
258
+
259
+ \${serviceEntries.length > 0 && html\`
260
+ <\${Card} title="Services">
261
+ <div style="display: grid; gap: 0.375rem;">
262
+ \${serviceEntries.map(([name, svc]) => html\`
263
+ <div key=\${name} style="display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3);">
264
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
265
+ <span class=\${'agent-dot agent-dot-' + (svc.status === 'running' ? 'active' : 'idle')}></span>
266
+ <span style="font-size: 0.8rem; font-weight: 600;">\${name}</span>
267
+ </div>
268
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
269
+ \${svc.port && svc.port !== '?' && html\`<span style="font-size: 0.7rem; color: var(--text-dim); font-family: monospace;">:\${svc.port}</span>\`}
270
+ <span style="font-size: 0.65rem; padding: 0.1rem 0.375rem; border-radius: 4px; background: rgba(148,163,184,0.12); color: var(--text-soft);">\${svc.type || 'unknown'}</span>
271
+ </div>
272
+ </div>
273
+ \`)}
274
+ </div>
275
+ <//>
276
+ \`}
277
+
278
+ <\${Card} title="Search">
279
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
280
+ <\${SearchInput}
281
+ value=\${searchQuery}
282
+ onInput=\${e => setSearchQuery(e.target.value)}
283
+ placeholder="Search journal, knowledge, code..."
284
+ loading=\${searching}
285
+ />
286
+ <button class="btn btn-primary" style="white-space: nowrap;" onClick=\${doSearch}>Search</button>
287
+ </div>
288
+ \${searchResults !== null && html\`
289
+ <div style="max-height: 250px; overflow-y: auto;">
290
+ \${searchResults.length === 0 ? html\`
291
+ <div style="color: var(--text-dim); font-size: 0.8rem; padding: 0.5rem 0;">No results found</div>
292
+ \` : searchResults.map((r, i) => html\`
293
+ <div key=\${i} class="memory-result">
294
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
295
+ <\${TypeBadge} type=\${r.type || 'unknown'} />
296
+ <span style="font-size: 0.8rem; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">\${r.title}</span>
297
+ \${r.timestamp && html\`<span style="font-size: 0.65rem; color: var(--text-dim); white-space: nowrap;">\${new Date(r.timestamp).toLocaleDateString()}</span>\`}
298
+ </div>
299
+ <div style="font-size: 0.75rem; color: var(--text-soft); margin-top: 0.25rem; line-height: 1.4;">
300
+ \${(r.content || r.summary || '').slice(0, 150)}\${(r.content || r.summary || '').length > 150 ? '...' : ''}
301
+ </div>
302
+ </div>
303
+ \`)}
304
+ </div>
305
+ \`}
306
+ <//>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ \`
311
+ }
312
+
313
+ // ================================================================
314
+ // EVENTS (full stream with filtering)
315
+ // ================================================================
316
+
317
+ function EventsPage() {
318
+ const { events, connected } = useEventStream()
319
+ const [filter, setFilter] = useState('')
320
+
321
+ const filtered = filter
322
+ ? events.filter(e =>
323
+ (e.type || '').toLowerCase().includes(filter.toLowerCase()) ||
324
+ (e.source || '').toLowerCase().includes(filter.toLowerCase()) ||
325
+ JSON.stringify(e.data || {}).toLowerCase().includes(filter.toLowerCase())
326
+ )
327
+ : events
328
+
329
+ return html\`
330
+ <div>
331
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
332
+ <h2 class="page-title" style="margin-bottom: 0;">Events</h2>
333
+ <div style="display: flex; align-items: center; gap: 1rem;">
334
+ <span style="font-size: 0.8rem; color: var(--text-dim);">\${filtered.length} events</span>
335
+ <\${LiveIndicator} connected=\${connected} />
336
+ </div>
337
+ </div>
338
+ <div style="margin-bottom: 1rem;">
339
+ <\${SearchInput}
340
+ value=\${filter}
341
+ onInput=\${e => setFilter(e.target.value)}
342
+ placeholder="Filter by type, source, or data..."
343
+ />
344
+ </div>
345
+ <\${Card}>
346
+ <div style="max-height: 600px; overflow-y: auto;">
347
+ <table>
348
+ <thead>
349
+ <tr>
350
+ <th style="width: 80px;">Time</th>
351
+ <th style="width: 180px;">Type</th>
352
+ <th style="width: 140px;">Source</th>
353
+ <th>Data</th>
354
+ </tr>
355
+ </thead>
356
+ <tbody>
357
+ \${filtered.slice(0, 200).map(e => html\`<\${EventRow} key=\${e.id} event=\${e} />\`)}
358
+ </tbody>
359
+ </table>
360
+ \${filtered.length === 0 && html\`
361
+ <div class="empty-state">
362
+ <div style="color: var(--text-dim);">\${filter ? 'No matching events' : 'No events yet'}</div>
363
+ </div>
364
+ \`}
365
+ </div>
366
+ <//>
367
+ </div>
368
+ \`
369
+ }
370
+
371
+ // ================================================================
372
+ // AGENTS
373
+ // ================================================================
374
+
375
+ function AgentsPage() {
376
+ const { events, connected } = useEventStream()
377
+ const agents = extractAgents(events)
378
+
379
+ const localAgents = agents.filter(a => !a.runtime || a.runtime === 'local')
380
+ const remoteAgents = agents.filter(a => a.runtime && a.runtime !== 'local')
381
+ const peterEvents = events.filter(e => (e.type || '').startsWith('peter:'))
382
+
383
+ return html\`
384
+ <div>
385
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
386
+ <h2 class="page-title" style="margin-bottom: 0;">Agents</h2>
387
+ <\${LiveIndicator} connected=\${connected} />
388
+ </div>
389
+
390
+ \${agents.length === 0 ? html\`
391
+ <div class="empty-state" style="padding: 3rem;">
392
+ <div style="font-size: 1.5rem; color: var(--text-dim); margin-bottom: 1rem;">No Agent Activity</div>
393
+ <div style="color: var(--text-dim); max-width: 400px; margin: 0 auto; line-height: 1.6;">
394
+ Agents appear here when they emit MAP events.<br/>
395
+ Run <span style="color: var(--accent); font-family: monospace;">jfl peter</span> or
396
+ <span style="color: var(--accent); font-family: monospace;">jfl ralph</span> to see them in action.
397
+ </div>
398
+ </div>
399
+ \` : html\`
400
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
401
+ <\${Card} title=\${'Local Agents (' + localAgents.length + ')'}>
402
+ \${localAgents.map(a => html\`
403
+ <\${AgentCard} key=\${a.name} agent=\${a} />
404
+ \`)}
405
+ \${localAgents.length === 0 && html\`<div style="color: var(--text-dim); font-size: 0.8rem;">None</div>\`}
406
+ <//>
407
+ <\${Card} title=\${'Remote Agents (' + remoteAgents.length + ')'}>
408
+ \${remoteAgents.map(a => html\`
409
+ <\${AgentCard} key=\${a.name} agent=\${a} />
410
+ \`)}
411
+ \${remoteAgents.length === 0 && html\`<div style="color: var(--text-dim); font-size: 0.8rem;">None</div>\`}
412
+ <//>
413
+ </div>
414
+
415
+ \${peterEvents.length > 0 && html\`
416
+ <\${Card} title="Peter Parker Routing" style="margin-top: 1rem;">
417
+ <div style="max-height: 300px; overflow-y: auto;">
418
+ <table>
419
+ <thead>
420
+ <tr>
421
+ <th>Time</th>
422
+ <th>Action</th>
423
+ <th>Details</th>
424
+ </tr>
425
+ </thead>
426
+ <tbody>
427
+ \${peterEvents.slice(0, 30).map(e => html\`
428
+ <tr key=\${e.id} class="event-row">
429
+ <td class="event-time">\${new Date(e.ts || e.timestamp).toLocaleTimeString()}</td>
430
+ <td><\${TypeBadge} type=\${e.type} /></td>
431
+ <td class="event-payload">
432
+ \${e.data?.task || e.data?.message || e.data?.agentRole || JSON.stringify(e.data || {}).slice(0, 80)}
433
+ </td>
434
+ </tr>
435
+ \`)}
436
+ </tbody>
437
+ </table>
438
+ </div>
439
+ <//>
440
+ \`}
441
+ \`}
442
+ </div>
443
+ \`
444
+ }
445
+
446
+ function AgentCard({ agent }) {
447
+ const a = agent
448
+ const age = a.lastSeen ? Math.round((Date.now() - new Date(a.lastSeen).getTime()) / 1000) : null
449
+ const ageStr = age !== null
450
+ ? age < 60 ? age + 's ago'
451
+ : age < 3600 ? Math.round(age / 60) + 'm ago'
452
+ : Math.round(age / 3600) + 'h ago'
453
+ : ''
454
+
455
+ return html\`
456
+ <div style="display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid rgba(51,65,85,0.3);">
457
+ <div style="display: flex; align-items: center; gap: 0.625rem;">
458
+ <span class=\${'agent-dot agent-dot-' + a.status}></span>
459
+ <div>
460
+ <div style="font-size: 0.85rem; font-weight: 600;">\${a.name}</div>
461
+ \${a.role && html\`<div style="font-size: 0.7rem; color: var(--text-dim);">\${a.role}</div>\`}
462
+ </div>
463
+ </div>
464
+ <div style="display: flex; align-items: center; gap: 0.5rem; text-align: right;">
465
+ \${a.model && html\`<span class="model-badge">\${a.model}</span>\`}
466
+ \${a.runtime && a.runtime !== 'local' && html\`<span class="runtime-badge">\${a.runtime}</span>\`}
467
+ <span style="font-size: 0.65rem; color: var(--text-dim); min-width: 50px;">\${ageStr}</span>
468
+ </div>
469
+ </div>
470
+ \`
471
+ }
472
+
473
+ // ================================================================
474
+ // PROJECTS (cross-project health)
475
+ // ================================================================
476
+
477
+ function ProjectsPage() {
478
+ const [projects, setProjects] = useState(null)
479
+ const [loading, setLoading] = useState(true)
480
+ const [projectJournal, setProjectJournal] = useState({})
481
+
482
+ useEffect(() => {
483
+ async function load() {
484
+ try {
485
+ const data = await apiFetch('/api/projects')
486
+ setProjects(data)
487
+ } catch (err) {
488
+ console.error('Failed to load projects:', err)
489
+ } finally {
490
+ setLoading(false)
491
+ }
492
+ }
493
+ load()
494
+ const interval = setInterval(load, 30000)
495
+
496
+ // Fetch journal for entry counts
497
+ apiFetch('/api/context', {
498
+ method: 'POST',
499
+ body: JSON.stringify({ maxItems: 500 })
500
+ })
501
+ .then(data => {
502
+ const entries = (data.items || []).filter(i => i.source === 'journal')
503
+ const bySession = {}
504
+ for (const e of entries) {
505
+ const session = (e.path || '').split('/').pop() || 'unknown'
506
+ const project = session.replace('.jsonl', '').replace(/^session-/, '')
507
+ if (!bySession[project]) bySession[project] = { count: 0, lastTs: '' }
508
+ bySession[project].count++
509
+ if (e.timestamp && e.timestamp > bySession[project].lastTs) {
510
+ bySession[project].lastTs = e.timestamp
511
+ }
512
+ }
513
+ setProjectJournal(bySession)
514
+ })
515
+ .catch(() => {})
516
+
517
+ return () => clearInterval(interval)
518
+ }, [])
519
+
520
+ if (loading) return html\`<div class="loading">Loading projects</div>\`
521
+
522
+ if (!projects || projects.length === 0) {
523
+ return html\`
524
+ <div>
525
+ <h2 class="page-title">Projects</h2>
526
+ <div class="empty-state">
527
+ <div style="color: var(--text-dim);">No tracked projects found.</div>
528
+ </div>
529
+ </div>
530
+ \`
531
+ }
532
+
533
+ const ok = projects.filter(p => p.status === 'OK').length
534
+ const down = projects.filter(p => p.status === 'DOWN').length
535
+ const zombie = projects.filter(p => p.status === 'ZOMBIE').length
536
+
537
+ return html\`
538
+ <div>
539
+ <h2 class="page-title">Projects</h2>
540
+ <div class="grid-3" style="margin-bottom: 1rem;">
541
+ <\${Card} title="Healthy">
542
+ <div class="card-value" style="color: var(--success);">\${ok}</div>
543
+ <//>
544
+ <\${Card} title="Down">
545
+ <div class="card-value" style="color: \${down > 0 ? 'var(--error)' : 'var(--text-dim)'};">\${down}</div>
546
+ <//>
547
+ <\${Card} title="Zombie">
548
+ <div class="card-value" style="color: \${zombie > 0 ? 'var(--warning)' : 'var(--text-dim)'};">\${zombie}</div>
549
+ <//>
550
+ </div>
551
+ <\${Card}>
552
+ <table>
553
+ <thead>
554
+ <tr>
555
+ <th>Project</th>
556
+ <th>Port</th>
557
+ <th>Status</th>
558
+ <th>PID</th>
559
+ <th>Journal Entries</th>
560
+ <th>Last Active</th>
561
+ </tr>
562
+ </thead>
563
+ <tbody>
564
+ \${projects.map(p => {
565
+ const name = p.name || ''
566
+ const journalInfo = Object.entries(projectJournal).find(([k]) => name.toLowerCase().includes(k.toLowerCase().slice(0, 8)))
567
+ const entryCount = journalInfo ? journalInfo[1].count : '-'
568
+ const lastActive = journalInfo && journalInfo[1].lastTs
569
+ ? new Date(journalInfo[1].lastTs).toLocaleDateString()
570
+ : '-'
571
+ return html\`
572
+ <tr key=\${p.path}>
573
+ <td style="font-weight: 600;">\${p.name}</td>
574
+ <td style="font-family: monospace; font-size: 0.8rem;">\${p.port}</td>
575
+ <td><\${StatusBadge} status=\${p.status} /></td>
576
+ <td style="font-family: monospace; font-size: 0.8rem; color: var(--text-soft);">\${p.pid || '-'}</td>
577
+ <td style="font-size: 0.8rem; color: var(--text-soft);">\${entryCount}</td>
578
+ <td style="font-size: 0.8rem; color: var(--text-soft);">\${lastActive}</td>
579
+ </tr>
580
+ \`
581
+ })}
582
+ </tbody>
583
+ </table>
584
+ <//>
585
+ </div>
586
+ \`
587
+ }
588
+
589
+ // ================================================================
590
+ // SESSIONS (journal-based)
591
+ // ================================================================
592
+
593
+ function SessionsPage() {
594
+ const { journal, loading } = useContextData()
595
+
596
+ // Group entries by session field
597
+ const sessions = new Map()
598
+ for (const entry of journal) {
599
+ // Extract session ID from path (filename) since journal entries are per-session files
600
+ const filename = (entry.path || '').split('/').pop() || ''
601
+ const sid = filename.replace('.jsonl', '') || 'unknown'
602
+ if (!sessions.has(sid)) {
603
+ sessions.set(sid, {
604
+ id: sid,
605
+ entries: [],
606
+ types: new Set(),
607
+ firstTs: entry.timestamp,
608
+ lastTs: entry.timestamp,
609
+ })
610
+ }
611
+ const s = sessions.get(sid)
612
+ s.entries.push(entry)
613
+ s.types.add(entry.type)
614
+ if (entry.timestamp) {
615
+ if (!s.firstTs || entry.timestamp < s.firstTs) s.firstTs = entry.timestamp
616
+ if (!s.lastTs || entry.timestamp > s.lastTs) s.lastTs = entry.timestamp
617
+ }
618
+ }
619
+
620
+ const sessionList = [...sessions.values()]
621
+ .sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''))
622
+
623
+ return html\`
624
+ <div>
625
+ <h2 class="page-title">Sessions</h2>
626
+ \${loading ? html\`<div class="loading">Loading sessions</div>\` :
627
+ sessionList.length === 0 ? html\`
628
+ <div class="empty-state" style="padding: 3rem;">
629
+ <div style="color: var(--text-dim);">No sessions found in journal.</div>
630
+ </div>
631
+ \` : html\`
632
+ <div style="margin-bottom: 1rem; font-size: 0.8rem; color: var(--text-dim);">
633
+ \${sessionList.length} sessions from journal entries
634
+ </div>
635
+ <div style="display: grid; gap: 0.75rem;">
636
+ \${sessionList.map(s => html\`
637
+ <\${Card} key=\${s.id}>
638
+ <div style="display: flex; justify-content: space-between; align-items: flex-start;">
639
+ <div>
640
+ <div style="font-weight: 600; font-family: monospace; font-size: 0.85rem;">\${s.id}</div>
641
+ <div style="font-size: 0.75rem; color: var(--text-dim); margin-top: 0.25rem;">
642
+ \${s.firstTs ? new Date(s.firstTs).toLocaleString() : ''}
643
+ \${s.lastTs && s.lastTs !== s.firstTs ? ' — ' + new Date(s.lastTs).toLocaleTimeString() : ''}
644
+ </div>
645
+ </div>
646
+ <span style="font-size: 0.8rem; font-weight: 600; color: var(--text-soft);">
647
+ \${s.entries.length} entries
648
+ </span>
649
+ </div>
650
+ <div style="display: flex; gap: 0.375rem; margin-top: 0.5rem; flex-wrap: wrap;">
651
+ \${[...s.types].map(t => html\`
652
+ <\${TypeBadge} key=\${t} type=\${t} />
653
+ \`)}
654
+ </div>
655
+ <!-- Show last 3 entries as preview -->
656
+ <div style="margin-top: 0.625rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
657
+ \${s.entries.slice(0, 3).map(entry => html\`
658
+ <div key=\${entry.timestamp + entry.title} style="display: flex; gap: 0.5rem; align-items: baseline; padding: 0.2rem 0; font-size: 0.75rem;">
659
+ <span style="color: var(--text-dim); white-space: nowrap;">\${entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
660
+ <span style="color: var(--text-soft);">\${entry.title}</span>
661
+ </div>
662
+ \`)}
663
+ </div>
664
+ <//>
665
+ \`)}
666
+ </div>
667
+ \`
668
+ }
669
+ </div>
670
+ \`
671
+ }
672
+
673
+ // ================================================================
674
+ // JOURNAL (full feed with type filtering)
675
+ // ================================================================
676
+
677
+ function JournalPage() {
678
+ const { journal, loading } = useContextData()
679
+ const [typeFilter, setTypeFilter] = useState('')
680
+ const [searchFilter, setSearchFilter] = useState('')
681
+
682
+ const types = [...new Set(journal.map(e => e.type))].sort()
683
+
684
+ const filtered = journal.filter(entry => {
685
+ if (typeFilter && entry.type !== typeFilter) return false
686
+ if (searchFilter) {
687
+ const q = searchFilter.toLowerCase()
688
+ return (entry.title || '').toLowerCase().includes(q) ||
689
+ (entry.content || '').toLowerCase().includes(q)
690
+ }
691
+ return true
692
+ })
693
+
694
+ return html\`
695
+ <div>
696
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
697
+ <h2 class="page-title" style="margin-bottom: 0;">Journal</h2>
698
+ <span style="font-size: 0.8rem; color: var(--text-dim);">\${filtered.length} entries</span>
699
+ </div>
700
+
701
+ <div style="display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap;">
702
+ <\${SearchInput}
703
+ value=\${searchFilter}
704
+ onInput=\${e => setSearchFilter(e.target.value)}
705
+ placeholder="Search journal entries..."
706
+ />
707
+ <div style="display: flex; gap: 0.375rem; flex-wrap: wrap; align-items: center;">
708
+ <button
709
+ class=\${'btn ' + (!typeFilter ? 'btn-primary' : 'btn-secondary')}
710
+ style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
711
+ onClick=\${() => setTypeFilter('')}
712
+ >All</button>
713
+ \${types.map(t => html\`
714
+ <button
715
+ key=\${t}
716
+ class=\${'btn ' + (typeFilter === t ? 'btn-primary' : 'btn-secondary')}
717
+ style="font-size: 0.75rem; padding: 0.375rem 0.75rem;"
718
+ onClick=\${() => setTypeFilter(typeFilter === t ? '' : t)}
719
+ >\${t}</button>
720
+ \`)}
721
+ </div>
722
+ </div>
723
+
724
+ \${loading ? html\`<div class="loading">Loading journal</div>\` : html\`
725
+ <\${Card}>
726
+ <div style="max-height: 600px; overflow-y: auto;">
727
+ \${filtered.length === 0 ? html\`
728
+ <div class="empty-state">
729
+ <div style="color: var(--text-dim);">\${searchFilter || typeFilter ? 'No matching entries' : 'No journal entries'}</div>
730
+ </div>
731
+ \` : filtered.map(entry => html\`
732
+ <\${JournalEntryRow} key=\${entry.timestamp + entry.title} entry=\${entry} expanded=\${true} />
733
+ \`)}
734
+ </div>
735
+ <//>
736
+ \`}
737
+ </div>
738
+ \`
739
+ }
740
+ `;
741
+ }
742
+ //# sourceMappingURL=pages.js.map