@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * AgentRow — Expandable agent row with lazy-loaded dispatch prompt.
3
+ *
4
+ * Expansion is keyboard-operable (Enter/Space on button) and scroll-preserving
5
+ * across prop updates (via key-based row identity).
6
+ *
7
+ * Expanded state shows:
8
+ * - Task/status/runtime/tokens + dispatch prompt (fetched from /agent?id=)
9
+ * - Cached with simple eviction (100-item LRU cache per session)
10
+ *
11
+ * D5: all interactive elements are real <button>/<a>, status colors from theme.
12
+ */
13
+
14
+ import { useState, useRef, useEffect } from 'react';
15
+ import type { Agent } from '../lib/types';
16
+ import { fetchAgent } from '../lib/api';
17
+ import { TESTIDS } from '../test/fixtures';
18
+ import './AgentRow.css';
19
+
20
+ interface AgentRowProps {
21
+ agent: Agent;
22
+ }
23
+
24
+ // Simple LRU cache for agent details
25
+ const agentDetailCache = new Map<string, { data: any; timestamp: number }>();
26
+ const MAX_CACHE_SIZE = 100;
27
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
28
+
29
+ function getCachedAgentDetail(id: string): any | null {
30
+ const cached = agentDetailCache.get(id);
31
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
32
+ return cached.data;
33
+ }
34
+ if (cached) {
35
+ agentDetailCache.delete(id);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function setCachedAgentDetail(id: string, data: any) {
41
+ if (agentDetailCache.size >= MAX_CACHE_SIZE) {
42
+ const firstKey = agentDetailCache.keys().next().value;
43
+ if (firstKey !== undefined) {
44
+ agentDetailCache.delete(firstKey);
45
+ }
46
+ }
47
+ agentDetailCache.set(id, { data, timestamp: Date.now() });
48
+ }
49
+
50
+ /**
51
+ * Determine status color.
52
+ */
53
+ function getStatusColor(status: string): string {
54
+ if (status === 'running') return 'var(--color-status-ok)';
55
+ if (status === 'idle') return 'var(--color-status-info)';
56
+ if (status === 'SUSPICIOUS' || status === 'HIGH') return 'var(--color-status-error)';
57
+ if (status === 'MED' || status === 'DRIFT') return 'var(--color-status-warn)';
58
+ return 'var(--color-status-neutral)';
59
+ }
60
+
61
+ export function AgentRow({ agent }: AgentRowProps) {
62
+ const [isExpanded, setIsExpanded] = useState(false);
63
+ const [detail, setDetail] = useState<any | null>(null);
64
+ const [isLoading, setIsLoading] = useState(false);
65
+ const [error, setError] = useState<string | null>(null);
66
+ const expandButtonRef = useRef<HTMLButtonElement>(null);
67
+
68
+ // Load agent detail on expansion
69
+ useEffect(() => {
70
+ if (!isExpanded) return;
71
+
72
+ const cached = getCachedAgentDetail(agent.id);
73
+ if (cached) {
74
+ setDetail(cached);
75
+ return;
76
+ }
77
+
78
+ setIsLoading(true);
79
+ setError(null);
80
+
81
+ fetchAgent(agent.id)
82
+ .then((data) => {
83
+ setCachedAgentDetail(agent.id, data);
84
+ setDetail(data);
85
+ })
86
+ .catch((err) => {
87
+ setError(err.message || 'Failed to load agent details');
88
+ })
89
+ .finally(() => {
90
+ setIsLoading(false);
91
+ });
92
+ }, [isExpanded, agent.id]);
93
+
94
+ const handleToggle = () => {
95
+ setIsExpanded(!isExpanded);
96
+ };
97
+
98
+ return (
99
+ <li className="agent-row" data-testid={TESTIDS.agentRow} key={agent.id}>
100
+ <button
101
+ type="button"
102
+ ref={expandButtonRef}
103
+ className="agent-row__toggle"
104
+ onClick={handleToggle}
105
+ aria-expanded={isExpanded}
106
+ aria-controls={`agent-detail-${agent.id}`}
107
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} agent ${agent.id}`}
108
+ >
109
+ <span className="agent-row__toggle-icon">{isExpanded ? '▼' : '▶'}</span>
110
+ </button>
111
+
112
+ <div className="agent-row__header">
113
+ <span
114
+ className="agent-row__status-icon"
115
+ style={{ color: getStatusColor(agent.status) }}
116
+ aria-label={`Status: ${agent.status}`}
117
+ title={`Status: ${agent.status}`}
118
+ >
119
+
120
+ </span>
121
+
122
+ <span className="agent-row__id">{agent.id}</span>
123
+
124
+ <span className="agent-row__age">
125
+ {agent.age_s < 60 ? `${agent.age_s}s` : `${Math.floor(agent.age_s / 60)}m`}
126
+ </span>
127
+
128
+ <span className="agent-row__hint">{agent.hint}</span>
129
+ </div>
130
+
131
+ {isExpanded && (
132
+ <div className="agent-row__detail" id={`agent-detail-${agent.id}`} data-testid={TESTIDS.agentRowDetail}>
133
+ <div className="agent-row__detail-row">
134
+ <span className="agent-row__detail-label">Project:</span>
135
+ <span className="agent-row__detail-value">{agent.project}</span>
136
+ </div>
137
+
138
+ <div className="agent-row__detail-row">
139
+ <span className="agent-row__detail-label">Status:</span>
140
+ <span
141
+ className="agent-row__detail-value"
142
+ style={{ color: getStatusColor(agent.status) }}
143
+ >
144
+ {agent.status}
145
+ </span>
146
+ </div>
147
+
148
+ <div className="agent-row__detail-row">
149
+ <span className="agent-row__detail-label">Started:</span>
150
+ <span className="agent-row__detail-value">
151
+ {agent.startedAt ? new Date(agent.startedAt).toLocaleString() : 'unknown'}
152
+ </span>
153
+ </div>
154
+
155
+ <div className="agent-row__detail-row">
156
+ <span className="agent-row__detail-label">Last Activity:</span>
157
+ <span className="agent-row__detail-value">
158
+ {agent.lastActivity ? new Date(agent.lastActivity).toLocaleString() : 'unknown'}
159
+ </span>
160
+ </div>
161
+
162
+ <div className="agent-row__detail-row">
163
+ <span className="agent-row__detail-label">Runtime:</span>
164
+ <span className="agent-row__detail-value">
165
+ {typeof agent.runtimeSeconds === 'number'
166
+ ? `${Math.floor(agent.runtimeSeconds / 60)}m ${agent.runtimeSeconds % 60}s`
167
+ : 'unknown'}
168
+ </span>
169
+ </div>
170
+
171
+ <div className="agent-row__detail-row">
172
+ <span className="agent-row__detail-label">Tokens Used:</span>
173
+ <span className="agent-row__detail-value">
174
+ {typeof agent.tokensUsed === 'number' ? agent.tokensUsed.toLocaleString() : 'unknown'}
175
+ </span>
176
+ </div>
177
+
178
+ <div className="agent-row__detail-row">
179
+ <span className="agent-row__detail-label">Task:</span>
180
+ <span className="agent-row__detail-value">{agent.taskLabel}</span>
181
+ </div>
182
+
183
+ {isLoading && (
184
+ <div className="agent-row__prompt">
185
+ <span className="agent-row__prompt-label">Dispatch Prompt:</span>
186
+ <div className="agent-row__prompt-loading">Loading...</div>
187
+ </div>
188
+ )}
189
+
190
+ {error && (
191
+ <div className="agent-row__prompt">
192
+ <span className="agent-row__prompt-label">Dispatch Prompt:</span>
193
+ <div className="agent-row__prompt-error">Error: {error}</div>
194
+ </div>
195
+ )}
196
+
197
+ {detail && !isLoading && (
198
+ <div className="agent-row__prompt">
199
+ <span className="agent-row__prompt-label">Dispatch Prompt:</span>
200
+ <pre className="agent-row__prompt-text">{detail.dispatch_prompt}</pre>
201
+ </div>
202
+ )}
203
+ </div>
204
+ )}
205
+ </li>
206
+ );
207
+ }
@@ -0,0 +1,108 @@
1
+ .agents-panel {
2
+ flex: 1;
3
+ min-width: 0;
4
+ padding: var(--space-5);
5
+ background: var(--color-surface);
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ }
9
+
10
+ .agents-panel h2 {
11
+ margin: 0 0 var(--space-4) 0;
12
+ font-size: var(--font-size-lg);
13
+ font-weight: var(--font-weight-bold);
14
+ color: var(--color-text);
15
+ }
16
+
17
+ .agents-panel__summaries {
18
+ display: grid;
19
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
20
+ gap: var(--space-3);
21
+ margin-bottom: var(--space-4);
22
+ padding: var(--space-3);
23
+ background: var(--color-bg-subtle);
24
+ border: 1px solid var(--color-border-subtle);
25
+ border-radius: var(--radius-md);
26
+ }
27
+
28
+ .agent-summary {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: var(--space-2);
32
+ padding: var(--space-2);
33
+ background: var(--color-surface);
34
+ border: 1px solid var(--color-border-subtle);
35
+ border-radius: var(--radius-sm);
36
+ font-size: var(--font-size-sm);
37
+ }
38
+
39
+ .agent-summary__project {
40
+ flex: 0 0 auto;
41
+ font-weight: var(--font-weight-medium);
42
+ color: var(--color-text);
43
+ min-width: fit-content;
44
+ }
45
+
46
+ .agent-summary__status {
47
+ flex: 0 0 auto;
48
+ padding: var(--space-1) var(--space-2);
49
+ border-radius: var(--radius-sm);
50
+ font-size: var(--font-size-xs);
51
+ font-weight: var(--font-weight-bold);
52
+ text-transform: uppercase;
53
+ white-space: nowrap;
54
+ }
55
+
56
+ .agent-summary__status--running {
57
+ background: var(--color-status-ok-bg);
58
+ color: var(--color-status-ok);
59
+ }
60
+
61
+ .agent-summary__status--idle {
62
+ background: var(--color-status-info-bg);
63
+ color: var(--color-status-info);
64
+ }
65
+
66
+ .agent-summary__status--SUSPICIOUS,
67
+ .agent-summary__status--HIGH {
68
+ background: var(--color-status-error-bg);
69
+ color: var(--color-status-error);
70
+ }
71
+
72
+ .agent-summary__status--MED,
73
+ .agent-summary__status--DRIFT {
74
+ background: var(--color-status-warn-bg);
75
+ color: var(--color-status-warn);
76
+ }
77
+
78
+ .agent-summary__runtime {
79
+ flex: 1;
80
+ text-align: right;
81
+ font-family: var(--font-mono);
82
+ color: var(--color-text-muted);
83
+ min-width: fit-content;
84
+ }
85
+
86
+ .agents-panel__list {
87
+ list-style: none;
88
+ margin: 0;
89
+ padding: 0;
90
+ margin-top: var(--space-4);
91
+ }
92
+
93
+ .empty-state {
94
+ padding: var(--space-4);
95
+ text-align: center;
96
+ color: var(--color-text-muted);
97
+ font-size: var(--font-size-sm);
98
+ }
99
+
100
+ @media (max-width: 768px) {
101
+ .agents-panel {
102
+ padding: var(--space-3);
103
+ }
104
+
105
+ .agents-panel h2 {
106
+ font-size: var(--font-size-md);
107
+ }
108
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { AgentsPanel } from './AgentsPanel';
4
+ import { fixtureAgents, TESTIDS } from '../test/fixtures';
5
+
6
+ vi.mock('./AgentRow', () => ({
7
+ AgentRow: ({ agent }: any) => <div data-testid="agent-row-mock">{agent.id}</div>,
8
+ }));
9
+
10
+ describe('AgentsPanel', () => {
11
+ it('renders agents list with count', () => {
12
+ render(<AgentsPanel agents={fixtureAgents} />);
13
+
14
+ expect(screen.getByText(`Fleet Agents (${fixtureAgents.length})`)).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders each agent row', () => {
18
+ render(<AgentsPanel agents={fixtureAgents} />);
19
+
20
+ const rows = screen.getAllByTestId('agent-row-mock');
21
+ expect(rows).toHaveLength(fixtureAgents.length);
22
+ });
23
+
24
+ it('renders empty state when no agents', () => {
25
+ render(<AgentsPanel agents={[]} />);
26
+
27
+ expect(screen.getByText('No agents running.')).toBeInTheDocument();
28
+ });
29
+
30
+ it('renders empty state when agents is null', () => {
31
+ render(<AgentsPanel agents={null} />);
32
+
33
+ expect(screen.getByText('No agents running.')).toBeInTheDocument();
34
+ });
35
+
36
+ it('has correct data-testid', () => {
37
+ render(<AgentsPanel agents={fixtureAgents} />);
38
+
39
+ expect(screen.getByTestId(TESTIDS.agentRow)).toBeInTheDocument();
40
+ });
41
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * AgentsPanel — Fleet agents list with expandable rows.
3
+ * Each agent row displays project · status · runtime at a glance.
4
+ */
5
+
6
+ import type { Agent } from '../lib/types';
7
+ import { AgentRow } from './AgentRow';
8
+ import { TESTIDS } from '../test/fixtures';
9
+ import './AgentsPanel.css';
10
+
11
+ /**
12
+ * Format runtime in seconds to a readable string.
13
+ */
14
+ function formatRuntime(seconds: number | undefined): string {
15
+ if (!seconds || seconds < 0) return 'unknown';
16
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
17
+ const mins = Math.floor(seconds / 60);
18
+ if (mins < 60) return `${mins}m`;
19
+ const hours = Math.floor(seconds / 3600);
20
+ return `${hours}h`;
21
+ }
22
+
23
+ interface AgentsPanelProps {
24
+ agents: Agent[] | null;
25
+ }
26
+
27
+ export function AgentsPanel({ agents }: AgentsPanelProps) {
28
+ if (!agents || agents.length === 0) {
29
+ return (
30
+ <section className="agents-panel" data-testid={TESTIDS.agentRow}>
31
+ <h2>Fleet Agents</h2>
32
+ <p className="empty-state">No agents running.</p>
33
+ </section>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <section className="agents-panel" data-testid={TESTIDS.agentRow}>
39
+ <h2>Fleet Agents ({agents.length})</h2>
40
+ <div className="agents-panel__summaries">
41
+ {agents.map((agent) => (
42
+ <div key={`${agent.id}-summary`} className="agent-summary">
43
+ <span className="agent-summary__project">{agent.project}</span>
44
+ <span className={`agent-summary__status agent-summary__status--${agent.status}`}>
45
+ {agent.status}
46
+ </span>
47
+ <span className="agent-summary__runtime">{formatRuntime(agent.runtimeSeconds)}</span>
48
+ </div>
49
+ ))}
50
+ </div>
51
+ <ul className="agents-panel__list">
52
+ {agents.map((agent) => (
53
+ <AgentRow key={agent.id} agent={agent} />
54
+ ))}
55
+ </ul>
56
+ </section>
57
+ );
58
+ }
@@ -0,0 +1,88 @@
1
+ .alerts-panel {
2
+ flex: 1;
3
+ min-width: 0;
4
+ padding: var(--space-5);
5
+ background: var(--color-surface);
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ }
9
+
10
+ .alerts-panel h2 {
11
+ margin: 0 0 var(--space-4) 0;
12
+ font-size: var(--font-size-lg);
13
+ font-weight: var(--font-weight-bold);
14
+ color: var(--color-text);
15
+ }
16
+
17
+ .alerts-panel__list {
18
+ list-style: none;
19
+ margin: 0;
20
+ padding: 0;
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--space-3);
24
+ }
25
+
26
+ .alerts-panel__item {
27
+ display: flex;
28
+ gap: var(--space-3);
29
+ align-items: flex-start;
30
+ padding: var(--space-3);
31
+ background: var(--color-bg-subtle);
32
+ border-left: 4px solid var(--color-border);
33
+ border-radius: var(--radius-md);
34
+ font-size: var(--font-size-sm);
35
+ line-height: var(--line-height-normal);
36
+ word-break: break-word;
37
+ }
38
+
39
+ .alerts-panel__item[data-severity='error'] {
40
+ background: var(--color-status-error-bg);
41
+ }
42
+
43
+ .alerts-panel__item[data-severity='warn'] {
44
+ background: var(--color-status-warn-bg);
45
+ }
46
+
47
+ .alerts-panel__item[data-severity='info'] {
48
+ background: var(--color-status-info-bg);
49
+ }
50
+
51
+ .alerts-panel__severity {
52
+ display: inline-block;
53
+ min-width: 50px;
54
+ font-weight: var(--font-weight-bold);
55
+ font-size: var(--font-size-xs);
56
+ text-transform: uppercase;
57
+ letter-spacing: 0.05em;
58
+ flex-shrink: 0;
59
+ }
60
+
61
+ .alerts-panel__text {
62
+ color: var(--color-text);
63
+ font-family: var(--font-mono);
64
+ font-size: var(--font-size-xs);
65
+ flex-grow: 1;
66
+ }
67
+
68
+ .empty-state {
69
+ padding: var(--space-4);
70
+ text-align: center;
71
+ color: var(--color-text-muted);
72
+ font-size: var(--font-size-sm);
73
+ }
74
+
75
+ @media (max-width: 768px) {
76
+ .alerts-panel {
77
+ padding: var(--space-3);
78
+ }
79
+
80
+ .alerts-panel h2 {
81
+ font-size: var(--font-size-md);
82
+ }
83
+
84
+ .alerts-panel__item {
85
+ padding: var(--space-2);
86
+ gap: var(--space-2);
87
+ }
88
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { AlertsPanel } from './AlertsPanel';
4
+ import { fixtureAlerts, fixtureAlertsEmpty, TESTIDS } from '../test/fixtures';
5
+
6
+ describe('AlertsPanel', () => {
7
+ it('renders alerts with severity styling', () => {
8
+ render(<AlertsPanel alerts={fixtureAlerts} />);
9
+
10
+ expect(screen.getByTestId(TESTIDS.alertsPanel)).toBeInTheDocument();
11
+ const alertLines = screen.getAllByTestId(TESTIDS.alertLine);
12
+ expect(alertLines).toHaveLength(fixtureAlerts.lines.length);
13
+ });
14
+
15
+ it('displays alert severity as text', () => {
16
+ render(<AlertsPanel alerts={fixtureAlerts} />);
17
+
18
+ // First line has HIGH
19
+ expect(screen.getByText(/HIGH/)).toBeInTheDocument();
20
+ });
21
+
22
+ it('renders empty state when no alerts', () => {
23
+ render(<AlertsPanel alerts={fixtureAlertsEmpty} />);
24
+
25
+ expect(screen.getByText('No alerts.')).toBeInTheDocument();
26
+ });
27
+
28
+ it('renders empty state when alerts is null', () => {
29
+ render(<AlertsPanel alerts={null} />);
30
+
31
+ expect(screen.getByText('No alerts.')).toBeInTheDocument();
32
+ });
33
+
34
+ it('applies severity-based styling to alert items', () => {
35
+ render(<AlertsPanel alerts={fixtureAlerts} />);
36
+
37
+ const alertItems = screen.getAllByTestId(TESTIDS.alertLine);
38
+ // First alert has HIGH severity
39
+ expect(alertItems[0]).toHaveAttribute('data-severity', 'error');
40
+ // Second alert has MED severity
41
+ expect(alertItems[1]).toHaveAttribute('data-severity', 'warn');
42
+ });
43
+
44
+ it('displays alert text content', () => {
45
+ render(<AlertsPanel alerts={fixtureAlerts} />);
46
+
47
+ fixtureAlerts.lines.forEach((line) => {
48
+ expect(screen.getByText(line)).toBeInTheDocument();
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * AlertsPanel — Security alerts with severity styling.
3
+ */
4
+
5
+ import type { Alert } from '../lib/types';
6
+ import { TESTIDS } from '../test/fixtures';
7
+ import './AlertsPanel.css';
8
+
9
+ interface AlertsPanelProps {
10
+ alerts: Alert | null;
11
+ }
12
+
13
+ /**
14
+ * Determine severity level from alert line.
15
+ */
16
+ function getSeverity(line: string): 'error' | 'warn' | 'info' {
17
+ if (line.includes('HIGH') || line.includes('SUSPICIOUS')) return 'error';
18
+ if (line.includes('MED') || line.includes('DRIFT')) return 'warn';
19
+ return 'info';
20
+ }
21
+
22
+ /**
23
+ * Get color for severity level.
24
+ */
25
+ function getSeverityColor(severity: 'error' | 'warn' | 'info'): string {
26
+ if (severity === 'error') return 'var(--color-status-error)';
27
+ if (severity === 'warn') return 'var(--color-status-warn)';
28
+ return 'var(--color-status-info)';
29
+ }
30
+
31
+ export function AlertsPanel({ alerts }: AlertsPanelProps) {
32
+ const hasAlerts = alerts && alerts.lines.length > 0;
33
+
34
+ return (
35
+ <section className="alerts-panel" data-testid={TESTIDS.alertsPanel}>
36
+ <h2>Security Alerts</h2>
37
+ {!hasAlerts ? (
38
+ <p className="empty-state">No alerts.</p>
39
+ ) : (
40
+ <ul className="alerts-panel__list">
41
+ {alerts.lines.map((line, idx) => {
42
+ const severity = getSeverity(line);
43
+ return (
44
+ <li
45
+ key={idx}
46
+ className="alerts-panel__item"
47
+ data-testid={TESTIDS.alertLine}
48
+ data-severity={severity}
49
+ style={{
50
+ borderLeftColor: getSeverityColor(severity),
51
+ }}
52
+ >
53
+ <span
54
+ className="alerts-panel__severity"
55
+ style={{ color: getSeverityColor(severity) }}
56
+ >
57
+ {severity.toUpperCase()}
58
+ </span>
59
+ <span className="alerts-panel__text">{line}</span>
60
+ </li>
61
+ );
62
+ })}
63
+ </ul>
64
+ )}
65
+ </section>
66
+ );
67
+ }