@mandujs/mcp 0.12.2 → 0.13.0

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 (141) hide show
  1. package/README.md +367 -367
  2. package/package.json +2 -2
  3. package/src/activity-monitor.ts +847 -847
  4. package/src/adapters/index.ts +20 -20
  5. package/src/adapters/monitor-adapter.ts +100 -100
  6. package/src/adapters/tool-adapter.ts +88 -88
  7. package/src/executor/error-handler.ts +250 -250
  8. package/src/executor/index.ts +22 -22
  9. package/src/executor/tool-executor.ts +148 -148
  10. package/src/hooks/config-watcher.ts +174 -174
  11. package/src/hooks/index.ts +23 -23
  12. package/src/hooks/mcp-hooks.ts +227 -227
  13. package/src/index.ts +106 -106
  14. package/src/logging/index.ts +15 -15
  15. package/src/logging/mcp-transport.ts +134 -134
  16. package/src/registry/index.ts +13 -13
  17. package/src/registry/mcp-tool-registry.ts +298 -298
  18. package/src/resources/skills/guides.ts +1136 -1136
  19. package/src/resources/skills/index.ts +12 -12
  20. package/src/resources/skills/loader.ts +218 -218
  21. package/src/resources/skills/mandu-composition/SKILL.md +91 -91
  22. package/src/resources/skills/mandu-composition/metadata.json +13 -13
  23. package/src/resources/skills/mandu-composition/rules/_sections.md +26 -26
  24. package/src/resources/skills/mandu-composition/rules/_template.md +77 -77
  25. package/src/resources/skills/mandu-composition/rules/comp-arch-avoid-boolean-props.md +146 -146
  26. package/src/resources/skills/mandu-composition/rules/comp-arch-compound-components.md +164 -164
  27. package/src/resources/skills/mandu-composition/rules/comp-island-event.md +161 -161
  28. package/src/resources/skills/mandu-composition/rules/comp-island-slot-split.md +167 -167
  29. package/src/resources/skills/mandu-composition/rules/comp-pattern-children.md +149 -149
  30. package/src/resources/skills/mandu-composition/rules/comp-state-context-interface.md +148 -148
  31. package/src/resources/skills/mandu-composition/rules/comp-state-lift-state.md +150 -150
  32. package/src/resources/skills/mandu-deployment/SKILL.md +92 -92
  33. package/src/resources/skills/mandu-deployment/_sections.md +41 -41
  34. package/src/resources/skills/mandu-deployment/_template.md +38 -38
  35. package/src/resources/skills/mandu-deployment/metadata.json +13 -13
  36. package/src/resources/skills/mandu-deployment/rules/deploy-build-bun.md +109 -109
  37. package/src/resources/skills/mandu-deployment/rules/deploy-build-output.md +115 -115
  38. package/src/resources/skills/mandu-deployment/rules/deploy-cicd-github.md +219 -219
  39. package/src/resources/skills/mandu-deployment/rules/deploy-docker-bun.md +150 -150
  40. package/src/resources/skills/mandu-deployment/rules/deploy-docker-compose.md +223 -223
  41. package/src/resources/skills/mandu-deployment/rules/deploy-platform-fly.md +152 -152
  42. package/src/resources/skills/mandu-deployment/rules/deploy-platform-render.md +179 -179
  43. package/src/resources/skills/mandu-deployment/rules/deploy-platform-supabase.md +323 -323
  44. package/src/resources/skills/mandu-deployment/rules/deploy-platform-vercel.md +140 -140
  45. package/src/resources/skills/mandu-fs-routes/SKILL.md +82 -82
  46. package/src/resources/skills/mandu-fs-routes/metadata.json +12 -12
  47. package/src/resources/skills/mandu-fs-routes/rules/_sections.md +36 -36
  48. package/src/resources/skills/mandu-fs-routes/rules/_template.md +69 -69
  49. package/src/resources/skills/mandu-fs-routes/rules/routes-api-methods.md +65 -65
  50. package/src/resources/skills/mandu-fs-routes/rules/routes-dynamic-param.md +93 -93
  51. package/src/resources/skills/mandu-fs-routes/rules/routes-naming-page.md +55 -55
  52. package/src/resources/skills/mandu-guard/SKILL.md +129 -129
  53. package/src/resources/skills/mandu-guard/metadata.json +12 -12
  54. package/src/resources/skills/mandu-guard/rules/_sections.md +36 -36
  55. package/src/resources/skills/mandu-guard/rules/_template.md +82 -82
  56. package/src/resources/skills/mandu-guard/rules/guard-config-rules.md +100 -100
  57. package/src/resources/skills/mandu-guard/rules/guard-layer-direction.md +76 -76
  58. package/src/resources/skills/mandu-guard/rules/guard-preset-mandu.md +81 -81
  59. package/src/resources/skills/mandu-guard/rules/guard-validate-import.md +80 -80
  60. package/src/resources/skills/mandu-hydration/SKILL.md +91 -91
  61. package/src/resources/skills/mandu-hydration/metadata.json +12 -12
  62. package/src/resources/skills/mandu-hydration/rules/_sections.md +31 -31
  63. package/src/resources/skills/mandu-hydration/rules/_template.md +72 -72
  64. package/src/resources/skills/mandu-hydration/rules/hydration-data-event.md +109 -109
  65. package/src/resources/skills/mandu-hydration/rules/hydration-directive-use-client.md +55 -55
  66. package/src/resources/skills/mandu-hydration/rules/hydration-island-setup.md +113 -113
  67. package/src/resources/skills/mandu-hydration/rules/hydration-priority-visible.md +68 -68
  68. package/src/resources/skills/mandu-performance/SKILL.md +85 -85
  69. package/src/resources/skills/mandu-performance/metadata.json +14 -14
  70. package/src/resources/skills/mandu-performance/rules/_sections.md +31 -31
  71. package/src/resources/skills/mandu-performance/rules/_template.md +64 -64
  72. package/src/resources/skills/mandu-performance/rules/perf-async-defer-await.md +103 -103
  73. package/src/resources/skills/mandu-performance/rules/perf-async-parallel.md +95 -95
  74. package/src/resources/skills/mandu-performance/rules/perf-bun-file.md +124 -124
  75. package/src/resources/skills/mandu-performance/rules/perf-bun-serve.md +125 -125
  76. package/src/resources/skills/mandu-performance/rules/perf-bundle-imports.md +80 -80
  77. package/src/resources/skills/mandu-performance/rules/perf-bundle-island-lazy.md +145 -145
  78. package/src/resources/skills/mandu-performance/rules/perf-cache-react.md +98 -98
  79. package/src/resources/skills/mandu-performance/rules/perf-render-transitions.md +154 -154
  80. package/src/resources/skills/mandu-security/SKILL.md +87 -87
  81. package/src/resources/skills/mandu-security/metadata.json +13 -13
  82. package/src/resources/skills/mandu-security/rules/_sections.md +31 -31
  83. package/src/resources/skills/mandu-security/rules/_template.md +74 -74
  84. package/src/resources/skills/mandu-security/rules/sec-auth-guard.md +127 -127
  85. package/src/resources/skills/mandu-security/rules/sec-env-management.md +133 -133
  86. package/src/resources/skills/mandu-security/rules/sec-input-validate.md +148 -148
  87. package/src/resources/skills/mandu-security/rules/sec-protect-csrf.md +146 -146
  88. package/src/resources/skills/mandu-security/rules/sec-protect-headers.md +138 -138
  89. package/src/resources/skills/mandu-slot/SKILL.md +85 -85
  90. package/src/resources/skills/mandu-slot/metadata.json +12 -12
  91. package/src/resources/skills/mandu-slot/rules/_sections.md +36 -36
  92. package/src/resources/skills/mandu-slot/rules/_template.md +63 -63
  93. package/src/resources/skills/mandu-slot/rules/slot-basic-structure.md +38 -38
  94. package/src/resources/skills/mandu-slot/rules/slot-ctx-response.md +56 -56
  95. package/src/resources/skills/mandu-slot/rules/slot-guard-auth.md +59 -59
  96. package/src/resources/skills/mandu-slot/rules/slot-http-methods.md +64 -64
  97. package/src/resources/skills/mandu-styling/SKILL.md +154 -154
  98. package/src/resources/skills/mandu-styling/_sections.md +43 -43
  99. package/src/resources/skills/mandu-styling/_template.md +32 -32
  100. package/src/resources/skills/mandu-styling/metadata.json +15 -15
  101. package/src/resources/skills/mandu-styling/rules/style-component-compound.md +235 -235
  102. package/src/resources/skills/mandu-styling/rules/style-component-slots.md +255 -255
  103. package/src/resources/skills/mandu-styling/rules/style-component-tokens.md +205 -205
  104. package/src/resources/skills/mandu-styling/rules/style-island-animations.md +272 -272
  105. package/src/resources/skills/mandu-styling/rules/style-island-scoping.md +167 -167
  106. package/src/resources/skills/mandu-styling/rules/style-island-variants.md +221 -221
  107. package/src/resources/skills/mandu-styling/rules/style-perf-critical.md +209 -209
  108. package/src/resources/skills/mandu-styling/rules/style-perf-purge.md +192 -192
  109. package/src/resources/skills/mandu-styling/rules/style-setup-modules.md +162 -162
  110. package/src/resources/skills/mandu-styling/rules/style-setup-panda.md +164 -164
  111. package/src/resources/skills/mandu-styling/rules/style-setup-tailwind.md +170 -170
  112. package/src/resources/skills/mandu-styling/rules/style-tailwind-v4-gotchas.md +179 -179
  113. package/src/resources/skills/mandu-styling/rules/style-theme-darkmode.md +229 -229
  114. package/src/resources/skills/mandu-testing/SKILL.md +99 -99
  115. package/src/resources/skills/mandu-testing/metadata.json +13 -13
  116. package/src/resources/skills/mandu-testing/rules/_sections.md +26 -26
  117. package/src/resources/skills/mandu-testing/rules/_template.md +65 -65
  118. package/src/resources/skills/mandu-testing/rules/test-component-island.md +195 -195
  119. package/src/resources/skills/mandu-testing/rules/test-e2e-playwright.md +196 -196
  120. package/src/resources/skills/mandu-testing/rules/test-mock-fetch.md +219 -219
  121. package/src/resources/skills/mandu-testing/rules/test-slot-unit.md +192 -192
  122. package/src/resources/skills/mandu-ui/SKILL.md +117 -117
  123. package/src/resources/skills/mandu-ui/_sections.md +23 -23
  124. package/src/resources/skills/mandu-ui/_template.md +32 -32
  125. package/src/resources/skills/mandu-ui/metadata.json +13 -13
  126. package/src/resources/skills/mandu-ui/rules/ui-accessibility-aria.md +232 -232
  127. package/src/resources/skills/mandu-ui/rules/ui-accessibility-focus.md +238 -238
  128. package/src/resources/skills/mandu-ui/rules/ui-composition-patterns.md +259 -259
  129. package/src/resources/skills/mandu-ui/rules/ui-island-integration.md +258 -258
  130. package/src/resources/skills/mandu-ui/rules/ui-radix-patterns.md +213 -213
  131. package/src/resources/skills/mandu-ui/rules/ui-shadcn-setup.md +209 -209
  132. package/src/resources/skills/recipes.ts +932 -932
  133. package/src/tools/generate.ts +7 -4
  134. package/src/tools/guard.ts +17 -4
  135. package/src/tools/hydration.ts +10 -10
  136. package/src/tools/project.ts +334 -334
  137. package/src/tools/runtime.ts +497 -497
  138. package/src/tools/seo.ts +417 -417
  139. package/src/tools/spec.ts +80 -159
  140. package/src/utils/project.ts +22 -12
  141. package/src/utils/withWarnings.ts +83 -83
