@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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,164 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ const CACHE_DIR = path.join(os.homedir(), '.cache/tokentop');
6
+ const PACKAGE_JSON_PATH = path.join(CACHE_DIR, 'package.json');
7
+ const NODE_MODULES = path.join(CACHE_DIR, 'node_modules');
8
+
9
+ interface CachePackageJson {
10
+ name: string;
11
+ private: boolean;
12
+ dependencies: Record<string, string>;
13
+ }
14
+
15
+ async function ensureCacheDir(): Promise<void> {
16
+ await fs.mkdir(CACHE_DIR, { recursive: true });
17
+
18
+ try {
19
+ await fs.access(PACKAGE_JSON_PATH);
20
+ } catch {
21
+ const skeleton: CachePackageJson = {
22
+ name: 'tokentop-plugin-cache',
23
+ private: true,
24
+ dependencies: {},
25
+ };
26
+ await fs.writeFile(PACKAGE_JSON_PATH, JSON.stringify(skeleton, null, 2));
27
+ }
28
+ }
29
+
30
+ function parsePackageSpec(spec: string): { name: string; version: string } {
31
+ const atIndex = spec.lastIndexOf('@');
32
+ if (atIndex > 0) {
33
+ return { name: spec.slice(0, atIndex), version: spec.slice(atIndex + 1) };
34
+ }
35
+ return { name: spec, version: 'latest' };
36
+ }
37
+
38
+ async function isInstalled(name: string, version: string): Promise<boolean> {
39
+ try {
40
+ const pkgJsonPath = path.join(NODE_MODULES, name, 'package.json');
41
+ const raw = await fs.readFile(pkgJsonPath, 'utf-8');
42
+ const pkg = JSON.parse(raw) as { version?: string };
43
+
44
+ if (version === 'latest') {
45
+ return !!pkg.version;
46
+ }
47
+ return pkg.version === version;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ export interface InstallResult {
54
+ name: string;
55
+ version: string;
56
+ installed: boolean;
57
+ error?: string;
58
+ resolvedPath: string;
59
+ }
60
+
61
+ export async function installNpmPlugin(spec: string): Promise<InstallResult> {
62
+ const { name, version } = parsePackageSpec(spec);
63
+ const resolvedPath = path.join(NODE_MODULES, name);
64
+
65
+ await ensureCacheDir();
66
+
67
+ if (await isInstalled(name, version)) {
68
+ return { name, version, installed: false, resolvedPath };
69
+ }
70
+
71
+ const versionArg = version === 'latest' ? name : `${name}@${version}`;
72
+
73
+ try {
74
+ const proc = Bun.spawn(
75
+ ['bun', 'add', '--force', '--exact', '--cwd', CACHE_DIR, versionArg],
76
+ { stdout: 'pipe', stderr: 'pipe' },
77
+ );
78
+
79
+ const exitCode = await proc.exited;
80
+
81
+ if (exitCode !== 0) {
82
+ const stderr = await new Response(proc.stderr).text();
83
+ return {
84
+ name,
85
+ version,
86
+ installed: false,
87
+ error: `bun add failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`,
88
+ resolvedPath,
89
+ };
90
+ }
91
+
92
+ return { name, version, installed: true, resolvedPath };
93
+ } catch (err) {
94
+ return {
95
+ name,
96
+ version,
97
+ installed: false,
98
+ error: err instanceof Error ? err.message : String(err),
99
+ resolvedPath,
100
+ };
101
+ }
102
+ }
103
+
104
+ export async function installAllNpmPlugins(
105
+ specs: string[],
106
+ ): Promise<InstallResult[]> {
107
+ if (specs.length === 0) return [];
108
+
109
+ await ensureCacheDir();
110
+
111
+ const results: InstallResult[] = [];
112
+ const toInstall: string[] = [];
113
+
114
+ for (const spec of specs) {
115
+ const { name, version } = parsePackageSpec(spec);
116
+ const resolvedPath = path.join(NODE_MODULES, name);
117
+
118
+ if (await isInstalled(name, version)) {
119
+ results.push({ name, version, installed: false, resolvedPath });
120
+ } else {
121
+ toInstall.push(version === 'latest' ? name : `${name}@${version}`);
122
+ results.push({ name, version, installed: true, resolvedPath });
123
+ }
124
+ }
125
+
126
+ if (toInstall.length === 0) return results;
127
+
128
+ try {
129
+ const proc = Bun.spawn(
130
+ ['bun', 'add', '--force', '--exact', '--cwd', CACHE_DIR, ...toInstall],
131
+ { stdout: 'pipe', stderr: 'pipe' },
132
+ );
133
+
134
+ const exitCode = await proc.exited;
135
+
136
+ if (exitCode !== 0) {
137
+ const stderr = await new Response(proc.stderr).text();
138
+ const errMsg = `bun add failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`;
139
+ for (const r of results) {
140
+ if (r.installed) {
141
+ r.installed = false;
142
+ r.error = errMsg;
143
+ }
144
+ }
145
+ }
146
+ } catch (err) {
147
+ const errMsg = err instanceof Error ? err.message : String(err);
148
+ for (const r of results) {
149
+ if (r.installed) {
150
+ r.installed = false;
151
+ r.error = errMsg;
152
+ }
153
+ }
154
+ }
155
+
156
+ return results;
157
+ }
158
+
159
+ export function resolveNpmPluginPath(packageName: string): string {
160
+ const { name } = parsePackageSpec(packageName);
161
+ return path.join(NODE_MODULES, name);
162
+ }
163
+
164
+ export { CACHE_DIR, NODE_MODULES };
@@ -0,0 +1,67 @@
1
+ import type { PluginPermissions } from './types/base.ts';
2
+ import type { PluginContext, PluginStorage } from './types/provider.ts';
3
+ import { createAuthSources } from './auth-sources.ts';
4
+ import { createSandboxedHttpClient, createPluginLogger } from './sandbox.ts';
5
+ import { deepFreeze } from './sandbox-guard.ts';
6
+ import {
7
+ pluginStorageGet,
8
+ pluginStorageSet,
9
+ pluginStorageDelete,
10
+ pluginStorageHas,
11
+ getDatabase,
12
+ } from '@/storage/database.ts';
13
+
14
+ let dbAvailable: boolean | null = null;
15
+
16
+ function isDbAvailable(): boolean {
17
+ if (dbAvailable !== null) return dbAvailable;
18
+ try {
19
+ getDatabase();
20
+ dbAvailable = true;
21
+ } catch {
22
+ dbAvailable = false;
23
+ }
24
+ return dbAvailable;
25
+ }
26
+
27
+ function createPluginStorage(pluginId: string): PluginStorage {
28
+ return {
29
+ async get(key: string): Promise<string | null> {
30
+ if (!isDbAvailable()) return null;
31
+ return pluginStorageGet(pluginId, key);
32
+ },
33
+ async set(key: string, value: string): Promise<void> {
34
+ if (!isDbAvailable()) return;
35
+ pluginStorageSet(pluginId, key, value);
36
+ },
37
+ async delete(key: string): Promise<void> {
38
+ if (!isDbAvailable()) return;
39
+ pluginStorageDelete(pluginId, key);
40
+ },
41
+ async has(key: string): Promise<boolean> {
42
+ if (!isDbAvailable()) return false;
43
+ return pluginStorageHas(pluginId, key);
44
+ },
45
+ };
46
+ }
47
+
48
+ export function resetDbAvailableCache(): void {
49
+ dbAvailable = null;
50
+ }
51
+
52
+ export function createPluginContext(
53
+ pluginId: string,
54
+ permissions: PluginPermissions,
55
+ signal?: AbortSignal
56
+ ): PluginContext {
57
+ const ctx: PluginContext = {
58
+ config: {},
59
+ logger: createPluginLogger(pluginId),
60
+ http: createSandboxedHttpClient(pluginId, permissions),
61
+ authSources: createAuthSources(pluginId, permissions),
62
+ storage: createPluginStorage(pluginId),
63
+ signal: signal ?? AbortSignal.timeout(30_000),
64
+ };
65
+ deepFreeze(ctx.config);
66
+ return ctx;
67
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Plugin host — error isolation and circuit breaker for plugin method calls.
3
+ *
4
+ * Every plugin method invocation should go through `safeInvoke` to ensure:
5
+ * 1. Unhandled errors never crash the app
6
+ * 2. Plugins that fail repeatedly are temporarily disabled (circuit breaker)
7
+ * 3. All errors are logged through the scoped plugin logger
8
+ */
9
+
10
+ import { createPluginLogger } from './sandbox.ts';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Circuit breaker state
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface CircuitState {
17
+ consecutiveFailures: number;
18
+ lastFailureAt: number;
19
+ disabledUntil: number;
20
+ totalFailures: number;
21
+ totalCalls: number;
22
+ }
23
+
24
+ /** How many consecutive failures before disabling a plugin. */
25
+ const MAX_CONSECUTIVE_FAILURES = 5;
26
+
27
+ /** How long (ms) a tripped plugin stays disabled before retrying. */
28
+ const COOLDOWN_MS = 60_000; // 1 minute
29
+
30
+ const circuits = new Map<string, CircuitState>();
31
+
32
+ function getCircuit(pluginId: string): CircuitState {
33
+ let state = circuits.get(pluginId);
34
+ if (!state) {
35
+ state = {
36
+ consecutiveFailures: 0,
37
+ lastFailureAt: 0,
38
+ disabledUntil: 0,
39
+ totalFailures: 0,
40
+ totalCalls: 0,
41
+ };
42
+ circuits.set(pluginId, state);
43
+ }
44
+ return state;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public API
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export type SafeInvokeResult<T> =
52
+ | { ok: true; value: T }
53
+ | { ok: false; error: Error; circuitOpen: boolean };
54
+
55
+ /**
56
+ * Safely invoke a plugin method with circuit breaker protection.
57
+ *
58
+ * @param pluginId Unique plugin identifier (for circuit tracking + logging)
59
+ * @param method Human-readable method name (for logging, e.g. "fetchUsage")
60
+ * @param fn The actual async function to call
61
+ * @returns `{ ok: true, value }` or `{ ok: false, error, circuitOpen }`
62
+ */
63
+ export async function safeInvoke<T>(
64
+ pluginId: string,
65
+ method: string,
66
+ fn: () => Promise<T>,
67
+ ): Promise<SafeInvokeResult<T>> {
68
+ const circuit = getCircuit(pluginId);
69
+ const log = createPluginLogger(pluginId);
70
+ circuit.totalCalls++;
71
+
72
+ // Check if circuit is open (plugin disabled)
73
+ if (circuit.disabledUntil > Date.now()) {
74
+ log.warn(`Circuit open — skipping ${method} (disabled until ${new Date(circuit.disabledUntil).toISOString()})`);
75
+ return {
76
+ ok: false,
77
+ error: new Error(`Plugin "${pluginId}" is temporarily disabled after ${circuit.consecutiveFailures} consecutive failures`),
78
+ circuitOpen: true,
79
+ };
80
+ }
81
+
82
+ // If we were disabled but cooldown expired, allow a retry (half-open)
83
+ if (circuit.disabledUntil > 0 && circuit.disabledUntil <= Date.now()) {
84
+ log.info(`Circuit half-open — retrying ${method}`);
85
+ }
86
+
87
+ try {
88
+ const value = await fn();
89
+
90
+ // Success — reset consecutive failures
91
+ if (circuit.consecutiveFailures > 0) {
92
+ log.info(`${method} succeeded — resetting circuit breaker (was at ${circuit.consecutiveFailures} failures)`);
93
+ }
94
+ circuit.consecutiveFailures = 0;
95
+ circuit.disabledUntil = 0;
96
+
97
+ return { ok: true, value };
98
+ } catch (err) {
99
+ const error = err instanceof Error ? err : new Error(String(err));
100
+
101
+ circuit.consecutiveFailures++;
102
+ circuit.totalFailures++;
103
+ circuit.lastFailureAt = Date.now();
104
+
105
+ log.error(`${method} failed (${circuit.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`, {
106
+ error: error.message,
107
+ });
108
+
109
+ // Trip the circuit if threshold reached
110
+ if (circuit.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
111
+ circuit.disabledUntil = Date.now() + COOLDOWN_MS;
112
+ log.error(
113
+ `Circuit OPEN — plugin disabled for ${COOLDOWN_MS / 1000}s after ${circuit.consecutiveFailures} consecutive failures`,
114
+ );
115
+ }
116
+
117
+ return { ok: false, error, circuitOpen: false };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Synchronous version of safeInvoke for non-async plugin methods.
123
+ */
124
+ export function safeInvokeSync<T>(
125
+ pluginId: string,
126
+ method: string,
127
+ fn: () => T,
128
+ ): SafeInvokeResult<T> {
129
+ const circuit = getCircuit(pluginId);
130
+ const log = createPluginLogger(pluginId);
131
+ circuit.totalCalls++;
132
+
133
+ if (circuit.disabledUntil > Date.now()) {
134
+ log.warn(`Circuit open — skipping ${method}`);
135
+ return {
136
+ ok: false,
137
+ error: new Error(`Plugin "${pluginId}" is temporarily disabled`),
138
+ circuitOpen: true,
139
+ };
140
+ }
141
+
142
+ try {
143
+ const value = fn();
144
+ circuit.consecutiveFailures = 0;
145
+ circuit.disabledUntil = 0;
146
+ return { ok: true, value };
147
+ } catch (err) {
148
+ const error = err instanceof Error ? err : new Error(String(err));
149
+ circuit.consecutiveFailures++;
150
+ circuit.totalFailures++;
151
+ circuit.lastFailureAt = Date.now();
152
+
153
+ log.error(`${method} failed (${circuit.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`, {
154
+ error: error.message,
155
+ });
156
+
157
+ if (circuit.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
158
+ circuit.disabledUntil = Date.now() + COOLDOWN_MS;
159
+ log.error(`Circuit OPEN — plugin disabled for ${COOLDOWN_MS / 1000}s`);
160
+ }
161
+
162
+ return { ok: false, error, circuitOpen: false };
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Health inspection
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export interface PluginHealth {
171
+ pluginId: string;
172
+ healthy: boolean;
173
+ consecutiveFailures: number;
174
+ totalFailures: number;
175
+ totalCalls: number;
176
+ disabledUntil: number | null;
177
+ }
178
+
179
+ /** Get health status for a specific plugin. */
180
+ export function getPluginHealth(pluginId: string): PluginHealth {
181
+ const circuit = getCircuit(pluginId);
182
+ return {
183
+ pluginId,
184
+ healthy: circuit.disabledUntil < Date.now() && circuit.consecutiveFailures < MAX_CONSECUTIVE_FAILURES,
185
+ consecutiveFailures: circuit.consecutiveFailures,
186
+ totalFailures: circuit.totalFailures,
187
+ totalCalls: circuit.totalCalls,
188
+ disabledUntil: circuit.disabledUntil > Date.now() ? circuit.disabledUntil : null,
189
+ };
190
+ }
191
+
192
+ /** Get health status for all tracked plugins. */
193
+ export function getAllPluginHealth(): PluginHealth[] {
194
+ return [...circuits.keys()].map(getPluginHealth);
195
+ }
196
+
197
+ /** Reset circuit breaker for a specific plugin (manual recovery). */
198
+ export function resetCircuit(pluginId: string): void {
199
+ circuits.delete(pluginId);
200
+ }
201
+
202
+ /** Reset all circuit breakers. */
203
+ export function resetAllCircuits(): void {
204
+ circuits.clear();
205
+ }