@tokentop/ttop 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import type {
|
|
5
|
+
PluginType,
|
|
6
|
+
PluginLoadResult,
|
|
7
|
+
PluginValidationResult,
|
|
8
|
+
AnyPlugin,
|
|
9
|
+
} from './types/index.ts';
|
|
10
|
+
import { CURRENT_API_VERSION } from './types/base.ts';
|
|
11
|
+
import { PATHS } from '@/storage/paths.ts';
|
|
12
|
+
|
|
13
|
+
const CUSTOM_PLUGINS_DIR = PATHS.config.plugins;
|
|
14
|
+
|
|
15
|
+
export async function validatePlugin(plugin: unknown): Promise<PluginValidationResult> {
|
|
16
|
+
const errors: string[] = [];
|
|
17
|
+
const warnings: string[] = [];
|
|
18
|
+
|
|
19
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
20
|
+
errors.push('Plugin must be an object');
|
|
21
|
+
return { valid: false, errors, warnings };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const p = plugin as Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
if (typeof p.id !== 'string' || p.id.length === 0) {
|
|
27
|
+
errors.push('Plugin must have a non-empty string "id"');
|
|
28
|
+
} else if (!/^[a-z][a-z0-9-]*$/.test(p.id)) {
|
|
29
|
+
errors.push('Plugin id must be kebab-case (lowercase letters, numbers, hyphens)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const validTypes: PluginType[] = ['provider', 'agent', 'theme', 'notification'];
|
|
33
|
+
if (!validTypes.includes(p.type as PluginType)) {
|
|
34
|
+
errors.push(`Plugin type must be one of: ${validTypes.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof p.name !== 'string' || p.name.length === 0) {
|
|
38
|
+
errors.push('Plugin must have a non-empty string "name"');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof p.version !== 'string' || !/^\d+\.\d+\.\d+/.test(p.version)) {
|
|
42
|
+
errors.push('Plugin must have a valid semver "version" (e.g., "1.0.0")');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!p.permissions || typeof p.permissions !== 'object') {
|
|
46
|
+
errors.push('Plugin must declare "permissions" object');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (p.apiVersion !== CURRENT_API_VERSION) {
|
|
50
|
+
if (typeof p.apiVersion === 'number') {
|
|
51
|
+
errors.push(`Plugin apiVersion ${p.apiVersion} is not compatible (expected ${CURRENT_API_VERSION})`);
|
|
52
|
+
} else {
|
|
53
|
+
errors.push(`Plugin must declare "apiVersion: ${CURRENT_API_VERSION}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (p.type === 'provider') {
|
|
58
|
+
if (!p.auth || typeof p.auth !== 'object') {
|
|
59
|
+
errors.push('Provider plugin must declare "auth" object with discover() and isConfigured()');
|
|
60
|
+
} else {
|
|
61
|
+
const auth = p.auth as Record<string, unknown>;
|
|
62
|
+
if (typeof auth.discover !== 'function') {
|
|
63
|
+
errors.push('Provider plugin auth must implement "discover" method');
|
|
64
|
+
}
|
|
65
|
+
if (typeof auth.isConfigured !== 'function') {
|
|
66
|
+
errors.push('Provider plugin auth must implement "isConfigured" method');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (typeof p.fetchUsage !== 'function') {
|
|
70
|
+
errors.push('Provider plugin must implement "fetchUsage" method');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (p.type === 'agent') {
|
|
75
|
+
if (typeof p.isInstalled !== 'function') {
|
|
76
|
+
errors.push('Agent plugin must implement "isInstalled" method');
|
|
77
|
+
}
|
|
78
|
+
if (typeof p.parseSessions !== 'function') {
|
|
79
|
+
errors.push('Agent plugin must implement "parseSessions" method');
|
|
80
|
+
}
|
|
81
|
+
if (p.readCredentials !== undefined && typeof p.readCredentials !== 'function') {
|
|
82
|
+
errors.push('Agent plugin "readCredentials" must be a function if provided');
|
|
83
|
+
}
|
|
84
|
+
if (p.getProviders !== undefined && typeof p.getProviders !== 'function') {
|
|
85
|
+
errors.push('Agent plugin "getProviders" must be a function if provided');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (p.type === 'theme') {
|
|
90
|
+
if (!['light', 'dark'].includes(p.colorScheme as string)) {
|
|
91
|
+
errors.push('Theme plugin must specify colorScheme as "light" or "dark"');
|
|
92
|
+
}
|
|
93
|
+
if (!p.colors || typeof p.colors !== 'object') {
|
|
94
|
+
errors.push('Theme plugin must provide "colors" object');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (p.type === 'notification') {
|
|
99
|
+
if (typeof p.initialize !== 'function') {
|
|
100
|
+
errors.push('Notification plugin must implement "initialize" method');
|
|
101
|
+
}
|
|
102
|
+
if (typeof p.notify !== 'function') {
|
|
103
|
+
errors.push('Notification plugin must implement "notify" method');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expandTilde(filePath: string): string {
|
|
111
|
+
if (filePath.startsWith('~/') || filePath === '~') {
|
|
112
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
113
|
+
}
|
|
114
|
+
return filePath;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function resolvePluginEntryPoint(dirPath: string): Promise<string | null> {
|
|
118
|
+
try {
|
|
119
|
+
const pkgJsonPath = path.join(dirPath, 'package.json');
|
|
120
|
+
const pkgContent = await fs.readFile(pkgJsonPath, 'utf-8');
|
|
121
|
+
const pkg = JSON.parse(pkgContent) as Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
if (typeof pkg.main === 'string') {
|
|
124
|
+
return path.resolve(dirPath, pkg.main);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pkg.exports && typeof pkg.exports === 'object') {
|
|
128
|
+
const exports = pkg.exports as Record<string, unknown>;
|
|
129
|
+
const root = exports['.'];
|
|
130
|
+
if (typeof root === 'string') return path.resolve(dirPath, root);
|
|
131
|
+
if (root && typeof root === 'object') {
|
|
132
|
+
const rootObj = root as Record<string, unknown>;
|
|
133
|
+
const entry = rootObj.import ?? rootObj.default;
|
|
134
|
+
if (typeof entry === 'string') return path.resolve(dirPath, entry);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// no package.json — try convention-based entry points
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const candidates = ['src/index.ts', 'src/index.js', 'index.ts', 'index.js', 'dist/index.js'];
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
const candidatePath = path.join(dirPath, candidate);
|
|
144
|
+
try {
|
|
145
|
+
await fs.access(candidatePath);
|
|
146
|
+
return candidatePath;
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolvePluginPath(rawPath: string, relativeTo?: string): string {
|
|
156
|
+
const expanded = expandTilde(rawPath);
|
|
157
|
+
if (path.isAbsolute(expanded)) return expanded;
|
|
158
|
+
const base = relativeTo ?? PATHS.config.dir;
|
|
159
|
+
return path.resolve(base, expanded);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function loadLocalPlugin(filePath: string): Promise<PluginLoadResult<AnyPlugin>> {
|
|
163
|
+
try {
|
|
164
|
+
let importPath = filePath;
|
|
165
|
+
|
|
166
|
+
const stat = await fs.stat(filePath);
|
|
167
|
+
if (stat.isDirectory()) {
|
|
168
|
+
const entryPoint = await resolvePluginEntryPoint(filePath);
|
|
169
|
+
if (!entryPoint) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
error: `Directory "${filePath}" has no recognizable entry point (checked package.json main/exports, src/index.ts, index.ts)`,
|
|
173
|
+
source: 'local',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
importPath = entryPoint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const module = await import(importPath);
|
|
180
|
+
const plugin = module.default ?? module.plugin ?? module;
|
|
181
|
+
|
|
182
|
+
const validation = await validatePlugin(plugin);
|
|
183
|
+
if (!validation.valid) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
error: `Validation failed: ${validation.errors.join('; ')}`,
|
|
187
|
+
source: 'local',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
plugin: plugin as AnyPlugin,
|
|
194
|
+
source: 'local',
|
|
195
|
+
};
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
error: err instanceof Error ? err.message : String(err),
|
|
200
|
+
source: 'local',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function loadNpmPlugin(packageName: string, resolvedPath?: string): Promise<PluginLoadResult<AnyPlugin>> {
|
|
206
|
+
const pluginTypes = ['provider', 'agent', 'theme', 'notification'];
|
|
207
|
+
|
|
208
|
+
const cleanName = packageName.replace(/@[^@]*$/, '');
|
|
209
|
+
|
|
210
|
+
const isOfficial = pluginTypes.some((t) => cleanName.startsWith(`@tokentop/${t}-`));
|
|
211
|
+
const isCommunity = pluginTypes.some((t) => cleanName.startsWith(`tokentop-${t}-`));
|
|
212
|
+
const isScopedCommunity = pluginTypes.some((t) => {
|
|
213
|
+
const match = cleanName.match(/^@[^/]+\/tokentop-(\w+)-/);
|
|
214
|
+
return match?.[1] === t;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!isOfficial && !isCommunity && !isScopedCommunity) {
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
error: `Invalid npm plugin name "${packageName}". Expected one of:\n` +
|
|
221
|
+
` Official: @tokentop/{provider,agent,theme,notification}-*\n` +
|
|
222
|
+
` Community: tokentop-{provider,agent,theme,notification}-*\n` +
|
|
223
|
+
` Scoped: @yourname/tokentop-{provider,agent,theme,notification}-*`,
|
|
224
|
+
source: 'npm',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const importTarget = resolvedPath ?? cleanName;
|
|
230
|
+
const module = await import(importTarget);
|
|
231
|
+
const plugin = module.default ?? module.plugin ?? module;
|
|
232
|
+
|
|
233
|
+
const validation = await validatePlugin(plugin);
|
|
234
|
+
if (!validation.valid) {
|
|
235
|
+
return {
|
|
236
|
+
success: false,
|
|
237
|
+
error: `Validation failed: ${validation.errors.join('; ')}`,
|
|
238
|
+
source: 'npm',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
success: true,
|
|
244
|
+
plugin: plugin as AnyPlugin,
|
|
245
|
+
source: 'npm',
|
|
246
|
+
};
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
error: err instanceof Error ? err.message : String(err),
|
|
251
|
+
source: 'npm',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function discoverLocalPlugins(): Promise<string[]> {
|
|
257
|
+
try {
|
|
258
|
+
await fs.access(CUSTOM_PLUGINS_DIR);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const entries = await fs.readdir(CUSTOM_PLUGINS_DIR, { withFileTypes: true });
|
|
264
|
+
const results: string[] = [];
|
|
265
|
+
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const fullPath = path.join(CUSTOM_PLUGINS_DIR, entry.name);
|
|
268
|
+
|
|
269
|
+
if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
|
|
270
|
+
results.push(fullPath);
|
|
271
|
+
} else if (entry.isDirectory()) {
|
|
272
|
+
const entryPoint = await resolvePluginEntryPoint(fullPath);
|
|
273
|
+
if (entryPoint) {
|
|
274
|
+
results.push(fullPath);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { CUSTOM_PLUGINS_DIR };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { NotificationPlugin, NotificationEvent } from './types/notification.ts';
|
|
2
|
+
import type { ProviderUsageData } from './types/provider.ts';
|
|
3
|
+
import { safeInvoke } from './plugin-host.ts';
|
|
4
|
+
import { createPluginLogger } from './sandbox.ts';
|
|
5
|
+
import type { AppConfig } from '@/config/schema.ts';
|
|
6
|
+
|
|
7
|
+
interface DedupEntry {
|
|
8
|
+
key: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEDUP_WINDOW_MS = 5 * 60_000; // 5 minutes — don't re-fire same event within this window
|
|
13
|
+
|
|
14
|
+
class NotificationBus {
|
|
15
|
+
private plugins: NotificationPlugin[] = [];
|
|
16
|
+
private recentEvents = new Map<string, DedupEntry>();
|
|
17
|
+
private pluginConfigs = new Map<string, Record<string, unknown>>();
|
|
18
|
+
|
|
19
|
+
registerPlugins(plugins: NotificationPlugin[]): void {
|
|
20
|
+
this.plugins = plugins;
|
|
21
|
+
for (const plugin of plugins) {
|
|
22
|
+
if (!this.pluginConfigs.has(plugin.id)) {
|
|
23
|
+
this.pluginConfigs.set(plugin.id, { enabled: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setPluginConfig(pluginId: string, config: Record<string, unknown>): void {
|
|
29
|
+
this.pluginConfigs.set(pluginId, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @deprecated Lifecycle hooks are now managed by pluginLifecycle. This is a no-op. */
|
|
33
|
+
async initializePlugins(): Promise<void> {
|
|
34
|
+
// No-op: lifecycle manager calls initialize/start on all plugins.
|
|
35
|
+
// Kept for API compatibility — callers can be removed gradually.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async checkProviderUsage(
|
|
39
|
+
providerId: string,
|
|
40
|
+
providerName: string,
|
|
41
|
+
usage: ProviderUsageData,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (usage.limitReached) {
|
|
44
|
+
await this.emit(`provider.limitReached:${providerId}`, {
|
|
45
|
+
type: 'provider.limitReached',
|
|
46
|
+
severity: 'critical',
|
|
47
|
+
title: `${providerName} Rate Limit Reached`,
|
|
48
|
+
message: `Rate limit reached for ${providerName}. Requests may be throttled.`,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
data: { provider: providerId },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const primaryPercent = usage.limits?.primary?.usedPercent;
|
|
55
|
+
if (primaryPercent !== null && primaryPercent !== undefined && primaryPercent >= 80 && !usage.limitReached) {
|
|
56
|
+
await this.emit(`provider.limitReached:warning:${providerId}`, {
|
|
57
|
+
type: 'provider.limitReached',
|
|
58
|
+
severity: primaryPercent >= 95 ? 'critical' : 'warning',
|
|
59
|
+
title: `${providerName} Approaching Limit`,
|
|
60
|
+
message: `${providerName} usage at ${Math.round(primaryPercent)}%.`,
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
data: { provider: providerId, usedPercent: primaryPercent },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async checkBudget(
|
|
68
|
+
cost: number,
|
|
69
|
+
limit: number,
|
|
70
|
+
budgetType: 'daily' | 'weekly' | 'monthly',
|
|
71
|
+
config: AppConfig,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
if (limit <= 0) return;
|
|
74
|
+
|
|
75
|
+
const percent = (cost / limit) * 100;
|
|
76
|
+
const label = capitalize(budgetType);
|
|
77
|
+
|
|
78
|
+
if (percent >= config.alerts.criticalPercent) {
|
|
79
|
+
await this.emit(`budget.limitReached:${budgetType}`, {
|
|
80
|
+
type: 'budget.limitReached',
|
|
81
|
+
severity: 'critical',
|
|
82
|
+
title: `${label} Budget Critical`,
|
|
83
|
+
message: `${label} spending at ${Math.round(percent)}% ($${cost.toFixed(2)}/$${limit.toFixed(2)}).`,
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
data: { budgetType, cost, limit, percent, currency: config.budgets.currency },
|
|
86
|
+
});
|
|
87
|
+
} else if (percent >= config.alerts.warningPercent) {
|
|
88
|
+
await this.emit(`budget.thresholdCrossed:${budgetType}`, {
|
|
89
|
+
type: 'budget.thresholdCrossed',
|
|
90
|
+
severity: 'warning',
|
|
91
|
+
title: `${label} Budget Warning`,
|
|
92
|
+
message: `${label} spending at ${Math.round(percent)}% ($${cost.toFixed(2)}/$${limit.toFixed(2)}).`,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
data: { budgetType, cost, limit, percent, currency: config.budgets.currency },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async emit(dedupKey: string, event: NotificationEvent): Promise<void> {
|
|
100
|
+
if (this.isDuplicate(dedupKey)) return;
|
|
101
|
+
this.recordEvent(dedupKey);
|
|
102
|
+
|
|
103
|
+
const matchingPlugins = this.plugins.filter((p) => {
|
|
104
|
+
const config = this.pluginConfigs.get(p.id);
|
|
105
|
+
if (config && config.enabled === false) return false;
|
|
106
|
+
if (p.supports) return p.supports(event);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await Promise.all(
|
|
111
|
+
matchingPlugins.map((plugin) => {
|
|
112
|
+
const config = this.pluginConfigs.get(plugin.id) ?? {};
|
|
113
|
+
const logger = createPluginLogger(plugin.id);
|
|
114
|
+
return safeInvoke(plugin.id, 'notify', () =>
|
|
115
|
+
plugin.notify({ logger, config, signal: AbortSignal.timeout(10_000) }, event),
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private isDuplicate(key: string): boolean {
|
|
122
|
+
const recent = this.recentEvents.get(key);
|
|
123
|
+
if (!recent) return false;
|
|
124
|
+
return Date.now() - recent.timestamp < DEDUP_WINDOW_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private recordEvent(key: string): void {
|
|
128
|
+
this.recentEvents.set(key, { key, timestamp: Date.now() });
|
|
129
|
+
this.pruneOldEvents();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private pruneOldEvents(): void {
|
|
133
|
+
const cutoff = Date.now() - DEDUP_WINDOW_MS;
|
|
134
|
+
for (const [key, entry] of this.recentEvents) {
|
|
135
|
+
if (entry.timestamp < cutoff) {
|
|
136
|
+
this.recentEvents.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
destroy(): void {
|
|
142
|
+
this.plugins = [];
|
|
143
|
+
this.recentEvents.clear();
|
|
144
|
+
this.pluginConfigs.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function capitalize(s: string): string {
|
|
149
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const notificationBus = new NotificationBus();
|
|
153
|
+
export type { NotificationBus };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationPlugin,
|
|
3
|
+
NotificationContext,
|
|
4
|
+
NotificationEvent,
|
|
5
|
+
} from '../types/notification.ts';
|
|
6
|
+
|
|
7
|
+
export const terminalBellPlugin: NotificationPlugin = {
|
|
8
|
+
apiVersion: 2,
|
|
9
|
+
id: 'terminal-bell',
|
|
10
|
+
type: 'notification',
|
|
11
|
+
name: 'Terminal Bell',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
|
|
14
|
+
meta: {
|
|
15
|
+
description: 'Simple terminal bell (BEL character) for alerts',
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
permissions: {},
|
|
19
|
+
|
|
20
|
+
configSchema: {
|
|
21
|
+
enabled: { type: 'boolean', default: true, description: 'Enable terminal bell notifications' },
|
|
22
|
+
minSeverity: { type: 'select', default: 'warning', options: [
|
|
23
|
+
{ value: 'info', label: 'Info' },
|
|
24
|
+
{ value: 'warning', label: 'Warning' },
|
|
25
|
+
{ value: 'critical', label: 'Critical' },
|
|
26
|
+
]},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
supports(event: NotificationEvent): boolean {
|
|
30
|
+
return event.type.startsWith('budget.') ||
|
|
31
|
+
event.type.startsWith('provider.') ||
|
|
32
|
+
event.type === 'plugin.crashed';
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async initialize(ctx: NotificationContext): Promise<void> {
|
|
36
|
+
ctx.logger.debug('Terminal bell notification plugin initialized');
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
|
|
40
|
+
const severityOrder = ['info', 'warning', 'critical'];
|
|
41
|
+
const minSeverity = (ctx.config.minSeverity as string) ?? 'warning';
|
|
42
|
+
|
|
43
|
+
if (severityOrder.indexOf(event.severity) < severityOrder.indexOf(minSeverity)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bellCount = event.severity === 'critical' ? 3 : 1;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < bellCount; i++) {
|
|
50
|
+
process.stdout.write('\x07');
|
|
51
|
+
if (i < bellCount - 1) {
|
|
52
|
+
await sleep(200);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async test(ctx: NotificationContext): Promise<boolean> {
|
|
58
|
+
ctx.logger.info('Testing terminal bell...');
|
|
59
|
+
process.stdout.write('\x07');
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function sleep(ms: number): Promise<void> {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationPlugin,
|
|
3
|
+
NotificationContext,
|
|
4
|
+
NotificationEvent,
|
|
5
|
+
} from '../types/notification.ts';
|
|
6
|
+
|
|
7
|
+
export const visualFlashPlugin: NotificationPlugin = {
|
|
8
|
+
apiVersion: 2,
|
|
9
|
+
id: 'visual-flash',
|
|
10
|
+
type: 'notification',
|
|
11
|
+
name: 'Visual Flash',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
|
|
14
|
+
meta: {
|
|
15
|
+
description: 'Visual screen flash using ANSI escape sequences',
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
permissions: {},
|
|
19
|
+
|
|
20
|
+
configSchema: {
|
|
21
|
+
enabled: { type: 'boolean', default: true, description: 'Enable visual flash notifications' },
|
|
22
|
+
duration: { type: 'number', default: 100, description: 'Flash duration in milliseconds' },
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
supports(event: NotificationEvent): boolean {
|
|
26
|
+
return event.type.startsWith('budget.') ||
|
|
27
|
+
event.type.startsWith('provider.') ||
|
|
28
|
+
event.type === 'plugin.crashed';
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async initialize(ctx: NotificationContext): Promise<void> {
|
|
32
|
+
ctx.logger.debug('Visual flash notification plugin initialized');
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
|
|
36
|
+
const duration = (ctx.config.duration as number) ?? 100;
|
|
37
|
+
|
|
38
|
+
const colorCode = event.severity === 'critical' ? '41' :
|
|
39
|
+
event.severity === 'warning' ? '43' : '42';
|
|
40
|
+
|
|
41
|
+
process.stdout.write(`\x1b[${colorCode}m`);
|
|
42
|
+
await sleep(duration);
|
|
43
|
+
process.stdout.write('\x1b[0m');
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async test(ctx: NotificationContext): Promise<boolean> {
|
|
47
|
+
ctx.logger.info('Testing visual flash...');
|
|
48
|
+
process.stdout.write('\x1b[44m');
|
|
49
|
+
await sleep(100);
|
|
50
|
+
process.stdout.write('\x1b[0m');
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number): Promise<void> {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|