@@ -1,847 +1,847 @@
1
- /**
2
- * Mandu Activity Monitor
3
- *
4
- * CLI-first real-time monitor for MCP server activity.
5
- * Supports pretty (human) and JSON (agent) outputs with dedupe + batching.
6
- */
7
-
8
- import fs from "fs";
9
- import path from "path";
10
- import { spawn, type ChildProcess } from "child_process";
11
-
12
- const TOOL_ICONS: Record<string, string> = {
13
- // Spec
14
- mandu_list_routes: "SPEC",
15
- mandu_get_route: "SPEC",
16
- mandu_add_route: "SPEC+",
17
- mandu_update_route: "SPEC~",
18
- mandu_delete_route: "SPEC-",
19
- mandu_validate_spec: "SPEC?",
20
- // Generate
21
- mandu_generate: "GEN",
22
- mandu_generate_status: "GEN?",
23
- // Guard
24
- mandu_guard_check: "GUARD",
25
- // Slot
26
- mandu_read_slot: "SLOT",
27
- mandu_write_slot: "SLOT~",
28
- mandu_validate_slot: "SLOT?",
29
- // Contract
30
- mandu_list_contracts: "CONTRACT",
31
- mandu_get_contract: "CONTRACT",
32
- mandu_create_contract: "CONTRACT+",
33
- mandu_validate_contracts: "CONTRACT?",
34
- mandu_sync_contract_slot: "SYNC",
35
- mandu_generate_openapi: "OPENAPI",
36
- mandu_update_route_contract: "CONTRACT~",
37
- // Transaction
38
- mandu_begin: "TX-BEGIN",
39
- mandu_commit: "TX-COMMIT",
40
- mandu_rollback: "TX-ROLLBACK",
41
- mandu_tx_status: "TX?",
42
- // History
43
- mandu_list_history: "HISTORY",
44
- mandu_get_snapshot: "SNAPSHOT",
45
- mandu_prune_history: "PRUNE",
46
- // Brain
47
- mandu_doctor: "DOCTOR",
48
- mandu_watch_start: "WATCH+",
49
- mandu_watch_status: "WATCH?",
50
- mandu_watch_stop: "WATCH-",
51
- mandu_check_location: "ARCH?",
52
- mandu_check_import: "IMPORT?",
53
- mandu_get_architecture: "ARCH",
54
- // Build
55
- mandu_build: "BUILD",
56
- mandu_build_status: "BUILD?",
57
- mandu_list_islands: "ISLAND",
58
- mandu_set_hydration: "HYDRA~",
59
- mandu_add_client_slot: "CLIENT+",
60
- // Error
61
- mandu_analyze_error: "ERROR",
62
- };
63
-
64
- type MonitorSeverity = "info" | "warn" | "error";
65
- type MonitorOutputFormat = "pretty" | "json";
66
- type MonitorOutputPreference = MonitorOutputFormat | "auto" | "console" | "agent";
67
- const SCHEMA_VERSION = "1.0";
68
-
69
- interface MonitorStoreConfig {
70
- enabled?: boolean;
71
- retentionDays?: number;
72
- maxArchived?: number;
73
- }
74
-
75
- interface MonitorConfig {
76
- output?: MonitorOutputPreference;
77
- openTerminal?: boolean;
78
- dedupeWindowMs?: number;
79
- flushIntervalMs?: number;
80
- summaryIntervalMs?: number;
81
- store?: MonitorStoreConfig;
82
- }
83
-
84
- interface MonitorEvent {
85
- ts: string;
86
- type: string;
87
- severity: MonitorSeverity;
88
- source: string;
89
- message?: string;
90
- data?: Record<string, unknown>;
91
- actionRequired?: boolean;
92
- fingerprint?: string;
93
- count?: number;
94
- schemaVersion?: string;
95
- }
96
-
97
- interface DedupeEntry {
98
- event: MonitorEvent;
99
- count: number;
100
- windowStart: number;
101
- lastTs: number;
102
- }
103
-
104
- const DEFAULT_CONFIG: Required<MonitorConfig> = {
105
- output: "auto",
106
- openTerminal: true,
107
- dedupeWindowMs: 1500,
108
- flushIntervalMs: 500,
109
- summaryIntervalMs: 30000,
110
- store: {
111
- enabled: true,
112
- retentionDays: 7,
113
- maxArchived: 20,
114
- },
115
- };
116
-
117
- function normalizeOutput(
118
- value: MonitorOutputPreference | undefined
119
- ): MonitorOutputFormat | undefined {
120
- if (!value) return undefined;
121
- if (value === "json" || value === "agent") return "json";
122
- if (value === "pretty" || value === "console") return "pretty";
123
- return undefined;
124
- }
125
-
126
- function resolveOutputFormat(
127
- preference: MonitorOutputPreference | undefined
128
- ): MonitorOutputFormat {
129
- const env = process.env;
130
- const direct =
131
- normalizeOutput(preference) ??
132
- normalizeOutput(env.MANDU_MONITOR_FORMAT as MonitorOutputPreference) ??
133
- normalizeOutput(env.MANDU_OUTPUT as MonitorOutputPreference);
134
-
135
- if (direct) return direct;
136
-
137
- const agentSignals = [
138
- "MANDU_AGENT",
139
- "CODEX_AGENT",
140
- "CODEX",
141
- "CLAUDE_CODE",
142
- "ANTHROPIC_CLAUDE_CODE",
143
- ];
144
-
145
- for (const key of agentSignals) {
146
- const value = env[key];
147
- if (value === "1" || value === "true") {
148
- return "json";
149
- }
150
- }
151
-
152
- if (env.CI === "true") {
153
- return "json";
154
- }
155
-
156
- if (process.stdout && !process.stdout.isTTY) {
157
- return "json";
158
- }
159
-
160
- return "pretty";
161
- }
162
-
163
- function formatTime(ts: string): string {
164
- return new Date(ts).toLocaleTimeString("ko-KR", { hour12: false });
165
- }
166
-
167
- function getTime(): string {
168
- return formatTime(new Date().toISOString());
169
- }
170
-
171
- function summarizeArgs(args: Record<string, unknown> | null | undefined): string {
172
- if (!args || Object.keys(args).length === 0) return "";
173
- const entries = Object.entries(args)
174
- .filter(([_, v]) => v !== undefined && v !== null)
175
- .map(([k, v]) => {
176
- const val =
177
- typeof v === "string"
178
- ? v.length > 40
179
- ? v.slice(0, 40) + "..."
180
- : v
181
- : JSON.stringify(v);
182
- return `${k}=${val}`;
183
- });
184
- return entries.length > 0 ? ` (${entries.join(", ")})` : "";
185
- }
186
-
187
- function summarizeResult(result: unknown): string {
188
- if (!result || typeof result !== "object") return "";
189
- const obj = result as Record<string, unknown>;
190
-
191
- // Common patterns
192
- if (obj.error) return ` >> ERROR: ${obj.error}`;
193
- if (obj.success === true) return obj.message ? ` >> ${obj.message}` : " >> OK";
194
- if (obj.success === false) return ` >> FAILED: ${obj.message || "unknown"}`;
195
- if (Array.isArray(obj.routes)) return ` >> ${obj.routes.length} routes`;
196
- if (obj.passed === true) return obj.message ? ` >> ${obj.message}` : " >> PASSED";
197
- if (obj.passed === false) {
198
- return ` >> FAILED (${(obj.violations as unknown[])?.length || 0} violations)`;
199
- }
200
- if (obj.valid === true) return obj.message ? ` >> ${obj.message}` : " >> VALID";
201
- if (obj.valid === false) {
202
- return ` >> INVALID (${(obj.violations as unknown[])?.length || 0} violations)`;
203
- }
204
- if (obj.generated) return " >> Generated";
205
- if (obj.status) return ` >> ${JSON.stringify(obj.status).slice(0, 60)}`;
206
-
207
- return "";
208
- }
209
-
210
- function mergeConfig(
211
- base: Required<MonitorConfig>,
212
- override: MonitorConfig
213
- ): Required<MonitorConfig> {
214
- return {
215
- ...base,
216
- ...override,
217
- store: {
218
- ...base.store,
219
- ...override.store,
220
- },
221
- };
222
- }
223
-
224
- function loadMonitorConfig(projectRoot: string): MonitorConfig {
225
- try {
226
- const configPath = path.join(projectRoot, ".mandu", "monitor.config.json");
227
- if (!fs.existsSync(configPath)) return {};
228
- const raw = fs.readFileSync(configPath, "utf-8");
229
- const parsed = JSON.parse(raw);
230
- return typeof parsed === "object" && parsed ? parsed : {};
231
- } catch {
232
- return {};
233
- }
234
- }
235
-
236
- function ensureManduDir(projectRoot: string): string {
237
- const manduDir = path.join(projectRoot, ".mandu");
238
- if (!fs.existsSync(manduDir)) {
239
- fs.mkdirSync(manduDir, { recursive: true });
240
- }
241
- return manduDir;
242
- }
243
-
244
- function writeDefaultConfig(projectRoot: string, config: Required<MonitorConfig>): void {
245
- try {
246
- const configPath = path.join(projectRoot, ".mandu", "monitor.config.json");
247
- if (fs.existsSync(configPath)) return;
248
- const template = {
249
- output: config.output,
250
- openTerminal: config.openTerminal,
251
- dedupeWindowMs: config.dedupeWindowMs,
252
- flushIntervalMs: config.flushIntervalMs,
253
- summaryIntervalMs: config.summaryIntervalMs,
254
- store: {
255
- enabled: config.store.enabled,
256
- retentionDays: config.store.retentionDays,
257
- maxArchived: config.store.maxArchived,
258
- },
259
- };
260
- fs.writeFileSync(configPath, JSON.stringify(template, null, 2));
261
- } catch {
262
- // ignore config write errors
263
- }
264
- }
265
-
266
- export class ActivityMonitor {
267
- private logFile = "";
268
- private logStream: fs.WriteStream | null = null;
269
- private tailProcess: ChildProcess | null = null;
270
- private projectRoot: string;
271
- private config: Required<MonitorConfig>;
272
- private outputFormat: MonitorOutputFormat;
273
- private pending: MonitorEvent[] = [];
274
- private dedupeMap = new Map<string, DedupeEntry>();
275
- private flushTimer: NodeJS.Timeout | null = null;
276
- private summaryTimer: NodeJS.Timeout | null = null;
277
- private summaryCounts = { total: 0, info: 0, warn: 0, error: 0 };
278
- private lastToolArgs = new Map<string, Record<string, unknown> | null>();
279
-
280
- constructor(projectRoot: string) {
281
- this.projectRoot = projectRoot;
282
- const userConfig = loadMonitorConfig(projectRoot);
283
- this.config = mergeConfig(DEFAULT_CONFIG, userConfig);
284
- this.outputFormat = resolveOutputFormat(this.config.output);
285
- }
286
-
287
- start(): void {
288
- const manduDir = ensureManduDir(this.projectRoot);
289
- writeDefaultConfig(this.projectRoot, this.config);
290
- const extension = this.outputFormat === "json" ? "jsonl" : "log";
291
- this.logFile = path.join(manduDir, `activity.${extension}`);
292
-
293
- if (this.config.store.enabled) {
294
- this.archiveExistingLog();
295
- this.pruneArchivedLogs();
296
- }
297
-
298
- this.logStream = fs.createWriteStream(this.logFile, { flags: "w" });
299
-
300
- if (this.outputFormat === "pretty") {
301
- const time = getTime();
302
- const header =
303
- `\n` +
304
- ` ╔══════════════════════════════════════════════╗\n` +
305
- ` ║ MANDU MCP Activity Monitor ║\n` +
306
- ` ║ ║\n` +
307
- ` ║ ${time} ║\n` +
308
- ` ║ ${this.projectRoot.slice(-40).padEnd(40)} ║\n` +
309
- ` ╚══════════════════════════════════════════════╝\n\n`;
310
- this.logStream.write(header);
311
- }
312
-
313
- if (this.config.flushIntervalMs > 0) {
314
- this.flushTimer = setInterval(
315
- () => this.flush(false),
316
- this.config.flushIntervalMs
317
- );
318
- }
319
-
320
- if (
321
- this.outputFormat === "pretty" &&
322
- this.config.summaryIntervalMs > 0
323
- ) {
324
- this.summaryTimer = setInterval(
325
- () => this.emitSummary(),
326
- this.config.summaryIntervalMs
327
- );
328
- }
329
-
330
- const openTerminal =
331
- this.config.openTerminal && this.outputFormat === "pretty";
332
- if (openTerminal) {
333
- this.openTerminal();
334
- }
335
- }
336
-
337
- stop(): void {
338
- this.flush(true);
339
- if (this.tailProcess) {
340
- this.tailProcess.kill();
341
- this.tailProcess = null;
342
- }
343
- if (this.flushTimer) {
344
- clearInterval(this.flushTimer);
345
- this.flushTimer = null;
346
- }
347
- if (this.summaryTimer) {
348
- clearInterval(this.summaryTimer);
349
- this.summaryTimer = null;
350
- }
351
- if (this.logStream) {
352
- this.logStream.end();
353
- this.logStream = null;
354
- }
355
- }
356
-
357
- /**
358
- * Log a tool call (invocation)
359
- */
360
- logTool(
361
- name: string,
362
- args?: Record<string, unknown> | null,
363
- _result?: unknown,
364
- error?: string,
365
- ): void {
366
- this.lastToolArgs.set(name, args ?? null);
367
- const argsStr = summarizeArgs(args);
368
- const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
369
-
370
- if (error) {
371
- this.enqueue({
372
- ts: new Date().toISOString(),
373
- type: "tool.error",
374
- severity: "error",
375
- source: "tool",
376
- message: `ERROR: ${error}`,
377
- actionRequired: true,
378
- fingerprint: `tool:error:${name}:${argsStr}`,
379
- data: { tool: name, tag, args, argsSummary: argsStr, error },
380
- });
381
- return;
382
- }
383
-
384
- this.enqueue({
385
- ts: new Date().toISOString(),
386
- type: "tool.call",
387
- severity: "info",
388
- source: "tool",
389
- data: { tool: name, tag, args, argsSummary: argsStr },
390
- });
391
- }
392
-
393
- /**
394
- * Log a tool result
395
- */
396
- logResult(name: string, result: unknown): void {
397
- const lastArgs = this.lastToolArgs.get(name) ?? null;
398
- if (this.lastToolArgs.has(name)) {
399
- this.lastToolArgs.delete(name);
400
- }
401
- const summary = summarizeResult(result);
402
- const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
403
-
404
- if (summary) {
405
- this.enqueue({
406
- ts: new Date().toISOString(),
407
- type: "tool.result",
408
- severity: "info",
409
- source: "tool",
410
- data: { tool: name, tag, summary },
411
- });
412
- }
413
-
414
- this.logStructuredResult(name, result, lastArgs);
415
- }
416
-
417
- /**
418
- * Log a watch event (called from watcher)
419
- */
420
- logWatch(level: string, ruleId: string, file: string, message: string): void {
421
- const severity: MonitorSeverity =
422
- level === "error" ? "error" : level === "info" ? "info" : "warn";
423
-
424
- this.enqueue({
425
- ts: new Date().toISOString(),
426
- type: "watch.warning",
427
- severity,
428
- source: "watch",
429
- message,
430
- actionRequired: severity !== "info",
431
- fingerprint: `watch:${ruleId}:${file}:${message}`,
432
- data: { ruleId, file, level, message },
433
- });
434
- }
435
-
436
- /**
437
- * Log a custom event
438
- */
439
- logEvent(category: string, message: string): void {
440
- this.enqueue({
441
- ts: new Date().toISOString(),
442
- type: "system.event",
443
- severity: "info",
444
- source: "system",
445
- message,
446
- data: { category },
447
- });
448
- }
449
-
450
- private logStructuredResult(
451
- name: string,
452
- result: unknown,
453
- lastArgs: Record<string, unknown> | null
454
- ): void {
455
- if (!result || typeof result !== "object") return;
456
- const obj = result as Record<string, unknown>;
457
-
458
- if (name === "mandu_guard_check") {
459
- const violations = Array.isArray(obj.violations) ? obj.violations : [];
460
- const passed = obj.passed === true;
461
- const count = violations.length;
462
-
463
- if (count > 0) {
464
- this.enqueue({
465
- ts: new Date().toISOString(),
466
- type: "guard.summary",
467
- severity: passed ? "info" : "error",
468
- source: "guard",
469
- actionRequired: !passed,
470
- fingerprint: `guard:summary:${count}`,
471
- data: { passed, count },
472
- });
473
-
474
- for (const violation of violations) {
475
- const v = violation as Record<string, unknown>;
476
- const ruleId = (v.ruleId as string | undefined) ?? "UNKNOWN_RULE";
477
- const file = v.file as string | undefined;
478
- const line = typeof v.line === "number" ? v.line : undefined;
479
- const column = typeof v.column === "number" ? v.column : undefined;
480
- const message =
481
- (v.message as string | undefined) ??
482
- (v.reason as string | undefined) ??
483
- "Guard violation";
484
- const suggestion =
485
- (v.suggestion as string | undefined) ??
486
- (v.tip as string | undefined);
487
-
488
- this.enqueue({
489
- ts: new Date().toISOString(),
490
- type: "guard.violation",
491
- severity: "error",
492
- source: "guard",
493
- message,
494
- actionRequired: true,
495
- fingerprint: `guard:${ruleId}:${file ?? ""}:${line ?? ""}:${column ?? ""}`,
496
- data: {
497
- ruleId,
498
- file,
499
- line,
500
- column,
501
- message,
502
- suggestion,
503
- },
504
- });
505
- }
506
- }
507
-
508
- return;
509
- }
510
-
511
- if (name === "mandu_check_location") {
512
- const allowed = obj.allowed === true;
513
- if (allowed) return;
514
-
515
- const violations = Array.isArray(obj.violations) ? obj.violations : [];
516
- const argPath = typeof lastArgs?.path === "string" ? lastArgs.path : undefined;
517
- for (const violation of violations) {
518
- const v = violation as Record<string, unknown>;
519
- const ruleId = (v.rule as string | undefined) ?? "LOCATION_RULE";
520
- const message = (v.message as string | undefined) ?? "Location violation";
521
- const severity = (v.severity as MonitorSeverity | undefined) ?? "warn";
522
-
523
- this.enqueue({
524
- ts: new Date().toISOString(),
525
- type: "guard.violation",
526
- severity,
527
- source: "architecture",
528
- message,
529
- actionRequired: severity !== "info",
530
- fingerprint: `guard:location:${ruleId}:${argPath ?? ""}`,
531
- data: {
532
- ruleId,
533
- file: argPath,
534
- message,
535
- suggestion: obj.suggestion,
536
- recommendedPath: obj.recommendedPath,
537
- },
538
- });
539
- }
540
- return;
541
- }
542
-
543
- if (name === "mandu_check_import") {
544
- const allowed = obj.allowed === true;
545
- if (allowed) return;
546
-
547
- const violations = Array.isArray(obj.violations) ? obj.violations : [];
548
- const sourceFile = typeof lastArgs?.sourceFile === "string" ? lastArgs.sourceFile : undefined;
549
- for (const violation of violations) {
550
- const v = violation as Record<string, unknown>;
551
- const message = (v.reason as string | undefined) ?? "Import violation";
552
- const suggestion = v.suggestion as string | undefined;
553
- const importTarget = typeof v.import === "string" ? v.import : undefined;
554
-
555
- this.enqueue({
556
- ts: new Date().toISOString(),
557
- type: "guard.violation",
558
- severity: "error",
559
- source: "architecture",
560
- message,
561
- actionRequired: true,
562
- fingerprint: `guard:import:${sourceFile ?? ""}:${importTarget ?? ""}`,
563
- data: {
564
- ruleId: "IMPORT_RULE",
565
- file: sourceFile,
566
- import: importTarget,
567
- message,
568
- suggestion,
569
- },
570
- });
571
- }
572
- return;
573
- }
574
-
575
- if (
576
- name === "mandu_add_route" ||
577
- name === "mandu_update_route" ||
578
- name === "mandu_delete_route"
579
- ) {
580
- if (obj.success !== true) return;
581
- const action =
582
- name === "mandu_add_route"
583
- ? "add"
584
- : name === "mandu_update_route"
585
- ? "update"
586
- : "delete";
587
- const route =
588
- (obj.route as Record<string, unknown> | undefined) ??
589
- (obj.deletedRoute as Record<string, unknown> | undefined);
590
-
591
- this.enqueue({
592
- ts: new Date().toISOString(),
593
- type: "routes.change",
594
- severity: "info",
595
- source: "routes",
596
- actionRequired: false,
597
- fingerprint: `routes:${action}:${route?.id ?? ""}`,
598
- data: {
599
- action,
600
- routeId: route?.id,
601
- pattern: route?.pattern,
602
- kind: route?.kind,
603
- },
604
- });
605
- }
606
- }
607
-
608
- private enqueue(event: MonitorEvent): void {
609
- if (!this.logStream) return;
610
- const now = Date.now();
611
-
612
- if (!event.fingerprint || this.config.dedupeWindowMs <= 0) {
613
- this.pending.push(event);
614
- return;
615
- }
616
-
617
- const existing = this.dedupeMap.get(event.fingerprint);
618
- if (!existing) {
619
- this.dedupeMap.set(event.fingerprint, {
620
- event,
621
- count: 1,
622
- windowStart: now,
623
- lastTs: now,
624
- });
625
- return;
626
- }
627
-
628
- if (now - existing.windowStart >= this.config.dedupeWindowMs) {
629
- this.pending.push(this.withCount(existing));
630
- this.dedupeMap.set(event.fingerprint, {
631
- event,
632
- count: 1,
633
- windowStart: now,
634
- lastTs: now,
635
- });
636
- return;
637
- }
638
-
639
- existing.count += 1;
640
- existing.lastTs = now;
641
- existing.event = event;
642
- }
643
-
644
- private flush(force: boolean): void {
645
- if (!this.logStream) return;
646
- const now = Date.now();
647
- const windowMs = this.config.dedupeWindowMs;
648
-
649
- for (const [key, entry] of this.dedupeMap) {
650
- const idleMs = now - entry.lastTs;
651
- if (force || idleMs >= windowMs) {
652
- this.pending.push(this.withCount(entry));
653
- this.dedupeMap.delete(key);
654
- }
655
- }
656
-
657
- if (this.pending.length === 0) return;
658
-
659
- for (const event of this.pending) {
660
- const line = this.formatEvent(event);
661
- if (!line) continue;
662
- this.write(line);
663
- this.updateSummary(event);
664
- }
665
-
666
- this.pending = [];
667
- }
668
-
669
- private withCount(entry: DedupeEntry): MonitorEvent {
670
- return {
671
- ...entry.event,
672
- count: entry.count,
673
- };
674
- }
675
-
676
- private formatEvent(event: MonitorEvent): string {
677
- if (this.outputFormat === "json") {
678
- const payload = event.schemaVersion
679
- ? event
680
- : { schemaVersion: SCHEMA_VERSION, ...event };
681
- return `${JSON.stringify(payload)}\n`;
682
- }
683
- return this.formatEventPretty(event);
684
- }
685
-
686
- private formatEventPretty(event: MonitorEvent): string {
687
- const time = formatTime(event.ts);
688
- const countSuffix = event.count && event.count > 1 ? ` x${event.count}` : "";
689
-
690
- switch (event.type) {
691
- case "tool.call": {
692
- const tag = event.data?.tag as string | undefined;
693
- const argsSummary = event.data?.argsSummary as string | undefined;
694
- return `${time} → [${tag ?? "TOOL"}]${argsSummary ?? ""}${countSuffix}\n`;
695
- }
696
- case "tool.error": {
697
- const tag = event.data?.tag as string | undefined;
698
- const argsSummary = event.data?.argsSummary as string | undefined;
699
- const message = event.message ?? "ERROR";
700
- return `${time} ✗ [${tag ?? "TOOL"}]${argsSummary ?? ""}${countSuffix}\n ${message}\n`;
701
- }
702
- case "tool.result": {
703
- const tag = event.data?.tag as string | undefined;
704
- const summary = event.data?.summary as string | undefined;
705
- if (!summary) return "";
706
- return `${time} ✓ [${tag ?? "TOOL"}]${summary}${countSuffix}\n`;
707
- }
708
- case "watch.warning": {
709
- const ruleId = event.data?.ruleId as string | undefined;
710
- const file = event.data?.file as string | undefined;
711
- const message = event.message ?? "";
712
- const icon = event.severity === "info" ? "ℹ" : "⚠";
713
- return `${time} ${icon} [WATCH:${ruleId ?? "UNKNOWN"}] ${file ?? ""}${countSuffix}\n ${message}\n`;
714
- }
715
- case "system.event": {
716
- const category = event.data?.category as string | undefined;
717
- const message = event.message ?? "";
718
- return `${time} [${category ?? "SYSTEM"}] ${message}${countSuffix}\n`;
719
- }
720
- case "monitor.summary": {
721
- return `${time} · SUMMARY ${event.message ?? ""}\n`;
722
- }
723
- default:
724
- return `${time} [${event.type}] ${event.message ?? ""}${countSuffix}\n`;
725
- }
726
- }
727
-
728
- private updateSummary(event: MonitorEvent): void {
729
- const count = event.count ?? 1;
730
- this.summaryCounts.total += count;
731
- this.summaryCounts[event.severity] += count;
732
- }
733
-
734
- private emitSummary(): void {
735
- if (this.summaryCounts.total === 0) return;
736
- const seconds = Math.round(this.config.summaryIntervalMs / 1000);
737
- const message = `last ${seconds}s · total=${this.summaryCounts.total} · error=${this.summaryCounts.error} · warn=${this.summaryCounts.warn} · info=${this.summaryCounts.info}`;
738
- this.summaryCounts = { total: 0, info: 0, warn: 0, error: 0 };
739
- const summaryEvent: MonitorEvent = {
740
- ts: new Date().toISOString(),
741
- type: "monitor.summary",
742
- severity: "info",
743
- source: "monitor",
744
- message,
745
- };
746
- const line = this.formatEvent(summaryEvent);
747
- if (line) {
748
- this.write(line);
749
- }
750
- }
751
-
752
- private write(text: string): void {
753
- if (this.logStream) {
754
- this.logStream.write(text);
755
- }
756
- }
757
-
758
- private archiveExistingLog(): void {
759
- try {
760
- if (!fs.existsSync(this.logFile)) return;
761
- const dir = path.dirname(this.logFile);
762
- const base = path.basename(this.logFile);
763
- const stamp = new Date()
764
- .toISOString()
765
- .replace(/[:.]/g, "")
766
- .slice(0, 15);
767
- const archived = path.join(dir, `${base}.${stamp}.bak`);
768
- fs.renameSync(this.logFile, archived);
769
- } catch {
770
- // ignore archive errors
771
- }
772
- }
773
-
774
- private pruneArchivedLogs(): void {
775
- try {
776
- const dir = path.dirname(this.logFile);
777
- const base = path.basename(this.logFile);
778
- const files = fs
779
- .readdirSync(dir)
780
- .filter((file) => file.startsWith(`${base}.`) && file.endsWith(".bak"));
781
-
782
- const entries = files
783
- .map((file) => {
784
- const fullPath = path.join(dir, file);
785
- const stat = fs.statSync(fullPath);
786
- return { file, fullPath, mtime: stat.mtime.getTime() };
787
- })
788
- .sort((a, b) => b.mtime - a.mtime);
789
-
790
- const retentionMs =
791
- (this.config.store.retentionDays || 7) * 24 * 60 * 60 * 1000;
792
- const now = Date.now();
793
-
794
- for (const entry of entries) {
795
- if (now - entry.mtime > retentionMs) {
796
- fs.unlinkSync(entry.fullPath);
797
- }
798
- }
799
-
800
- const maxArchived = this.config.store.maxArchived || 20;
801
- const remaining = entries.filter((entry) => fs.existsSync(entry.fullPath));
802
- if (remaining.length > maxArchived) {
803
- const toRemove = remaining.slice(maxArchived);
804
- for (const entry of toRemove) {
805
- fs.unlinkSync(entry.fullPath);
806
- }
807
- }
808
- } catch {
809
- // ignore prune errors
810
- }
811
- }
812
-
813
- private openTerminal(): void {
814
- try {
815
- if (process.platform === "win32") {
816
- this.tailProcess = spawn(
817
- "cmd",
818
- [
819
- "/c",
820
- "start",
821
- "Mandu Activity Monitor",
822
- "powershell",
823
- "-NoExit",
824
- "-Command",
825
- `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 | Out-Null; Get-Content '${this.logFile}' -Wait -Encoding UTF8`,
826
- ],
827
- { cwd: this.projectRoot, detached: true, stdio: "ignore" }
828
- );
829
- } else if (process.platform === "darwin") {
830
- this.tailProcess = spawn(
831
- "osascript",
832
- ["-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`],
833
- { detached: true, stdio: "ignore" }
834
- );
835
- } else {
836
- this.tailProcess = spawn(
837
- "x-terminal-emulator",
838
- ["-e", `tail -f '${this.logFile}'`],
839
- { cwd: this.projectRoot, detached: true, stdio: "ignore" }
840
- );
841
- }
842
- this.tailProcess?.unref();
843
- } catch {
844
- // Terminal auto-open failed silently
845
- }
846
- }
847
- }
1
+ /**
2
+ * Mandu Activity Monitor
3
+ *
4
+ * CLI-first real-time monitor for MCP server activity.
5
+ * Supports pretty (human) and JSON (agent) outputs with dedupe + batching.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { spawn, type ChildProcess } from "child_process";
11
+
12
+ const TOOL_ICONS: Record<string, string> = {
13
+ // Spec
14
+ mandu_list_routes: "SPEC",
15
+ mandu_get_route: "SPEC",
16
+ mandu_add_route: "SPEC+",
17
+ mandu_update_route: "SPEC~",
18
+ mandu_delete_route: "SPEC-",
19
+ mandu_validate_manifest: "SPEC?",
20
+ // Generate
21
+ mandu_generate: "GEN",
22
+ mandu_generate_status: "GEN?",
23
+ // Guard
24
+ mandu_guard_check: "GUARD",
25
+ // Slot
26
+ mandu_read_slot: "SLOT",
27
+ mandu_write_slot: "SLOT~",
28
+ mandu_validate_slot: "SLOT?",
29
+ // Contract
30
+ mandu_list_contracts: "CONTRACT",
31
+ mandu_get_contract: "CONTRACT",
32
+ mandu_create_contract: "CONTRACT+",
33
+ mandu_validate_contracts: "CONTRACT?",
34
+ mandu_sync_contract_slot: "SYNC",
35
+ mandu_generate_openapi: "OPENAPI",
36
+ mandu_update_route_contract: "CONTRACT~",
37
+ // Transaction
38
+ mandu_begin: "TX-BEGIN",
39
+ mandu_commit: "TX-COMMIT",
40
+ mandu_rollback: "TX-ROLLBACK",
41
+ mandu_tx_status: "TX?",
42
+ // History
43
+ mandu_list_history: "HISTORY",
44
+ mandu_get_snapshot: "SNAPSHOT",
45
+ mandu_prune_history: "PRUNE",
46
+ // Brain
47
+ mandu_doctor: "DOCTOR",
48
+ mandu_watch_start: "WATCH+",
49
+ mandu_watch_status: "WATCH?",
50
+ mandu_watch_stop: "WATCH-",
51
+ mandu_check_location: "ARCH?",
52
+ mandu_check_import: "IMPORT?",
53
+ mandu_get_architecture: "ARCH",
54
+ // Build
55
+ mandu_build: "BUILD",
56
+ mandu_build_status: "BUILD?",
57
+ mandu_list_islands: "ISLAND",
58
+ mandu_set_hydration: "HYDRA~",
59
+ mandu_add_client_slot: "CLIENT+",
60
+ // Error
61
+ mandu_analyze_error: "ERROR",
62
+ };
63
+
64
+ type MonitorSeverity = "info" | "warn" | "error";
65
+ type MonitorOutputFormat = "pretty" | "json";
66
+ type MonitorOutputPreference = MonitorOutputFormat | "auto" | "console" | "agent";
67
+ const SCHEMA_VERSION = "1.0";
68
+
69
+ interface MonitorStoreConfig {
70
+ enabled?: boolean;
71
+ retentionDays?: number;
72
+ maxArchived?: number;
73
+ }
74
+
75
+ interface MonitorConfig {
76
+ output?: MonitorOutputPreference;
77
+ openTerminal?: boolean;
78
+ dedupeWindowMs?: number;
79
+ flushIntervalMs?: number;
80
+ summaryIntervalMs?: number;
81
+ store?: MonitorStoreConfig;
82
+ }
83
+
84
+ interface MonitorEvent {
85
+ ts: string;
86
+ type: string;
87
+ severity: MonitorSeverity;
88
+ source: string;
89
+ message?: string;
90
+ data?: Record<string, unknown>;
91
+ actionRequired?: boolean;
92
+ fingerprint?: string;
93
+ count?: number;
94
+ schemaVersion?: string;
95
+ }
96
+
97
+ interface DedupeEntry {
98
+ event: MonitorEvent;
99
+ count: number;
100
+ windowStart: number;
101
+ lastTs: number;
102
+ }
103
+
104
+ const DEFAULT_CONFIG: Required<MonitorConfig> = {
105
+ output: "auto",
106
+ openTerminal: true,
107
+ dedupeWindowMs: 1500,
108
+ flushIntervalMs: 500,
109
+ summaryIntervalMs: 30000,
110
+ store: {
111
+ enabled: true,
112
+ retentionDays: 7,
113
+ maxArchived: 20,
114
+ },
115
+ };
116
+
117
+ function normalizeOutput(
118
+ value: MonitorOutputPreference | undefined
119
+ ): MonitorOutputFormat | undefined {
120
+ if (!value) return undefined;
121
+ if (value === "json" || value === "agent") return "json";
122
+ if (value === "pretty" || value === "console") return "pretty";
123
+ return undefined;
124
+ }
125
+
126
+ function resolveOutputFormat(
127
+ preference: MonitorOutputPreference | undefined
128
+ ): MonitorOutputFormat {
129
+ const env = process.env;
130
+ const direct =
131
+ normalizeOutput(preference) ??
132
+ normalizeOutput(env.MANDU_MONITOR_FORMAT as MonitorOutputPreference) ??
133
+ normalizeOutput(env.MANDU_OUTPUT as MonitorOutputPreference);
134
+
135
+ if (direct) return direct;
136
+
137
+ const agentSignals = [
138
+ "MANDU_AGENT",
139
+ "CODEX_AGENT",
140
+ "CODEX",
141
+ "CLAUDE_CODE",
142
+ "ANTHROPIC_CLAUDE_CODE",
143
+ ];
144
+
145
+ for (const key of agentSignals) {
146
+ const value = env[key];
147
+ if (value === "1" || value === "true") {
148
+ return "json";
149
+ }
150
+ }
151
+
152
+ if (env.CI === "true") {
153
+ return "json";
154
+ }
155
+
156
+ if (process.stdout && !process.stdout.isTTY) {
157
+ return "json";
158
+ }
159
+
160
+ return "pretty";
161
+ }
162
+
163
+ function formatTime(ts: string): string {
164
+ return new Date(ts).toLocaleTimeString("ko-KR", { hour12: false });
165
+ }
166
+
167
+ function getTime(): string {
168
+ return formatTime(new Date().toISOString());
169
+ }
170
+
171
+ function summarizeArgs(args: Record<string, unknown> | null | undefined): string {
172
+ if (!args || Object.keys(args).length === 0) return "";
173
+ const entries = Object.entries(args)
174
+ .filter(([_, v]) => v !== undefined && v !== null)
175
+ .map(([k, v]) => {
176
+ const val =
177
+ typeof v === "string"
178
+ ? v.length > 40
179
+ ? v.slice(0, 40) + "..."
180
+ : v
181
+ : JSON.stringify(v);
182
+ return `${k}=${val}`;
183
+ });
184
+ return entries.length > 0 ? ` (${entries.join(", ")})` : "";
185
+ }
186
+
187
+ function summarizeResult(result: unknown): string {
188
+ if (!result || typeof result !== "object") return "";
189
+ const obj = result as Record<string, unknown>;
190
+
191
+ // Common patterns
192
+ if (obj.error) return ` >> ERROR: ${obj.error}`;
193
+ if (obj.success === true) return obj.message ? ` >> ${obj.message}` : " >> OK";
194
+ if (obj.success === false) return ` >> FAILED: ${obj.message || "unknown"}`;
195
+ if (Array.isArray(obj.routes)) return ` >> ${obj.routes.length} routes`;
196
+ if (obj.passed === true) return obj.message ? ` >> ${obj.message}` : " >> PASSED";
197
+ if (obj.passed === false) {
198
+ return ` >> FAILED (${(obj.violations as unknown[])?.length || 0} violations)`;
199
+ }
200
+ if (obj.valid === true) return obj.message ? ` >> ${obj.message}` : " >> VALID";
201
+ if (obj.valid === false) {
202
+ return ` >> INVALID (${(obj.violations as unknown[])?.length || 0} violations)`;
203
+ }
204
+ if (obj.generated) return " >> Generated";
205
+ if (obj.status) return ` >> ${JSON.stringify(obj.status).slice(0, 60)}`;
206
+
207
+ return "";
208
+ }
209
+
210
+ function mergeConfig(
211
+ base: Required<MonitorConfig>,
212
+ override: MonitorConfig
213
+ ): Required<MonitorConfig> {
214
+ return {
215
+ ...base,
216
+ ...override,
217
+ store: {
218
+ ...base.store,
219
+ ...override.store,
220
+ },
221
+ };
222
+ }
223
+
224
+ function loadMonitorConfig(projectRoot: string): MonitorConfig {
225
+ try {
226
+ const configPath = path.join(projectRoot, ".mandu", "monitor.config.json");
227
+ if (!fs.existsSync(configPath)) return {};
228
+ const raw = fs.readFileSync(configPath, "utf-8");
229
+ const parsed = JSON.parse(raw);
230
+ return typeof parsed === "object" && parsed ? parsed : {};
231
+ } catch {
232
+ return {};
233
+ }
234
+ }
235
+
236
+ function ensureManduDir(projectRoot: string): string {
237
+ const manduDir = path.join(projectRoot, ".mandu");
238
+ if (!fs.existsSync(manduDir)) {
239
+ fs.mkdirSync(manduDir, { recursive: true });
240
+ }
241
+ return manduDir;
242
+ }
243
+
244
+ function writeDefaultConfig(projectRoot: string, config: Required<MonitorConfig>): void {
245
+ try {
246
+ const configPath = path.join(projectRoot, ".mandu", "monitor.config.json");
247
+ if (fs.existsSync(configPath)) return;
248
+ const template = {
249
+ output: config.output,
250
+ openTerminal: config.openTerminal,
251
+ dedupeWindowMs: config.dedupeWindowMs,
252
+ flushIntervalMs: config.flushIntervalMs,
253
+ summaryIntervalMs: config.summaryIntervalMs,
254
+ store: {
255
+ enabled: config.store.enabled,
256
+ retentionDays: config.store.retentionDays,
257
+ maxArchived: config.store.maxArchived,
258
+ },
259
+ };
260
+ fs.writeFileSync(configPath, JSON.stringify(template, null, 2));
261
+ } catch {
262
+ // ignore config write errors
263
+ }
264
+ }
265
+
266
+ export class ActivityMonitor {
267
+ private logFile = "";
268
+ private logStream: fs.WriteStream | null = null;
269
+ private tailProcess: ChildProcess | null = null;
270
+ private projectRoot: string;
271
+ private config: Required<MonitorConfig>;
272
+ private outputFormat: MonitorOutputFormat;
273
+ private pending: MonitorEvent[] = [];
274
+ private dedupeMap = new Map<string, DedupeEntry>();
275
+ private flushTimer: NodeJS.Timeout | null = null;
276
+ private summaryTimer: NodeJS.Timeout | null = null;
277
+ private summaryCounts = { total: 0, info: 0, warn: 0, error: 0 };
278
+ private lastToolArgs = new Map<string, Record<string, unknown> | null>();
279
+
280
+ constructor(projectRoot: string) {
281
+ this.projectRoot = projectRoot;
282
+ const userConfig = loadMonitorConfig(projectRoot);
283
+ this.config = mergeConfig(DEFAULT_CONFIG, userConfig);
284
+ this.outputFormat = resolveOutputFormat(this.config.output);
285
+ }
286
+
287
+ start(): void {
288
+ const manduDir = ensureManduDir(this.projectRoot);
289
+ writeDefaultConfig(this.projectRoot, this.config);
290
+ const extension = this.outputFormat === "json" ? "jsonl" : "log";
291
+ this.logFile = path.join(manduDir, `activity.${extension}`);
292
+
293
+ if (this.config.store.enabled) {
294
+ this.archiveExistingLog();
295
+ this.pruneArchivedLogs();
296
+ }
297
+
298
+ this.logStream = fs.createWriteStream(this.logFile, { flags: "w" });
299
+
300
+ if (this.outputFormat === "pretty") {
301
+ const time = getTime();
302
+ const header =
303
+ `\n` +
304
+ ` ╔══════════════════════════════════════════════╗\n` +
305
+ ` ║ MANDU MCP Activity Monitor ║\n` +
306
+ ` ║ ║\n` +
307
+ ` ║ ${time} ║\n` +
308
+ ` ║ ${this.projectRoot.slice(-40).padEnd(40)} ║\n` +
309
+ ` ╚══════════════════════════════════════════════╝\n\n`;
310
+ this.logStream.write(header);
311
+ }
312
+
313
+ if (this.config.flushIntervalMs > 0) {
314
+ this.flushTimer = setInterval(
315
+ () => this.flush(false),
316
+ this.config.flushIntervalMs
317
+ );
318
+ }
319
+
320
+ if (
321
+ this.outputFormat === "pretty" &&
322
+ this.config.summaryIntervalMs > 0
323
+ ) {
324
+ this.summaryTimer = setInterval(
325
+ () => this.emitSummary(),
326
+ this.config.summaryIntervalMs
327
+ );
328
+ }
329
+
330
+ const openTerminal =
331
+ this.config.openTerminal && this.outputFormat === "pretty";
332
+ if (openTerminal) {
333
+ this.openTerminal();
334
+ }
335
+ }
336
+
337
+ stop(): void {
338
+ this.flush(true);
339
+ if (this.tailProcess) {
340
+ this.tailProcess.kill();
341
+ this.tailProcess = null;
342
+ }
343
+ if (this.flushTimer) {
344
+ clearInterval(this.flushTimer);
345
+ this.flushTimer = null;
346
+ }
347
+ if (this.summaryTimer) {
348
+ clearInterval(this.summaryTimer);
349
+ this.summaryTimer = null;
350
+ }
351
+ if (this.logStream) {
352
+ this.logStream.end();
353
+ this.logStream = null;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Log a tool call (invocation)
359
+ */
360
+ logTool(
361
+ name: string,
362
+ args?: Record<string, unknown> | null,
363
+ _result?: unknown,
364
+ error?: string,
365
+ ): void {
366
+ this.lastToolArgs.set(name, args ?? null);
367
+ const argsStr = summarizeArgs(args);
368
+ const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
369
+
370
+ if (error) {
371
+ this.enqueue({
372
+ ts: new Date().toISOString(),
373
+ type: "tool.error",
374
+ severity: "error",
375
+ source: "tool",
376
+ message: `ERROR: ${error}`,
377
+ actionRequired: true,
378
+ fingerprint: `tool:error:${name}:${argsStr}`,
379
+ data: { tool: name, tag, args, argsSummary: argsStr, error },
380
+ });
381
+ return;
382
+ }
383
+
384
+ this.enqueue({
385
+ ts: new Date().toISOString(),
386
+ type: "tool.call",
387
+ severity: "info",
388
+ source: "tool",
389
+ data: { tool: name, tag, args, argsSummary: argsStr },
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Log a tool result
395
+ */
396
+ logResult(name: string, result: unknown): void {
397
+ const lastArgs = this.lastToolArgs.get(name) ?? null;
398
+ if (this.lastToolArgs.has(name)) {
399
+ this.lastToolArgs.delete(name);
400
+ }
401
+ const summary = summarizeResult(result);
402
+ const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
403
+
404
+ if (summary) {
405
+ this.enqueue({
406
+ ts: new Date().toISOString(),
407
+ type: "tool.result",
408
+ severity: "info",
409
+ source: "tool",
410
+ data: { tool: name, tag, summary },
411
+ });
412
+ }
413
+
414
+ this.logStructuredResult(name, result, lastArgs);
415
+ }
416
+
417
+ /**
418
+ * Log a watch event (called from watcher)
419
+ */
420
+ logWatch(level: string, ruleId: string, file: string, message: string): void {
421
+ const severity: MonitorSeverity =
422
+ level === "error" ? "error" : level === "info" ? "info" : "warn";
423
+
424
+ this.enqueue({
425
+ ts: new Date().toISOString(),
426
+ type: "watch.warning",
427
+ severity,
428
+ source: "watch",
429
+ message,
430
+ actionRequired: severity !== "info",
431
+ fingerprint: `watch:${ruleId}:${file}:${message}`,
432
+ data: { ruleId, file, level, message },
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Log a custom event
438
+ */
439
+ logEvent(category: string, message: string): void {
440
+ this.enqueue({
441
+ ts: new Date().toISOString(),
442
+ type: "system.event",
443
+ severity: "info",
444
+ source: "system",
445
+ message,
446
+ data: { category },
447
+ });
448
+ }
449
+
450
+ private logStructuredResult(
451
+ name: string,
452
+ result: unknown,
453
+ lastArgs: Record<string, unknown> | null
454
+ ): void {
455
+ if (!result || typeof result !== "object") return;
456
+ const obj = result as Record<string, unknown>;
457
+
458
+ if (name === "mandu_guard_check") {
459
+ const violations = Array.isArray(obj.violations) ? obj.violations : [];
460
+ const passed = obj.passed === true;
461
+ const count = violations.length;
462
+
463
+ if (count > 0) {
464
+ this.enqueue({
465
+ ts: new Date().toISOString(),
466
+ type: "guard.summary",
467
+ severity: passed ? "info" : "error",
468
+ source: "guard",
469
+ actionRequired: !passed,
470
+ fingerprint: `guard:summary:${count}`,
471
+ data: { passed, count },
472
+ });
473
+
474
+ for (const violation of violations) {
475
+ const v = violation as Record<string, unknown>;
476
+ const ruleId = (v.ruleId as string | undefined) ?? "UNKNOWN_RULE";
477
+ const file = v.file as string | undefined;
478
+ const line = typeof v.line === "number" ? v.line : undefined;
479
+ const column = typeof v.column === "number" ? v.column : undefined;
480
+ const message =
481
+ (v.message as string | undefined) ??
482
+ (v.reason as string | undefined) ??
483
+ "Guard violation";
484
+ const suggestion =
485
+ (v.suggestion as string | undefined) ??
486
+ (v.tip as string | undefined);
487
+
488
+ this.enqueue({
489
+ ts: new Date().toISOString(),
490
+ type: "guard.violation",
491
+ severity: "error",
492
+ source: "guard",
493
+ message,
494
+ actionRequired: true,
495
+ fingerprint: `guard:${ruleId}:${file ?? ""}:${line ?? ""}:${column ?? ""}`,
496
+ data: {
497
+ ruleId,
498
+ file,
499
+ line,
500
+ column,
501
+ message,
502
+ suggestion,
503
+ },
504
+ });
505
+ }
506
+ }
507
+
508
+ return;
509
+ }
510
+
511
+ if (name === "mandu_check_location") {
512
+ const allowed = obj.allowed === true;
513
+ if (allowed) return;
514
+
515
+ const violations = Array.isArray(obj.violations) ? obj.violations : [];
516
+ const argPath = typeof lastArgs?.path === "string" ? lastArgs.path : undefined;
517
+ for (const violation of violations) {
518
+ const v = violation as Record<string, unknown>;
519
+ const ruleId = (v.rule as string | undefined) ?? "LOCATION_RULE";
520
+ const message = (v.message as string | undefined) ?? "Location violation";
521
+ const severity = (v.severity as MonitorSeverity | undefined) ?? "warn";
522
+
523
+ this.enqueue({
524
+ ts: new Date().toISOString(),
525
+ type: "guard.violation",
526
+ severity,
527
+ source: "architecture",
528
+ message,
529
+ actionRequired: severity !== "info",
530
+ fingerprint: `guard:location:${ruleId}:${argPath ?? ""}`,
531
+ data: {
532
+ ruleId,
533
+ file: argPath,
534
+ message,
535
+ suggestion: obj.suggestion,
536
+ recommendedPath: obj.recommendedPath,
537
+ },
538
+ });
539
+ }
540
+ return;
541
+ }
542
+
543
+ if (name === "mandu_check_import") {
544
+ const allowed = obj.allowed === true;
545
+ if (allowed) return;
546
+
547
+ const violations = Array.isArray(obj.violations) ? obj.violations : [];
548
+ const sourceFile = typeof lastArgs?.sourceFile === "string" ? lastArgs.sourceFile : undefined;
549
+ for (const violation of violations) {
550
+ const v = violation as Record<string, unknown>;
551
+ const message = (v.reason as string | undefined) ?? "Import violation";
552
+ const suggestion = v.suggestion as string | undefined;
553
+ const importTarget = typeof v.import === "string" ? v.import : undefined;
554
+
555
+ this.enqueue({
556
+ ts: new Date().toISOString(),
557
+ type: "guard.violation",
558
+ severity: "error",
559
+ source: "architecture",
560
+ message,
561
+ actionRequired: true,
562
+ fingerprint: `guard:import:${sourceFile ?? ""}:${importTarget ?? ""}`,
563
+ data: {
564
+ ruleId: "IMPORT_RULE",
565
+ file: sourceFile,
566
+ import: importTarget,
567
+ message,
568
+ suggestion,
569
+ },
570
+ });
571
+ }
572
+ return;
573
+ }
574
+
575
+ if (
576
+ name === "mandu_add_route" ||
577
+ name === "mandu_update_route" ||
578
+ name === "mandu_delete_route"
579
+ ) {
580
+ if (obj.success !== true) return;
581
+ const action =
582
+ name === "mandu_add_route"
583
+ ? "add"
584
+ : name === "mandu_update_route"
585
+ ? "update"
586
+ : "delete";
587
+ const route =
588
+ (obj.route as Record<string, unknown> | undefined) ??
589
+ (obj.deletedRoute as Record<string, unknown> | undefined);
590
+
591
+ this.enqueue({
592
+ ts: new Date().toISOString(),
593
+ type: "routes.change",
594
+ severity: "info",
595
+ source: "routes",
596
+ actionRequired: false,
597
+ fingerprint: `routes:${action}:${route?.id ?? ""}`,
598
+ data: {
599
+ action,
600
+ routeId: route?.id,
601
+ pattern: route?.pattern,
602
+ kind: route?.kind,
603
+ },
604
+ });
605
+ }
606
+ }
607
+
608
+ private enqueue(event: MonitorEvent): void {
609
+ if (!this.logStream) return;
610
+ const now = Date.now();
611
+
612
+ if (!event.fingerprint || this.config.dedupeWindowMs <= 0) {
613
+ this.pending.push(event);
614
+ return;
615
+ }
616
+
617
+ const existing = this.dedupeMap.get(event.fingerprint);
618
+ if (!existing) {
619
+ this.dedupeMap.set(event.fingerprint, {
620
+ event,
621
+ count: 1,
622
+ windowStart: now,
623
+ lastTs: now,
624
+ });
625
+ return;
626
+ }
627
+
628
+ if (now - existing.windowStart >= this.config.dedupeWindowMs) {
629
+ this.pending.push(this.withCount(existing));
630
+ this.dedupeMap.set(event.fingerprint, {
631
+ event,
632
+ count: 1,
633
+ windowStart: now,
634
+ lastTs: now,
635
+ });
636
+ return;
637
+ }
638
+
639
+ existing.count += 1;
640
+ existing.lastTs = now;
641
+ existing.event = event;
642
+ }
643
+
644
+ private flush(force: boolean): void {
645
+ if (!this.logStream) return;
646
+ const now = Date.now();
647
+ const windowMs = this.config.dedupeWindowMs;
648
+
649
+ for (const [key, entry] of this.dedupeMap) {
650
+ const idleMs = now - entry.lastTs;
651
+ if (force || idleMs >= windowMs) {
652
+ this.pending.push(this.withCount(entry));
653
+ this.dedupeMap.delete(key);
654
+ }
655
+ }
656
+
657
+ if (this.pending.length === 0) return;
658
+
659
+ for (const event of this.pending) {
660
+ const line = this.formatEvent(event);
661
+ if (!line) continue;
662
+ this.write(line);
663
+ this.updateSummary(event);
664
+ }
665
+
666
+ this.pending = [];
667
+ }
668
+
669
+ private withCount(entry: DedupeEntry): MonitorEvent {
670
+ return {
671
+ ...entry.event,
672
+ count: entry.count,
673
+ };
674
+ }
675
+
676
+ private formatEvent(event: MonitorEvent): string {
677
+ if (this.outputFormat === "json") {
678
+ const payload = event.schemaVersion
679
+ ? event
680
+ : { schemaVersion: SCHEMA_VERSION, ...event };
681
+ return `${JSON.stringify(payload)}\n`;
682
+ }
683
+ return this.formatEventPretty(event);
684
+ }
685
+
686
+ private formatEventPretty(event: MonitorEvent): string {
687
+ const time = formatTime(event.ts);
688
+ const countSuffix = event.count && event.count > 1 ? ` x${event.count}` : "";
689
+
690
+ switch (event.type) {
691
+ case "tool.call": {
692
+ const tag = event.data?.tag as string | undefined;
693
+ const argsSummary = event.data?.argsSummary as string | undefined;
694
+ return `${time} → [${tag ?? "TOOL"}]${argsSummary ?? ""}${countSuffix}\n`;
695
+ }
696
+ case "tool.error": {
697
+ const tag = event.data?.tag as string | undefined;
698
+ const argsSummary = event.data?.argsSummary as string | undefined;
699
+ const message = event.message ?? "ERROR";
700
+ return `${time} ✗ [${tag ?? "TOOL"}]${argsSummary ?? ""}${countSuffix}\n ${message}\n`;
701
+ }
702
+ case "tool.result": {
703
+ const tag = event.data?.tag as string | undefined;
704
+ const summary = event.data?.summary as string | undefined;
705
+ if (!summary) return "";
706
+ return `${time} ✓ [${tag ?? "TOOL"}]${summary}${countSuffix}\n`;
707
+ }
708
+ case "watch.warning": {
709
+ const ruleId = event.data?.ruleId as string | undefined;
710
+ const file = event.data?.file as string | undefined;
711
+ const message = event.message ?? "";
712
+ const icon = event.severity === "info" ? "ℹ" : "⚠";
713
+ return `${time} ${icon} [WATCH:${ruleId ?? "UNKNOWN"}] ${file ?? ""}${countSuffix}\n ${message}\n`;
714
+ }
715
+ case "system.event": {
716
+ const category = event.data?.category as string | undefined;
717
+ const message = event.message ?? "";
718
+ return `${time} [${category ?? "SYSTEM"}] ${message}${countSuffix}\n`;
719
+ }
720
+ case "monitor.summary": {
721
+ return `${time} · SUMMARY ${event.message ?? ""}\n`;
722
+ }
723
+ default:
724
+ return `${time} [${event.type}] ${event.message ?? ""}${countSuffix}\n`;
725
+ }
726
+ }
727
+
728
+ private updateSummary(event: MonitorEvent): void {
729
+ const count = event.count ?? 1;
730
+ this.summaryCounts.total += count;
731
+ this.summaryCounts[event.severity] += count;
732
+ }
733
+
734
+ private emitSummary(): void {
735
+ if (this.summaryCounts.total === 0) return;
736
+ const seconds = Math.round(this.config.summaryIntervalMs / 1000);
737
+ const message = `last ${seconds}s · total=${this.summaryCounts.total} · error=${this.summaryCounts.error} · warn=${this.summaryCounts.warn} · info=${this.summaryCounts.info}`;
738
+ this.summaryCounts = { total: 0, info: 0, warn: 0, error: 0 };
739
+ const summaryEvent: MonitorEvent = {
740
+ ts: new Date().toISOString(),
741
+ type: "monitor.summary",
742
+ severity: "info",
743
+ source: "monitor",
744
+ message,
745
+ };
746
+ const line = this.formatEvent(summaryEvent);
747
+ if (line) {
748
+ this.write(line);
749
+ }
750
+ }
751
+
752
+ private write(text: string): void {
753
+ if (this.logStream) {
754
+ this.logStream.write(text);
755
+ }
756
+ }
757
+
758
+ private archiveExistingLog(): void {
759
+ try {
760
+ if (!fs.existsSync(this.logFile)) return;
761
+ const dir = path.dirname(this.logFile);
762
+ const base = path.basename(this.logFile);
763
+ const stamp = new Date()
764
+ .toISOString()
765
+ .replace(/[:.]/g, "")
766
+ .slice(0, 15);
767
+ const archived = path.join(dir, `${base}.${stamp}.bak`);
768
+ fs.renameSync(this.logFile, archived);
769
+ } catch {
770
+ // ignore archive errors
771
+ }
772
+ }
773
+
774
+ private pruneArchivedLogs(): void {
775
+ try {
776
+ const dir = path.dirname(this.logFile);
777
+ const base = path.basename(this.logFile);
778
+ const files = fs
779
+ .readdirSync(dir)
780
+ .filter((file) => file.startsWith(`${base}.`) && file.endsWith(".bak"));
781
+
782
+ const entries = files
783
+ .map((file) => {
784
+ const fullPath = path.join(dir, file);
785
+ const stat = fs.statSync(fullPath);
786
+ return { file, fullPath, mtime: stat.mtime.getTime() };
787
+ })
788
+ .sort((a, b) => b.mtime - a.mtime);
789
+
790
+ const retentionMs =
791
+ (this.config.store.retentionDays || 7) * 24 * 60 * 60 * 1000;
792
+ const now = Date.now();
793
+
794
+ for (const entry of entries) {
795
+ if (now - entry.mtime > retentionMs) {
796
+ fs.unlinkSync(entry.fullPath);
797
+ }
798
+ }
799
+
800
+ const maxArchived = this.config.store.maxArchived || 20;
801
+ const remaining = entries.filter((entry) => fs.existsSync(entry.fullPath));
802
+ if (remaining.length > maxArchived) {
803
+ const toRemove = remaining.slice(maxArchived);
804
+ for (const entry of toRemove) {
805
+ fs.unlinkSync(entry.fullPath);
806
+ }
807
+ }
808
+ } catch {
809
+ // ignore prune errors
810
+ }
811
+ }
812
+
813
+ private openTerminal(): void {
814
+ try {
815
+ if (process.platform === "win32") {
816
+ this.tailProcess = spawn(
817
+ "cmd",
818
+ [
819
+ "/c",
820
+ "start",
821
+ "Mandu Activity Monitor",
822
+ "powershell",
823
+ "-NoExit",
824
+ "-Command",
825
+ `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 | Out-Null; Get-Content '${this.logFile}' -Wait -Encoding UTF8`,
826
+ ],
827
+ { cwd: this.projectRoot, detached: true, stdio: "ignore" }
828
+ );
829
+ } else if (process.platform === "darwin") {
830
+ this.tailProcess = spawn(
831
+ "osascript",
832
+ ["-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`],
833
+ { detached: true, stdio: "ignore" }
834
+ );
835
+ } else {
836
+ this.tailProcess = spawn(
837
+ "x-terminal-emulator",
838
+ ["-e", `tail -f '${this.logFile}'`],
839
+ { cwd: this.projectRoot, detached: true, stdio: "ignore" }
840
+ );
841
+ }
842
+ this.tailProcess?.unref();
843
+ } catch {
844
+ // Terminal auto-open failed silently
845
+ }
846
+ }
847
+ }