@xctrace-analyzer/mcp-server 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,2219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Xcode Instruments Trace Analyzer MCP Server
4
+ *
5
+ * Provides intelligent analysis of Xcode Instruments traces via Model Context Protocol
6
+ */
7
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { execFile as execFileCallback } from 'child_process';
10
+ import { readFileSync } from 'fs';
11
+ import { lstat, mkdir, mkdtemp, readdir, rm } from 'fs/promises';
12
+ import { homedir, tmpdir } from 'os';
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
16
+ import { analyzeTraceFile as defaultAnalyzeTraceFile, compareTraceFiles as defaultCompareTraceFiles, getXCTraceVersion as defaultGetXCTraceVersion, getXCTraceCapabilities as defaultGetXCTraceCapabilities, isXCTraceAvailable as defaultIsXCTraceAvailable, listDevices as defaultListDevices, listTemplates as defaultListTemplates, recordTrace as defaultRecordTrace, symbolicateTrace as defaultSymbolicateTrace, } from '@xctrace-analyzer/core';
17
+ function defaultOpenTrace(tracePath) {
18
+ return new Promise((resolveOpen, rejectOpen) => {
19
+ execFileCallback('open', [tracePath], (error) => {
20
+ if (error) {
21
+ rejectOpen(error);
22
+ return;
23
+ }
24
+ resolveOpen();
25
+ });
26
+ });
27
+ }
28
+ const defaultDependencies = {
29
+ analyzeTraceFile: defaultAnalyzeTraceFile,
30
+ compareTraceFiles: defaultCompareTraceFiles,
31
+ listTemplates: defaultListTemplates,
32
+ listDevices: defaultListDevices,
33
+ isXCTraceAvailable: defaultIsXCTraceAvailable,
34
+ getXCTraceVersion: defaultGetXCTraceVersion,
35
+ getXCTraceCapabilities: defaultGetXCTraceCapabilities,
36
+ recordTrace: defaultRecordTrace,
37
+ symbolicateTrace: defaultSymbolicateTrace,
38
+ openTrace: defaultOpenTrace,
39
+ };
40
+ const passthroughJsonSchemaValidator = {
41
+ getValidator() {
42
+ return (input) => ({
43
+ valid: true,
44
+ data: input,
45
+ errorMessage: undefined,
46
+ });
47
+ },
48
+ };
49
+ const PROFILE_PRESETS = {
50
+ cpu: { template: 'Time Profiler', instruments: [] },
51
+ memory: { template: 'Allocations', instruments: ['Leaks'] },
52
+ network: { template: 'Time Profiler', instruments: ['HTTP Traffic'] },
53
+ energy: { template: 'Power Profiler', instruments: [] },
54
+ full: { template: 'Time Profiler', instruments: ['Leaks', 'Allocations', 'HTTP Traffic'] },
55
+ 'full-ios': {
56
+ template: 'Time Profiler',
57
+ instruments: ['Leaks', 'Allocations', 'HTTP Traffic', 'Power Profiler'],
58
+ },
59
+ };
60
+ const SERVER_NAME = 'xctrace-analyzer';
61
+ const SERVER_VERSION = readPackageVersion();
62
+ const DEFAULT_TRACE_ROOT = getDefaultTraceRoot();
63
+ const DEFAULT_MAX_DURATION_SECONDS = 300;
64
+ const MAX_TOP_N = 100;
65
+ const MAX_STRING_LENGTH = 4096;
66
+ const MAX_LAUNCH_ARGUMENTS = 128;
67
+ const MAX_USER_BINARY_HINTS = 64;
68
+ const MAX_ENVIRONMENT_VARIABLES = 64;
69
+ /**
70
+ * MCP Server for Xcode Instruments trace analysis
71
+ */
72
+ export class XCTraceAnalyzerServer {
73
+ server;
74
+ deps;
75
+ security;
76
+ recordedTracePaths = new Set();
77
+ constructor(deps = defaultDependencies, securityOptions = {}) {
78
+ this.deps = deps;
79
+ this.security = resolveSecurityOptions(securityOptions);
80
+ this.server = new Server({
81
+ name: SERVER_NAME,
82
+ version: SERVER_VERSION,
83
+ }, {
84
+ capabilities: {
85
+ tools: {},
86
+ },
87
+ // This server does not issue elicitation requests, so avoid the SDK's
88
+ // default AJV startup path.
89
+ jsonSchemaValidator: passthroughJsonSchemaValidator,
90
+ });
91
+ this.setupHandlers();
92
+ }
93
+ setupHandlers() {
94
+ // List available tools
95
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
96
+ tools: this.getTools(),
97
+ }));
98
+ // Handle tool calls
99
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
100
+ return await this.callTool(request.params.name, request.params.arguments);
101
+ });
102
+ }
103
+ /**
104
+ * Define available MCP tools
105
+ */
106
+ getTools() {
107
+ return [
108
+ {
109
+ name: 'analyze_trace',
110
+ description: 'Analyze an Xcode Instruments trace file for performance bottlenecks and generate recommendations',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ tracePath: {
115
+ type: 'string',
116
+ description: 'Path to the .trace file to analyze',
117
+ },
118
+ slowThreshold: {
119
+ type: 'number',
120
+ description: 'Threshold in milliseconds to consider a function slow (default: 100)',
121
+ },
122
+ topN: {
123
+ type: 'number',
124
+ description: 'Number of top functions to show (default: 10)',
125
+ },
126
+ dsymPath: {
127
+ type: 'string',
128
+ description: 'Optional dSYM path or directory. The server symbolicates to a temporary trace before analysis.',
129
+ },
130
+ timeRangeMs: {
131
+ type: 'object',
132
+ properties: {
133
+ startMs: {
134
+ type: 'number',
135
+ description: 'Trace-relative window start in milliseconds',
136
+ },
137
+ endMs: {
138
+ type: 'number',
139
+ description: 'Trace-relative window end in milliseconds',
140
+ },
141
+ },
142
+ required: ['startMs', 'endMs'],
143
+ description: 'Restrict analysis to a trace-relative window. Useful for asking what ran during a specific hang.',
144
+ },
145
+ userBinaryHints: {
146
+ type: 'array',
147
+ items: { type: 'string' },
148
+ description: 'Optional app/module names used to attribute Time Profiler samples to user code.',
149
+ },
150
+ outputFormat: {
151
+ type: 'string',
152
+ enum: ['markdown', 'json', 'both'],
153
+ description: 'Response format: markdown, json, or both (default: markdown)',
154
+ },
155
+ },
156
+ required: ['tracePath'],
157
+ },
158
+ },
159
+ {
160
+ name: 'compare_traces',
161
+ description: 'Compare two trace files to detect performance regressions or improvements',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ baselinePath: {
166
+ type: 'string',
167
+ description: 'Path to the baseline .trace file',
168
+ },
169
+ currentPath: {
170
+ type: 'string',
171
+ description: 'Path to the current .trace file to compare',
172
+ },
173
+ regressionThreshold: {
174
+ type: 'number',
175
+ description: 'Percentage increase to flag as regression (default: 10)',
176
+ },
177
+ failOnRegression: {
178
+ type: 'boolean',
179
+ description: 'Whether to fail if regression is detected (default: false)',
180
+ },
181
+ baselineDsymPath: {
182
+ type: 'string',
183
+ description: 'Optional dSYM path or directory for the baseline trace',
184
+ },
185
+ currentDsymPath: {
186
+ type: 'string',
187
+ description: 'Optional dSYM path or directory for the current trace',
188
+ },
189
+ outputFormat: {
190
+ type: 'string',
191
+ enum: ['markdown', 'json', 'both'],
192
+ description: 'Response format: markdown, json, or both (default: markdown)',
193
+ },
194
+ },
195
+ required: ['baselinePath', 'currentPath'],
196
+ },
197
+ },
198
+ {
199
+ name: 'track_running_app',
200
+ description: 'Record one explicit Instruments template. Use this when the user names a template such as Leaks or Allocations; for broad hangs/CPU profiling prefer the bundled skill or profile_running_app.',
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ processName: {
205
+ type: 'string',
206
+ description: 'Running process name or pid to attach to, for example MyApp. Use a pid when the name is ambiguous.',
207
+ },
208
+ target: {
209
+ type: 'string',
210
+ enum: ['attach', 'launch', 'all-processes'],
211
+ description: 'Recording target mode. Defaults to attach when processName is provided.',
212
+ },
213
+ launchCommand: {
214
+ type: 'string',
215
+ description: 'Command, app path, or bundle identifier to launch when startup/cold-launch behavior is the target. Prefer processName with a PID for already-running apps.',
216
+ },
217
+ launchArguments: {
218
+ type: 'array',
219
+ items: { type: 'string' },
220
+ description: 'Arguments passed after the launched command',
221
+ },
222
+ environment: {
223
+ type: 'object',
224
+ additionalProperties: { type: 'string' },
225
+ description: 'Environment variables for launch recordings',
226
+ },
227
+ targetStdin: {
228
+ type: 'string',
229
+ description: 'Standard input redirection for launch recordings',
230
+ },
231
+ targetStdout: {
232
+ type: 'string',
233
+ description: 'Standard output redirection for launch recordings',
234
+ },
235
+ allProcesses: {
236
+ type: 'boolean',
237
+ description: 'Record all processes. Equivalent to target: all-processes.',
238
+ },
239
+ template: {
240
+ type: 'string',
241
+ description: 'Instruments template to record with, for example Leaks, Allocations, Network, Power Profiler, or Time Profiler (default: Leaks)',
242
+ },
243
+ durationSeconds: {
244
+ type: 'number',
245
+ description: 'Recording duration in seconds (default: 60)',
246
+ },
247
+ device: {
248
+ type: 'string',
249
+ description: 'Optional device or simulator name/UDID to profile',
250
+ },
251
+ outputDirectory: {
252
+ type: 'string',
253
+ description: 'Directory where the .trace file should be saved (default: configured trace root)',
254
+ },
255
+ outputPath: {
256
+ type: 'string',
257
+ description: 'Optional exact output .trace path. Overrides outputDirectory.',
258
+ },
259
+ analyze: {
260
+ type: 'boolean',
261
+ description: 'Analyze the trace after recording (default: true)',
262
+ },
263
+ openInInstruments: {
264
+ type: 'boolean',
265
+ description: 'Open the saved .trace in Instruments.app after recording (default: true). Set false for CI or headless runs.',
266
+ },
267
+ outputFormat: {
268
+ type: 'string',
269
+ enum: ['markdown', 'json', 'both'],
270
+ description: 'Response format: markdown, json, or both (default: markdown)',
271
+ },
272
+ slowThreshold: {
273
+ type: 'number',
274
+ description: 'Threshold in milliseconds to consider a function slow when analyzing Time Profiler data',
275
+ },
276
+ topN: {
277
+ type: 'number',
278
+ description: 'Number of top functions to show when analyzing Time Profiler data',
279
+ },
280
+ userBinaryHints: {
281
+ type: 'array',
282
+ items: { type: 'string' },
283
+ description: 'Optional app/module names used to attribute Time Profiler samples to user code.',
284
+ },
285
+ },
286
+ required: [],
287
+ },
288
+ },
289
+ {
290
+ name: 'profile_running_app',
291
+ description: 'Record an app once with a profiling preset and return one combined report. Prefer attach-by-PID for already-running apps; use launch mode only for startup/cold-launch profiling.',
292
+ inputSchema: {
293
+ type: 'object',
294
+ properties: {
295
+ processName: {
296
+ type: 'string',
297
+ description: 'Running process name or pid to attach to, for example MyApp. Use a pid when the name is ambiguous.',
298
+ },
299
+ target: {
300
+ type: 'string',
301
+ enum: ['attach', 'launch', 'all-processes'],
302
+ description: 'Recording target mode. Defaults to attach when processName is provided.',
303
+ },
304
+ launchCommand: {
305
+ type: 'string',
306
+ description: 'Command, app path, or bundle identifier to launch when startup/cold-launch behavior is the target. Prefer processName with a PID for already-running apps.',
307
+ },
308
+ launchArguments: {
309
+ type: 'array',
310
+ items: { type: 'string' },
311
+ description: 'Arguments passed after the launched command',
312
+ },
313
+ environment: {
314
+ type: 'object',
315
+ additionalProperties: { type: 'string' },
316
+ description: 'Environment variables for launch recordings',
317
+ },
318
+ targetStdin: {
319
+ type: 'string',
320
+ description: 'Standard input redirection for launch recordings',
321
+ },
322
+ targetStdout: {
323
+ type: 'string',
324
+ description: 'Standard output redirection for launch recordings',
325
+ },
326
+ allProcesses: {
327
+ type: 'boolean',
328
+ description: 'Record all processes. Equivalent to target: all-processes.',
329
+ },
330
+ preset: {
331
+ type: 'string',
332
+ description: 'Profiling preset: full, full-ios, cpu, memory, network, or energy (default: full)',
333
+ },
334
+ durationSeconds: {
335
+ type: 'number',
336
+ description: 'Total recording duration in seconds (default: 60)',
337
+ },
338
+ device: {
339
+ type: 'string',
340
+ description: 'Optional device or simulator name/UDID to profile',
341
+ },
342
+ outputDirectory: {
343
+ type: 'string',
344
+ description: 'Directory where generated .trace files should be saved (default: configured trace root)',
345
+ },
346
+ analyze: {
347
+ type: 'boolean',
348
+ description: 'Analyze traces after recording (default: true)',
349
+ },
350
+ openInInstruments: {
351
+ type: 'boolean',
352
+ description: 'Open the saved .trace in Instruments.app after recording (default: true). Set false for CI or headless runs.',
353
+ },
354
+ outputFormat: {
355
+ type: 'string',
356
+ enum: ['markdown', 'json', 'both'],
357
+ description: 'Response format: markdown, json, or both (default: markdown)',
358
+ },
359
+ slowThreshold: {
360
+ type: 'number',
361
+ description: 'Threshold in milliseconds to consider a function slow when analyzing Time Profiler data',
362
+ },
363
+ topN: {
364
+ type: 'number',
365
+ description: 'Number of top functions to show when analyzing Time Profiler data',
366
+ },
367
+ userBinaryHints: {
368
+ type: 'array',
369
+ items: { type: 'string' },
370
+ description: 'Optional app/module names used to attribute Time Profiler samples to user code.',
371
+ },
372
+ },
373
+ required: [],
374
+ },
375
+ },
376
+ {
377
+ name: 'list_templates',
378
+ description: 'List all available Instruments templates on this system',
379
+ inputSchema: {
380
+ type: 'object',
381
+ properties: {},
382
+ },
383
+ },
384
+ {
385
+ name: 'list_devices',
386
+ description: 'List all available devices (simulators and real devices) for profiling',
387
+ inputSchema: {
388
+ type: 'object',
389
+ properties: {},
390
+ },
391
+ },
392
+ {
393
+ name: 'check_xctrace',
394
+ description: 'Check if xctrace is available and get version information',
395
+ inputSchema: {
396
+ type: 'object',
397
+ properties: {},
398
+ },
399
+ },
400
+ {
401
+ name: 'cleanup_traces',
402
+ description: 'Trace garbage collector. Preview or delete .trace bundles after the user is done inspecting profiling results.',
403
+ inputSchema: {
404
+ type: 'object',
405
+ properties: {
406
+ tracePaths: {
407
+ type: 'array',
408
+ items: { type: 'string' },
409
+ description: 'Exact .trace paths to preview or delete. Safest option after a profiling report.',
410
+ },
411
+ directory: {
412
+ type: 'string',
413
+ description: 'Directory to scan for .trace bundles when tracePaths is omitted (default: configured trace root).',
414
+ },
415
+ recursive: {
416
+ type: 'boolean',
417
+ description: 'Recursively scan subdirectories for .trace bundles when using directory mode (default: false).',
418
+ },
419
+ olderThanMinutes: {
420
+ type: 'number',
421
+ description: 'Only match traces older than this many minutes. Required for destructive directory cleanup.',
422
+ },
423
+ dryRun: {
424
+ type: 'boolean',
425
+ description: 'Preview cleanup without deleting files (default: true). Set false only after the user confirms the traces are no longer needed.',
426
+ },
427
+ outputFormat: {
428
+ type: 'string',
429
+ enum: ['markdown', 'json', 'both'],
430
+ description: 'Response format: markdown, json, or both (default: markdown)',
431
+ },
432
+ },
433
+ required: [],
434
+ },
435
+ },
436
+ ];
437
+ }
438
+ /**
439
+ * Handle tool calls
440
+ */
441
+ async callTool(toolName, args) {
442
+ try {
443
+ switch (toolName) {
444
+ case 'analyze_trace':
445
+ return await this.analyzeTrace(args);
446
+ case 'compare_traces':
447
+ return await this.compareTraces(args);
448
+ case 'track_running_app':
449
+ return await this.trackRunningApp(args);
450
+ case 'profile_running_app':
451
+ return await this.profileRunningApp(args);
452
+ case 'list_templates':
453
+ return await this.listTemplates();
454
+ case 'list_devices':
455
+ return await this.listDevices();
456
+ case 'check_xctrace':
457
+ return await this.checkXCTrace();
458
+ case 'cleanup_traces':
459
+ return await this.cleanupTraces(args);
460
+ default:
461
+ throw new Error(`Unknown tool: ${toolName}`);
462
+ }
463
+ }
464
+ catch (error) {
465
+ const err = error;
466
+ return {
467
+ content: [
468
+ {
469
+ type: 'text',
470
+ text: this.formatToolError(err),
471
+ },
472
+ ],
473
+ isError: true,
474
+ };
475
+ }
476
+ }
477
+ formatToolError(error) {
478
+ const message = `Error: ${this.safeInlineText(error.message)}`;
479
+ if (!this.isTraceTocExportFailure(error.message)) {
480
+ return this.sanitizeReportText(message);
481
+ }
482
+ return this.sanitizeReportText([
483
+ message,
484
+ '',
485
+ '## Next Steps',
486
+ ...this.traceExportFailureNextSteps().map((step) => `- ${step}`),
487
+ ].join('\n'));
488
+ }
489
+ /**
490
+ * Analyze a trace file
491
+ */
492
+ async analyzeTrace(args) {
493
+ const tracePath = this.requiredString(args?.tracePath, 'tracePath');
494
+ const outputFormat = this.outputFormat(args);
495
+ const timeRangeMs = this.optionalTimeRangeMs(args?.timeRangeMs);
496
+ const userBinaryHints = this.optionalStringArray(args?.userBinaryHints, 'userBinaryHints', MAX_USER_BINARY_HINTS);
497
+ const slowThreshold = this.optionalNonNegativeNumber(args?.slowThreshold, 'slowThreshold');
498
+ const topN = this.optionalPositiveInteger(args?.topN, 'topN', MAX_TOP_N);
499
+ const preparedTracePath = await this.prepareTraceForAnalysis(tracePath, this.optionalString(args?.dsymPath, 'dsymPath'));
500
+ const options = {
501
+ slowThreshold,
502
+ topN,
503
+ includeRecommendations: true,
504
+ ...(timeRangeMs ? { timeRangeMs } : {}),
505
+ ...(userBinaryHints ? { userBinaryHints } : {}),
506
+ };
507
+ const analysis = await this.deps.analyzeTraceFile(preparedTracePath, options);
508
+ if (preparedTracePath !== tracePath) {
509
+ analysis.exportAttempts = [
510
+ {
511
+ kind: 'symbolication',
512
+ status: 'success',
513
+ message: `Symbolicated ${tracePath} to ${preparedTracePath}`,
514
+ },
515
+ ...(analysis.exportAttempts ?? []),
516
+ ];
517
+ }
518
+ // Format output for Claude
519
+ const output = this.formatAnalysisOutput(this.safeDisplayValue(analysis));
520
+ const text = this.formatToolOutput(output, this.structuredAnalysis(analysis), outputFormat);
521
+ return {
522
+ content: [
523
+ {
524
+ type: 'text',
525
+ text,
526
+ },
527
+ ],
528
+ };
529
+ }
530
+ /**
531
+ * Compare two trace files
532
+ */
533
+ async compareTraces(args) {
534
+ const { baselinePath, currentPath } = args;
535
+ const outputFormat = this.outputFormat(args);
536
+ const preparedBaselinePath = await this.prepareTraceForAnalysis(this.requiredString(baselinePath, 'baselinePath'), this.optionalString(args?.baselineDsymPath, 'baselineDsymPath'));
537
+ const preparedCurrentPath = await this.prepareTraceForAnalysis(this.requiredString(currentPath, 'currentPath'), this.optionalString(args?.currentDsymPath, 'currentDsymPath'));
538
+ const comparisonOptions = {
539
+ regressionThreshold: this.optionalNonNegativeNumber(args?.regressionThreshold, 'regressionThreshold'),
540
+ failOnRegression: this.optionalBoolean(args?.failOnRegression, 'failOnRegression'),
541
+ };
542
+ const comparison = await this.deps.compareTraceFiles(preparedBaselinePath, preparedCurrentPath, undefined, comparisonOptions);
543
+ // Format output
544
+ const output = this.formatComparisonOutput(this.safeDisplayValue(comparison));
545
+ const text = this.formatToolOutput(output, comparison, outputFormat);
546
+ return {
547
+ content: [
548
+ {
549
+ type: 'text',
550
+ text,
551
+ },
552
+ ],
553
+ ...(comparisonOptions.failOnRegression && comparison.hasRegression ? { isError: true } : {}),
554
+ };
555
+ }
556
+ /**
557
+ * Record a running app and optionally analyze the captured trace.
558
+ */
559
+ async trackRunningApp(args) {
560
+ const target = this.recordTargetOptions(args);
561
+ const template = this.optionalString(args?.template, 'template') ?? 'Leaks';
562
+ const duration = this.optionalPositiveNumber(args?.durationSeconds, 'durationSeconds') ?? 60;
563
+ this.assertDurationWithinLimit(duration);
564
+ const outputFormat = this.outputFormat(args);
565
+ const outputPath = this.traceOutputPath(args, target.fileLabel, template);
566
+ const openInInstruments = this.optionalBoolean(args?.openInInstruments, 'openInInstruments') ?? true;
567
+ await mkdir(dirname(outputPath), { recursive: true });
568
+ const recordOptions = {
569
+ template,
570
+ ...target.recordOptions,
571
+ duration,
572
+ outputPath,
573
+ ...(args?.device !== undefined && args?.device !== null
574
+ ? { device: this.requiredString(args.device, 'device') }
575
+ : {}),
576
+ };
577
+ await this.deps.recordTrace(recordOptions);
578
+ this.rememberRecordedTrace(outputPath);
579
+ const instrumentsOpen = await this.openTraceInInstruments(outputPath, openInInstruments);
580
+ const lines = this.formatTrackingHeader({
581
+ target: target.reportLabel,
582
+ template,
583
+ duration,
584
+ device: recordOptions.device,
585
+ outputPath,
586
+ instrumentsOpen,
587
+ workflowWarnings: target.workflowWarnings,
588
+ });
589
+ if (args?.analyze === false) {
590
+ lines.push('Analysis skipped.');
591
+ return {
592
+ content: [
593
+ {
594
+ type: 'text',
595
+ text: this.formatToolOutput(lines.join('\n'), {
596
+ recording: {
597
+ target: target.reportLabel,
598
+ template,
599
+ duration,
600
+ outputPath,
601
+ instrumentsOpen,
602
+ workflowWarnings: target.workflowWarnings,
603
+ },
604
+ analysis: null,
605
+ }, outputFormat),
606
+ },
607
+ ],
608
+ };
609
+ }
610
+ const userBinaryHints = this.optionalStringArray(args?.userBinaryHints, 'userBinaryHints', MAX_USER_BINARY_HINTS);
611
+ const options = {
612
+ slowThreshold: this.optionalNonNegativeNumber(args?.slowThreshold, 'slowThreshold'),
613
+ topN: this.optionalPositiveInteger(args?.topN, 'topN', MAX_TOP_N),
614
+ includeRecommendations: true,
615
+ ...(userBinaryHints ? { userBinaryHints } : {}),
616
+ };
617
+ const analysis = await this.deps.analyzeTraceFile(outputPath, options);
618
+ lines.push(this.formatAnalysisOutput(this.safeDisplayValue(analysis)));
619
+ const markdown = lines.join('\n');
620
+ return {
621
+ content: [
622
+ {
623
+ type: 'text',
624
+ text: this.formatToolOutput(markdown, {
625
+ recording: {
626
+ target: target.reportLabel,
627
+ template,
628
+ duration,
629
+ outputPath,
630
+ instrumentsOpen,
631
+ workflowWarnings: target.workflowWarnings,
632
+ },
633
+ ...this.structuredAnalysis(analysis),
634
+ }, outputFormat),
635
+ },
636
+ ],
637
+ };
638
+ }
639
+ /**
640
+ * Record a running app with a preset of Instruments templates and return one report.
641
+ */
642
+ async profileRunningApp(args) {
643
+ const target = this.recordTargetOptions(args);
644
+ const preset = this.optionalString(args?.preset, 'preset') ?? 'full';
645
+ const profilePreset = this.profilePresetForName(preset);
646
+ const duration = this.optionalPositiveNumber(args?.durationSeconds, 'durationSeconds') ?? 60;
647
+ this.assertDurationWithinLimit(duration);
648
+ const outputFormat = this.outputFormat(args);
649
+ const outputDirectory = resolve(this.optionalString(args?.outputDirectory, 'outputDirectory') ?? this.security.traceRoot);
650
+ this.assertTraceDirectoryAllowed(outputDirectory, 'outputDirectory');
651
+ const device = args?.device !== undefined && args?.device !== null
652
+ ? this.requiredString(args.device, 'device')
653
+ : undefined;
654
+ const analyze = args?.analyze !== false;
655
+ const openInInstruments = this.optionalBoolean(args?.openInInstruments, 'openInInstruments') ?? true;
656
+ const startedAt = new Date().toISOString().replace(/[:.]/g, '-');
657
+ const results = [];
658
+ const capabilityWarnings = await this.profileCapabilityWarnings(profilePreset);
659
+ const userBinaryHints = this.optionalStringArray(args?.userBinaryHints, 'userBinaryHints', MAX_USER_BINARY_HINTS);
660
+ await mkdir(outputDirectory, { recursive: true });
661
+ const outputPath = this.profileTraceOutputPath(outputDirectory, target.fileLabel, preset, startedAt);
662
+ try {
663
+ await this.deps.recordTrace({
664
+ template: profilePreset.template,
665
+ instruments: profilePreset.instruments,
666
+ ...target.recordOptions,
667
+ duration,
668
+ outputPath,
669
+ ...(device ? { device } : {}),
670
+ });
671
+ this.rememberRecordedTrace(outputPath);
672
+ const instrumentsOpen = await this.openTraceInInstruments(outputPath, openInInstruments);
673
+ const analysis = analyze
674
+ ? await this.deps.analyzeTraceFile(outputPath, {
675
+ slowThreshold: this.optionalNonNegativeNumber(args?.slowThreshold, 'slowThreshold'),
676
+ topN: this.optionalPositiveInteger(args?.topN, 'topN', MAX_TOP_N),
677
+ includeRecommendations: true,
678
+ ...(userBinaryHints ? { userBinaryHints } : {}),
679
+ })
680
+ : undefined;
681
+ results.push({ template: profilePreset.template, tracePath: outputPath, instrumentsOpen, analysis });
682
+ }
683
+ catch (error) {
684
+ results.push({
685
+ template: profilePreset.template,
686
+ tracePath: outputPath,
687
+ error: error.message,
688
+ });
689
+ }
690
+ const output = this.formatProfileReport({
691
+ target: target.reportLabel,
692
+ preset,
693
+ baseTemplate: profilePreset.template,
694
+ instruments: profilePreset.instruments,
695
+ duration,
696
+ device,
697
+ results: this.safeDisplayValue(results),
698
+ analyze,
699
+ capabilityWarnings,
700
+ workflowWarnings: target.workflowWarnings,
701
+ });
702
+ const text = this.formatToolOutput(output, {
703
+ recording: {
704
+ target: target.reportLabel,
705
+ preset,
706
+ baseTemplate: profilePreset.template,
707
+ instruments: profilePreset.instruments,
708
+ duration,
709
+ device,
710
+ capabilityWarnings,
711
+ workflowWarnings: target.workflowWarnings,
712
+ },
713
+ results: results.map((result) => ({
714
+ template: result.template,
715
+ tracePath: result.tracePath,
716
+ instrumentsOpen: result.instrumentsOpen,
717
+ error: result.error,
718
+ analysis: result.analysis ? this.structuredAnalysis(result.analysis) : undefined,
719
+ })),
720
+ }, outputFormat);
721
+ return {
722
+ content: [
723
+ {
724
+ type: 'text',
725
+ text,
726
+ },
727
+ ],
728
+ ...(results.every((result) => result.error) ? { isError: true } : {}),
729
+ };
730
+ }
731
+ /**
732
+ * List available templates
733
+ */
734
+ async listTemplates() {
735
+ const templates = await this.deps.listTemplates();
736
+ const safeTemplates = templates.map((template) => this.safeInlineText(template));
737
+ return {
738
+ content: [
739
+ {
740
+ type: 'text',
741
+ text: this.sanitizeReportText(`Available Instruments Templates:\n\n${safeTemplates.join('\n')}`),
742
+ },
743
+ ],
744
+ };
745
+ }
746
+ /**
747
+ * List available devices
748
+ */
749
+ async listDevices() {
750
+ const devices = await this.deps.listDevices();
751
+ const safeDevices = devices.map((device) => this.safeInlineText(device));
752
+ return {
753
+ content: [
754
+ {
755
+ type: 'text',
756
+ text: this.sanitizeReportText(`Available Devices:\n\n${safeDevices.join('\n')}`),
757
+ },
758
+ ],
759
+ };
760
+ }
761
+ /**
762
+ * Check xctrace availability
763
+ */
764
+ async checkXCTrace() {
765
+ const capabilities = this.deps.getXCTraceCapabilities
766
+ ? await this.deps.getXCTraceCapabilities()
767
+ : await this.fallbackCapabilities();
768
+ if (!capabilities.available) {
769
+ return {
770
+ content: [
771
+ {
772
+ type: 'text',
773
+ text: '❌ xctrace is not available on this system.\n\nThis tool requires Xcode Command Line Tools to be installed on macOS.',
774
+ },
775
+ ],
776
+ };
777
+ }
778
+ const version = this.safeInlineText(capabilities.version ?? 'unknown');
779
+ const recordModes = capabilities.recordModes.map((mode) => this.safeInlineText(mode));
780
+ const exportModes = capabilities.exportModes.map((mode) => this.safeInlineText(mode));
781
+ const templates = capabilities.templates.map((template) => this.safeInlineText(template));
782
+ const warnings = capabilities.warnings.map((warning) => this.safeInlineText(warning));
783
+ const lines = [
784
+ '✅ xctrace is available and ready to use.',
785
+ '',
786
+ `Version: ${version}`,
787
+ '',
788
+ 'Capabilities:',
789
+ `- Record modes: ${recordModes.join(', ') || 'none detected'}`,
790
+ `- Export modes: ${exportModes.join(', ') || 'none detected'}`,
791
+ `- Symbolication: ${capabilities.supportsSymbolication ? 'available' : 'not detected'}`,
792
+ `- Templates detected: ${capabilities.templates.length}`,
793
+ `- Devices detected: ${capabilities.devices.length}`,
794
+ `- Addable instruments detected: ${capabilities.instruments.length}`,
795
+ ];
796
+ if (templates.length > 0) {
797
+ lines.push('');
798
+ lines.push('Templates:');
799
+ lines.push(...templates.map((template) => `- ${template}`));
800
+ }
801
+ if (warnings.length > 0) {
802
+ lines.push('');
803
+ lines.push('Warnings:');
804
+ lines.push(...warnings.map((warning) => `- ${warning}`));
805
+ }
806
+ return {
807
+ content: [
808
+ {
809
+ type: 'text',
810
+ text: this.sanitizeReportText(lines.join('\n')),
811
+ },
812
+ ],
813
+ };
814
+ }
815
+ /**
816
+ * Preview or delete generated .trace bundles.
817
+ */
818
+ async cleanupTraces(args) {
819
+ const outputFormat = this.outputFormat(args);
820
+ const dryRun = this.optionalBoolean(args?.dryRun, 'dryRun') ?? true;
821
+ const tracePaths = this.optionalStringArray(args?.tracePaths, 'tracePaths') ?? [];
822
+ const directory = this.optionalString(args?.directory, 'directory');
823
+ const recursive = this.optionalBoolean(args?.recursive, 'recursive') ?? false;
824
+ const olderThanMinutes = this.optionalNonNegativeNumber(args?.olderThanMinutes, 'olderThanMinutes');
825
+ if (!dryRun && tracePaths.length === 0 && olderThanMinutes === undefined) {
826
+ throw new Error('Refusing to delete a directory scan without exact tracePaths or olderThanMinutes. Preview with dryRun: true first, or pass olderThanMinutes.');
827
+ }
828
+ const scope = tracePaths.length > 0
829
+ ? 'exact trace paths'
830
+ : `${recursive ? 'recursive ' : ''}directory scan: ${resolve(directory ?? this.security.traceRoot)}`;
831
+ const candidatePaths = tracePaths.length > 0
832
+ ? tracePaths.map((path) => resolve(path))
833
+ : await this.discoverTraceBundles(resolve(directory ?? this.security.traceRoot), recursive);
834
+ if (!dryRun && tracePaths.length === 0) {
835
+ const scanDirectory = resolve(directory ?? this.security.traceRoot);
836
+ this.assertCleanupDirectoryAllowed(scanDirectory);
837
+ }
838
+ const entries = [];
839
+ const seenPaths = new Set();
840
+ for (const candidatePath of candidatePaths) {
841
+ if (seenPaths.has(candidatePath)) {
842
+ continue;
843
+ }
844
+ seenPaths.add(candidatePath);
845
+ entries.push(await this.cleanupTraceCandidate(candidatePath, dryRun, olderThanMinutes));
846
+ }
847
+ const result = {
848
+ dryRun,
849
+ scope,
850
+ matchedCount: entries.filter((entry) => entry.status === 'would_delete' || entry.status === 'deleted').length,
851
+ deletedCount: entries.filter((entry) => entry.status === 'deleted').length,
852
+ skippedCount: entries.filter((entry) => entry.status === 'skipped').length,
853
+ failedCount: entries.filter((entry) => entry.status === 'failed').length,
854
+ reclaimableBytes: entries
855
+ .filter((entry) => entry.status === 'would_delete')
856
+ .reduce((total, entry) => total + (entry.sizeBytes ?? 0), 0),
857
+ reclaimedBytes: entries
858
+ .filter((entry) => entry.status === 'deleted')
859
+ .reduce((total, entry) => total + (entry.sizeBytes ?? 0), 0),
860
+ entries,
861
+ };
862
+ const output = this.formatTraceCleanupOutput(result);
863
+ const text = this.formatToolOutput(output, result, outputFormat);
864
+ return {
865
+ content: [
866
+ {
867
+ type: 'text',
868
+ text,
869
+ },
870
+ ],
871
+ ...(result.failedCount > 0 ? { isError: true } : {}),
872
+ };
873
+ }
874
+ requiredString(value, fieldName) {
875
+ if (typeof value !== 'string' || value.trim() === '') {
876
+ throw new Error(`${fieldName} is required`);
877
+ }
878
+ const stringValue = value.trim();
879
+ if (stringValue.length > MAX_STRING_LENGTH) {
880
+ throw new Error(`${fieldName} must be ${MAX_STRING_LENGTH} characters or fewer`);
881
+ }
882
+ return stringValue;
883
+ }
884
+ optionalString(value, fieldName) {
885
+ if (value === undefined || value === null) {
886
+ return undefined;
887
+ }
888
+ return this.requiredString(value, fieldName);
889
+ }
890
+ optionalPositiveNumber(value, fieldName) {
891
+ if (value === undefined || value === null) {
892
+ return undefined;
893
+ }
894
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
895
+ throw new Error(`${fieldName} must be a positive number`);
896
+ }
897
+ return value;
898
+ }
899
+ optionalNonNegativeNumber(value, fieldName) {
900
+ if (value === undefined || value === null) {
901
+ return undefined;
902
+ }
903
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
904
+ throw new Error(`${fieldName} must be a non-negative number`);
905
+ }
906
+ return value;
907
+ }
908
+ optionalPositiveInteger(value, fieldName, maxValue) {
909
+ if (value === undefined || value === null) {
910
+ return undefined;
911
+ }
912
+ if (typeof value !== 'number' ||
913
+ !Number.isFinite(value) ||
914
+ !Number.isInteger(value) ||
915
+ value <= 0 ||
916
+ value > maxValue) {
917
+ throw new Error(`${fieldName} must be a positive integer no greater than ${maxValue}`);
918
+ }
919
+ return value;
920
+ }
921
+ assertDurationWithinLimit(duration) {
922
+ if (duration > this.security.maxDurationSeconds) {
923
+ throw new Error(`durationSeconds must be no greater than ${this.security.maxDurationSeconds}. ` +
924
+ 'Increase XCTRACE_ANALYZER_MAX_DURATION_SECONDS only for trusted profiling sessions.');
925
+ }
926
+ }
927
+ optionalStringArray(value, fieldName, maxItems = MAX_LAUNCH_ARGUMENTS) {
928
+ if (value === undefined || value === null) {
929
+ return undefined;
930
+ }
931
+ if (!Array.isArray(value)) {
932
+ throw new Error(`${fieldName} must be an array of strings`);
933
+ }
934
+ if (value.length > maxItems) {
935
+ throw new Error(`${fieldName} must contain ${maxItems} items or fewer`);
936
+ }
937
+ const strings = value.map((item, index) => {
938
+ if (typeof item !== 'string' || item.trim() === '') {
939
+ throw new Error(`${fieldName}[${index}] must be a non-empty string`);
940
+ }
941
+ return this.requiredString(item, `${fieldName}[${index}]`);
942
+ });
943
+ return strings.length > 0 ? strings : undefined;
944
+ }
945
+ optionalTimeRangeMs(value) {
946
+ if (value === undefined || value === null) {
947
+ return undefined;
948
+ }
949
+ if (typeof value !== 'object' || Array.isArray(value)) {
950
+ throw new Error('timeRangeMs must be an object with startMs and endMs numbers');
951
+ }
952
+ const range = value;
953
+ if (typeof range.startMs !== 'number' ||
954
+ typeof range.endMs !== 'number' ||
955
+ !Number.isFinite(range.startMs) ||
956
+ !Number.isFinite(range.endMs)) {
957
+ throw new Error('timeRangeMs.startMs and timeRangeMs.endMs must be finite numbers');
958
+ }
959
+ if (range.startMs < 0) {
960
+ throw new Error('timeRangeMs.startMs must be greater than or equal to 0');
961
+ }
962
+ if (range.endMs <= range.startMs) {
963
+ throw new Error('timeRangeMs.endMs must be greater than timeRangeMs.startMs');
964
+ }
965
+ return {
966
+ startMs: range.startMs,
967
+ endMs: range.endMs,
968
+ };
969
+ }
970
+ outputFormat(args) {
971
+ const value = this.optionalString(args?.outputFormat, 'outputFormat') ?? 'markdown';
972
+ if (value !== 'markdown' && value !== 'json' && value !== 'both') {
973
+ throw new Error('outputFormat must be markdown, json, or both');
974
+ }
975
+ return value;
976
+ }
977
+ formatToolOutput(markdown, structured, outputFormat) {
978
+ const safeMarkdown = this.sanitizeReportText(markdown);
979
+ const safeStructured = this.redactStructuredValue(structured, false);
980
+ if (outputFormat === 'markdown') {
981
+ return safeMarkdown;
982
+ }
983
+ const json = JSON.stringify(safeStructured, null, 2);
984
+ if (outputFormat === 'json') {
985
+ return json;
986
+ }
987
+ const fence = codeFenceFor(json);
988
+ return `${safeMarkdown}\n\n## Structured Result\n\n${fence}json\n${json}\n${fence}`;
989
+ }
990
+ safeDisplayValue(value) {
991
+ return this.redactStructuredValue(value, true);
992
+ }
993
+ sanitizeReportText(value) {
994
+ return redactText(value, this.security.redaction, false);
995
+ }
996
+ safeInlineText(value) {
997
+ return redactText(String(value ?? ''), this.security.redaction, true);
998
+ }
999
+ redactStructuredValue(value, collapseStrings) {
1000
+ const seen = new WeakMap();
1001
+ const visit = (item) => {
1002
+ if (typeof item === 'string') {
1003
+ return redactText(item, this.security.redaction, collapseStrings);
1004
+ }
1005
+ if (item === null || item === undefined || typeof item !== 'object') {
1006
+ return item;
1007
+ }
1008
+ if (item instanceof Date) {
1009
+ return item;
1010
+ }
1011
+ const cached = seen.get(item);
1012
+ if (cached) {
1013
+ return cached;
1014
+ }
1015
+ if (Array.isArray(item)) {
1016
+ const out = [];
1017
+ seen.set(item, out);
1018
+ out.push(...item.map(visit));
1019
+ return out;
1020
+ }
1021
+ const out = {};
1022
+ seen.set(item, out);
1023
+ for (const [key, child] of Object.entries(item)) {
1024
+ out[key] = visit(child);
1025
+ }
1026
+ return out;
1027
+ };
1028
+ return visit(value);
1029
+ }
1030
+ structuredAnalysis(analysis) {
1031
+ return {
1032
+ analysis,
1033
+ supportStatus: analysis.supportStatus ?? [],
1034
+ exportAttempts: analysis.exportAttempts ?? [],
1035
+ };
1036
+ }
1037
+ async prepareTraceForAnalysis(tracePath, dsymPath) {
1038
+ if (!dsymPath) {
1039
+ return tracePath;
1040
+ }
1041
+ const tempDir = await mkdtemp(join(tmpdir(), 'xctrace-analyzer-'));
1042
+ const outputPath = join(tempDir, `${this.safeFileName(basename(tracePath, '.trace'))}-symbolicated.trace`);
1043
+ const symbolicateTrace = this.deps.symbolicateTrace ?? defaultSymbolicateTrace;
1044
+ await symbolicateTrace({
1045
+ inputPath: tracePath,
1046
+ outputPath,
1047
+ dsymPath,
1048
+ });
1049
+ return outputPath;
1050
+ }
1051
+ recordTargetOptions(args) {
1052
+ const target = this.optionalString(args?.target, 'target');
1053
+ if (target && !['attach', 'launch', 'all-processes'].includes(target)) {
1054
+ throw new Error('target must be attach, launch, or all-processes');
1055
+ }
1056
+ const launchCommand = this.optionalString(args?.launchCommand, 'launchCommand') ??
1057
+ this.optionalString(args?.appIdentifier, 'appIdentifier');
1058
+ if (target === 'all-processes' || args?.allProcesses === true) {
1059
+ if (!this.security.allowAllProcessesProfiling) {
1060
+ throw new Error('All-process profiling is disabled by default because traces can expose data from unrelated apps. ' +
1061
+ 'Set XCTRACE_ANALYZER_ALLOW_ALL_PROCESSES=1 or configure allowAllProcessesProfiling for trusted sessions.');
1062
+ }
1063
+ return {
1064
+ fileLabel: 'all-processes',
1065
+ reportLabel: 'all processes',
1066
+ workflowWarnings: [],
1067
+ recordOptions: { allProcesses: true },
1068
+ };
1069
+ }
1070
+ if (target === 'launch' || launchCommand) {
1071
+ const command = launchCommand ?? this.requiredString(args?.launchCommand, 'launchCommand');
1072
+ if (!this.security.allowLaunchProfiling) {
1073
+ throw new Error('Launch profiling is disabled by default because it can execute local programs. ' +
1074
+ 'Set XCTRACE_ANALYZER_ALLOW_LAUNCH=1 or configure allowLaunchProfiling for trusted sessions.');
1075
+ }
1076
+ if (command.length > 1024) {
1077
+ throw new Error('launchCommand must be 1024 characters or fewer');
1078
+ }
1079
+ const targetStdin = this.optionalString(args?.targetStdin, 'targetStdin');
1080
+ const targetStdout = this.optionalString(args?.targetStdout, 'targetStdout');
1081
+ this.assertStreamPathAllowed(targetStdin, 'targetStdin');
1082
+ this.assertStreamPathAllowed(targetStdout, 'targetStdout');
1083
+ return {
1084
+ fileLabel: command,
1085
+ reportLabel: `launch: ${command}`,
1086
+ workflowWarnings: [
1087
+ `Launch target "${command}" records startup/cold-launch behavior. For general hangs or CPU bottlenecks in an already-running app, prefer attach-by-PID. Launch traces can be saved but fail TOC export on some Xcode setups.`,
1088
+ ],
1089
+ recordOptions: {
1090
+ launchCommand: command,
1091
+ launchArguments: this.optionalStringArray(args?.launchArguments, 'launchArguments', MAX_LAUNCH_ARGUMENTS),
1092
+ environment: this.optionalStringMap(args?.environment, 'environment'),
1093
+ targetStdin,
1094
+ targetStdout,
1095
+ },
1096
+ };
1097
+ }
1098
+ const processName = this.requiredString(args?.processName, 'processName');
1099
+ const workflowWarnings = /^\d+$/.test(processName)
1100
+ ? []
1101
+ : [
1102
+ `Attach target "${processName}" is a process name, not a PID. If multiple processes share this name, xctrace may fail as ambiguous; rerun with the exact PID in processName.`,
1103
+ ];
1104
+ return {
1105
+ fileLabel: processName,
1106
+ reportLabel: `attach: ${processName}`,
1107
+ workflowWarnings,
1108
+ recordOptions: { processName },
1109
+ };
1110
+ }
1111
+ optionalStringMap(value, fieldName) {
1112
+ if (value === undefined || value === null) {
1113
+ return undefined;
1114
+ }
1115
+ if (typeof value !== 'object' ||
1116
+ Array.isArray(value) ||
1117
+ Object.entries(value).some(([key, item]) => typeof key !== 'string' || typeof item !== 'string')) {
1118
+ throw new Error(`${fieldName} must be an object with string values`);
1119
+ }
1120
+ const entries = Object.entries(value);
1121
+ if (entries.length > MAX_ENVIRONMENT_VARIABLES) {
1122
+ throw new Error(`${fieldName} must contain ${MAX_ENVIRONMENT_VARIABLES} entries or fewer`);
1123
+ }
1124
+ const output = {};
1125
+ for (const [key, item] of entries) {
1126
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1127
+ throw new Error(`${fieldName} contains invalid environment variable name: ${key}`);
1128
+ }
1129
+ output[key] = this.requiredString(item, `${fieldName}.${key}`);
1130
+ }
1131
+ return output;
1132
+ }
1133
+ optionalBoolean(value, fieldName) {
1134
+ if (value === undefined || value === null) {
1135
+ return undefined;
1136
+ }
1137
+ if (typeof value !== 'boolean') {
1138
+ throw new Error(`${fieldName} must be a boolean`);
1139
+ }
1140
+ return value;
1141
+ }
1142
+ async openTraceInInstruments(tracePath, shouldOpen) {
1143
+ if (!shouldOpen || !this.deps.openTrace) {
1144
+ return undefined;
1145
+ }
1146
+ try {
1147
+ await this.deps.openTrace(tracePath);
1148
+ return { status: 'opened' };
1149
+ }
1150
+ catch (error) {
1151
+ return {
1152
+ status: 'failed',
1153
+ error: error.message,
1154
+ };
1155
+ }
1156
+ }
1157
+ async fallbackCapabilities() {
1158
+ const available = await this.deps.isXCTraceAvailable();
1159
+ if (!available) {
1160
+ return {
1161
+ available: false,
1162
+ templates: [],
1163
+ devices: [],
1164
+ instruments: [],
1165
+ exportModes: [],
1166
+ recordModes: [],
1167
+ supportsSymbolication: false,
1168
+ warnings: ['xctrace is not available.'],
1169
+ };
1170
+ }
1171
+ return {
1172
+ available: true,
1173
+ version: await this.deps.getXCTraceVersion(),
1174
+ templates: await this.deps.listTemplates(),
1175
+ devices: await this.deps.listDevices(),
1176
+ instruments: [],
1177
+ exportModes: ['toc', 'xpath', 'har'],
1178
+ recordModes: ['attach', 'launch', 'all-processes'],
1179
+ supportsSymbolication: true,
1180
+ warnings: [],
1181
+ };
1182
+ }
1183
+ async profileCapabilityWarnings(profilePreset) {
1184
+ const getCapabilities = this.deps.getXCTraceCapabilities;
1185
+ if (!getCapabilities) {
1186
+ return [];
1187
+ }
1188
+ const capabilities = await getCapabilities();
1189
+ const warnings = [...capabilities.warnings];
1190
+ if (!capabilities.available) {
1191
+ warnings.push('xctrace is not available; recording will fail until Xcode Command Line Tools are configured.');
1192
+ return warnings;
1193
+ }
1194
+ if (capabilities.templates.length > 0 &&
1195
+ !this.includesCaseInsensitive(capabilities.templates, profilePreset.template)) {
1196
+ warnings.push(`Template "${profilePreset.template}" was not listed by xctrace; recording will still be attempted.`);
1197
+ }
1198
+ if (capabilities.instruments.length > 0) {
1199
+ for (const instrument of profilePreset.instruments) {
1200
+ if (!this.includesCaseInsensitive(capabilities.instruments, instrument)) {
1201
+ warnings.push(`Instrument "${instrument}" was not listed by xctrace; recording will still be attempted.`);
1202
+ }
1203
+ }
1204
+ }
1205
+ return warnings;
1206
+ }
1207
+ includesCaseInsensitive(values, expected) {
1208
+ const lower = expected.toLowerCase();
1209
+ return values.some((value) => value.toLowerCase() === lower);
1210
+ }
1211
+ async discoverTraceBundles(directory, recursive) {
1212
+ let entries;
1213
+ try {
1214
+ entries = await readdir(directory, { withFileTypes: true });
1215
+ }
1216
+ catch (error) {
1217
+ if (error.code === 'ENOENT') {
1218
+ return [];
1219
+ }
1220
+ throw error;
1221
+ }
1222
+ const tracePaths = [];
1223
+ for (const entry of entries) {
1224
+ const fullPath = join(directory, entry.name);
1225
+ if (entry.name.endsWith('.trace')) {
1226
+ tracePaths.push(fullPath);
1227
+ continue;
1228
+ }
1229
+ if (recursive && entry.isDirectory()) {
1230
+ tracePaths.push(...(await this.discoverTraceBundles(fullPath, recursive)));
1231
+ }
1232
+ }
1233
+ return tracePaths;
1234
+ }
1235
+ async cleanupTraceCandidate(tracePath, dryRun, olderThanMinutes) {
1236
+ if (!tracePath.endsWith('.trace')) {
1237
+ return {
1238
+ path: tracePath,
1239
+ status: 'skipped',
1240
+ reason: 'not a .trace bundle',
1241
+ };
1242
+ }
1243
+ let stats;
1244
+ try {
1245
+ stats = await lstat(tracePath);
1246
+ }
1247
+ catch (error) {
1248
+ if (error.code === 'ENOENT') {
1249
+ return {
1250
+ path: tracePath,
1251
+ status: 'skipped',
1252
+ reason: 'path does not exist',
1253
+ };
1254
+ }
1255
+ return {
1256
+ path: tracePath,
1257
+ status: 'failed',
1258
+ reason: error.message,
1259
+ };
1260
+ }
1261
+ if (stats.isSymbolicLink()) {
1262
+ return {
1263
+ path: tracePath,
1264
+ status: 'skipped',
1265
+ reason: 'symbolic links are not deleted',
1266
+ };
1267
+ }
1268
+ const ageMinutes = Math.max(0, (Date.now() - stats.mtimeMs) / 60_000);
1269
+ if (olderThanMinutes !== undefined && ageMinutes < olderThanMinutes) {
1270
+ return {
1271
+ path: tracePath,
1272
+ status: 'skipped',
1273
+ modifiedAt: stats.mtime.toISOString(),
1274
+ ageMinutes,
1275
+ reason: `newer than ${olderThanMinutes} minutes`,
1276
+ };
1277
+ }
1278
+ const sizeBytes = await this.tracePathSizeBytes(tracePath);
1279
+ const baseEntry = {
1280
+ path: tracePath,
1281
+ sizeBytes,
1282
+ modifiedAt: stats.mtime.toISOString(),
1283
+ ageMinutes,
1284
+ };
1285
+ if (dryRun) {
1286
+ return {
1287
+ ...baseEntry,
1288
+ status: 'would_delete',
1289
+ };
1290
+ }
1291
+ if (!this.canDeleteTracePath(tracePath)) {
1292
+ return {
1293
+ ...baseEntry,
1294
+ status: 'failed',
1295
+ reason: 'destructive cleanup outside the configured trace root is disabled',
1296
+ };
1297
+ }
1298
+ try {
1299
+ await rm(tracePath, { recursive: true });
1300
+ return {
1301
+ ...baseEntry,
1302
+ status: 'deleted',
1303
+ };
1304
+ }
1305
+ catch (error) {
1306
+ return {
1307
+ ...baseEntry,
1308
+ status: 'failed',
1309
+ reason: error.message,
1310
+ };
1311
+ }
1312
+ }
1313
+ async tracePathSizeBytes(tracePath) {
1314
+ const stats = await lstat(tracePath);
1315
+ if (stats.isSymbolicLink()) {
1316
+ return 0;
1317
+ }
1318
+ if (!stats.isDirectory()) {
1319
+ return stats.size;
1320
+ }
1321
+ let total = stats.size;
1322
+ const entries = await readdir(tracePath, { withFileTypes: true });
1323
+ for (const entry of entries) {
1324
+ total += await this.tracePathSizeBytes(join(tracePath, entry.name));
1325
+ }
1326
+ return total;
1327
+ }
1328
+ rememberRecordedTrace(tracePath) {
1329
+ this.recordedTracePaths.add(resolve(tracePath));
1330
+ }
1331
+ traceOutputPath(args, processName, template) {
1332
+ if (args?.outputPath) {
1333
+ const outputPath = resolve(this.requiredString(args.outputPath, 'outputPath'));
1334
+ this.assertTraceOutputPathAllowed(outputPath, 'outputPath');
1335
+ return outputPath;
1336
+ }
1337
+ const outputDirectory = resolve(this.optionalString(args?.outputDirectory, 'outputDirectory') ?? this.security.traceRoot);
1338
+ this.assertTraceDirectoryAllowed(outputDirectory, 'outputDirectory');
1339
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1340
+ const fileName = [
1341
+ this.safeFileName(processName),
1342
+ this.safeFileName(template),
1343
+ timestamp,
1344
+ ].join('-');
1345
+ return join(outputDirectory, `${fileName}.trace`);
1346
+ }
1347
+ assertTraceOutputPathAllowed(tracePath, fieldName) {
1348
+ if (!tracePath.endsWith('.trace')) {
1349
+ throw new Error(`${fieldName} must end in .trace`);
1350
+ }
1351
+ const directory = dirname(tracePath);
1352
+ this.assertTraceDirectoryAllowed(directory, fieldName);
1353
+ }
1354
+ assertTraceDirectoryAllowed(directory, fieldName) {
1355
+ if (this.security.allowExternalTraceOutput || this.isWithinTraceRoot(directory)) {
1356
+ return;
1357
+ }
1358
+ throw new Error(`${fieldName} must be inside the configured trace root (${this.security.traceRoot}) ` +
1359
+ 'unless external trace output is explicitly enabled.');
1360
+ }
1361
+ assertStreamPathAllowed(pathValue, fieldName) {
1362
+ if (!pathValue || pathValue === '-') {
1363
+ return;
1364
+ }
1365
+ const resolvedPath = resolve(pathValue);
1366
+ if (this.security.allowExternalTraceOutput || this.isWithinTraceRoot(resolvedPath)) {
1367
+ return;
1368
+ }
1369
+ throw new Error(`${fieldName} must be "-" or inside the configured trace root (${this.security.traceRoot}) ` +
1370
+ 'unless external trace output is explicitly enabled.');
1371
+ }
1372
+ assertCleanupDirectoryAllowed(directory) {
1373
+ if (this.security.allowExternalTraceCleanup || this.isWithinTraceRoot(directory)) {
1374
+ return;
1375
+ }
1376
+ throw new Error(`Refusing destructive cleanup outside the configured trace root: ${directory}. ` +
1377
+ 'Set XCTRACE_ANALYZER_ALLOW_EXTERNAL_CLEANUP=1 only for trusted sessions.');
1378
+ }
1379
+ canDeleteTracePath(tracePath) {
1380
+ const resolvedPath = resolve(tracePath);
1381
+ return this.security.allowExternalTraceCleanup ||
1382
+ this.isWithinTraceRoot(resolvedPath) ||
1383
+ this.recordedTracePaths.has(resolvedPath);
1384
+ }
1385
+ isWithinTraceRoot(pathValue) {
1386
+ return isPathInside(resolve(pathValue), this.security.traceRoot);
1387
+ }
1388
+ profilePresetForName(preset) {
1389
+ const profilePreset = PROFILE_PRESETS[preset];
1390
+ if (!profilePreset) {
1391
+ throw new Error(`Unknown profiling preset: ${preset}. Use one of: ${Object.keys(PROFILE_PRESETS).join(', ')}`);
1392
+ }
1393
+ return profilePreset;
1394
+ }
1395
+ profileTraceOutputPath(outputDirectory, processName, template, timestamp) {
1396
+ return join(outputDirectory, `${this.safeFileName(processName)}-${this.safeFileName(template)}-${timestamp}.trace`);
1397
+ }
1398
+ safeFileName(value) {
1399
+ return value
1400
+ .trim()
1401
+ .replace(/[^a-z0-9._-]+/gi, '-')
1402
+ .replace(/^-+|-+$/g, '') || 'trace';
1403
+ }
1404
+ formatTrackingHeader(recording) {
1405
+ recording = this.safeDisplayValue(recording);
1406
+ const lines = [
1407
+ '# Running App Trace Report',
1408
+ '',
1409
+ `- Target: ${recording.target}`,
1410
+ `- Template: ${recording.template}`,
1411
+ `- Duration: ${recording.duration}s`,
1412
+ ];
1413
+ if (recording.device) {
1414
+ lines.push(`- Device: ${recording.device}`);
1415
+ }
1416
+ lines.push(`- Trace: ${recording.outputPath}`);
1417
+ const instrumentsLine = this.formatInstrumentsOpenLine(recording.instrumentsOpen);
1418
+ if (instrumentsLine) {
1419
+ lines.push(instrumentsLine);
1420
+ }
1421
+ lines.push('- Cleanup: trace retained; use cleanup_traces when it is no longer needed');
1422
+ lines.push('');
1423
+ if (recording.workflowWarnings.length > 0) {
1424
+ lines.push('## Workflow Warnings');
1425
+ lines.push(...recording.workflowWarnings.map((warning) => `- ${warning}`));
1426
+ lines.push('');
1427
+ }
1428
+ return lines;
1429
+ }
1430
+ formatInstrumentsOpenLine(result) {
1431
+ if (!result) {
1432
+ return undefined;
1433
+ }
1434
+ if (result.status === 'opened') {
1435
+ return '- Instruments.app: opened';
1436
+ }
1437
+ return `- Instruments.app: failed to open - ${result.error ?? 'unknown error'}`;
1438
+ }
1439
+ formatProfileReport(profile) {
1440
+ profile = this.safeDisplayValue(profile);
1441
+ const lines = [];
1442
+ const failedResults = profile.results.filter((result) => result.error);
1443
+ const analyzedResults = profile.results.filter((result) => result.analysis);
1444
+ lines.push('# Profiling Report');
1445
+ lines.push('');
1446
+ lines.push(`- Target: ${profile.target}`);
1447
+ lines.push(`- Preset: ${profile.preset}`);
1448
+ lines.push('- Recording strategy: combined');
1449
+ lines.push(`- Duration: ${profile.duration}s`);
1450
+ lines.push(`- Base template: ${profile.baseTemplate}`);
1451
+ lines.push(`- Instruments: ${profile.instruments.length > 0 ? profile.instruments.join(', ') : 'none'}`);
1452
+ if (profile.device) {
1453
+ lines.push(`- Device: ${profile.device}`);
1454
+ }
1455
+ if (profile.capabilityWarnings.length > 0) {
1456
+ lines.push('- Capability validation: warnings');
1457
+ }
1458
+ if (profile.workflowWarnings.length > 0) {
1459
+ lines.push('- Workflow validation: warnings');
1460
+ }
1461
+ lines.push('');
1462
+ lines.push('## Summary');
1463
+ lines.push(`- Overall status: ${this.profileStatus(profile.results)}`);
1464
+ lines.push(`- Traces recorded: ${profile.results.length - failedResults.length}/${profile.results.length}`);
1465
+ lines.push(`- Traces analyzed: ${profile.analyze ? analyzedResults.length : 0}/${profile.results.length}`);
1466
+ if (failedResults.length > 0) {
1467
+ lines.push(`- Recording failures: ${failedResults.length}`);
1468
+ }
1469
+ lines.push('');
1470
+ if (profile.capabilityWarnings.length > 0) {
1471
+ lines.push('## Capability Warnings');
1472
+ lines.push(...profile.capabilityWarnings.map((warning) => `- ${warning}`));
1473
+ lines.push('');
1474
+ }
1475
+ if (profile.workflowWarnings.length > 0) {
1476
+ lines.push('## Workflow Warnings');
1477
+ lines.push(...profile.workflowWarnings.map((warning) => `- ${warning}`));
1478
+ lines.push('');
1479
+ }
1480
+ if (profile.results.some((result) => result.error && this.isTraceTocExportFailure(result.error))) {
1481
+ lines.push('## Next Steps');
1482
+ lines.push(...this.traceExportFailureNextSteps().map((step) => `- ${step}`));
1483
+ lines.push('');
1484
+ }
1485
+ lines.push('## Trace Files');
1486
+ for (const result of profile.results) {
1487
+ const openSuffix = result.instrumentsOpen?.status === 'opened'
1488
+ ? ' (opened in Instruments.app)'
1489
+ : '';
1490
+ lines.push(`- ${result.template}: ${result.tracePath}${openSuffix}`);
1491
+ }
1492
+ if (profile.results.some((result) => !result.error)) {
1493
+ lines.push('');
1494
+ lines.push('_Trace files are retained for Instruments.app inspection. Use cleanup_traces when they are no longer needed._');
1495
+ }
1496
+ lines.push('');
1497
+ for (const result of profile.results) {
1498
+ lines.push(`## ${this.profileSectionTitle(result.template)}`);
1499
+ lines.push(`- Template: ${result.template}`);
1500
+ lines.push(`- Trace: ${result.tracePath}`);
1501
+ const instrumentsLine = this.formatInstrumentsOpenLine(result.instrumentsOpen);
1502
+ if (instrumentsLine) {
1503
+ lines.push(instrumentsLine);
1504
+ }
1505
+ if (profile.instruments.length > 0) {
1506
+ lines.push(`- Instruments: ${profile.instruments.join(', ')}`);
1507
+ }
1508
+ if (result.error) {
1509
+ lines.push(`- Error: ${result.error}`);
1510
+ lines.push('');
1511
+ continue;
1512
+ }
1513
+ if (!result.analysis) {
1514
+ lines.push('Analysis skipped.');
1515
+ lines.push('');
1516
+ continue;
1517
+ }
1518
+ lines.push('');
1519
+ lines.push(result.analysis.summary);
1520
+ lines.push('');
1521
+ this.appendAnalysisWindow(lines, result.analysis);
1522
+ this.appendTimeProfilerStatus(lines, result.analysis);
1523
+ this.appendSupportStatus(lines, result.analysis);
1524
+ this.appendExportDiagnostics(lines, result.analysis);
1525
+ this.appendCpuHighlights(lines, result.analysis);
1526
+ this.appendUserFrameSection(lines, result.analysis);
1527
+ lines.push('');
1528
+ this.appendHangsSection(lines, result.analysis);
1529
+ this.appendInstrumentSections(lines, result.analysis);
1530
+ }
1531
+ lines.push('## Prioritized Recommendations');
1532
+ const recommendations = this.profileRecommendations(profile.results);
1533
+ if (recommendations.length === 0) {
1534
+ lines.push('- No high-priority recommendations found in the exported trace data.');
1535
+ }
1536
+ else {
1537
+ for (const recommendation of recommendations.slice(0, 10)) {
1538
+ lines.push(`- ${recommendation}`);
1539
+ }
1540
+ }
1541
+ return lines.join('\n');
1542
+ }
1543
+ profileStatus(results) {
1544
+ if (results.every((result) => result.error)) {
1545
+ return 'recording failed';
1546
+ }
1547
+ const severities = results
1548
+ .flatMap((result) => result.analysis?.instrumentAnalyses ?? [])
1549
+ .flatMap((instrument) => instrument.findings.map((finding) => finding.severity));
1550
+ const hasCriticalBottleneck = results.some((result) => result.analysis?.bottlenecks.some((bottleneck) => bottleneck.impact === 'critical'));
1551
+ const hasSevereHang = results.some((result) => (result.analysis?.hangs?.severeCount ?? 0) > 0);
1552
+ const hasHang = results.some((result) => (result.analysis?.hangs?.events.length ?? 0) > 0);
1553
+ if (severities.includes('critical') || hasCriticalBottleneck || hasSevereHang) {
1554
+ return 'critical issues found';
1555
+ }
1556
+ if (severities.includes('high') || severities.includes('medium') || hasHang) {
1557
+ return 'warnings found';
1558
+ }
1559
+ if (results.some((result) => result.error)) {
1560
+ return 'partial report with recording errors';
1561
+ }
1562
+ return 'no critical issues found';
1563
+ }
1564
+ profileSectionTitle(template) {
1565
+ switch (template) {
1566
+ case 'Time Profiler':
1567
+ return 'CPU / Time Profiler';
1568
+ case 'Leaks':
1569
+ return 'Leaks';
1570
+ case 'Allocations':
1571
+ return 'Allocations';
1572
+ case 'Network':
1573
+ return 'Network';
1574
+ case 'Power Profiler':
1575
+ return 'Energy / Power';
1576
+ default:
1577
+ return template;
1578
+ }
1579
+ }
1580
+ formatTraceCleanupOutput(result) {
1581
+ result = this.safeDisplayValue(result);
1582
+ const lines = [
1583
+ '# Trace Cleanup Report',
1584
+ '',
1585
+ `- Mode: ${result.dryRun ? 'preview' : 'delete'}`,
1586
+ `- Scope: ${result.scope}`,
1587
+ `- Traces matched: ${result.matchedCount}`,
1588
+ `- Deleted: ${result.deletedCount}`,
1589
+ `- Skipped: ${result.skippedCount}`,
1590
+ `- Failed: ${result.failedCount}`,
1591
+ result.dryRun
1592
+ ? `- Reclaimable space: ${this.formatBytes(result.reclaimableBytes)}`
1593
+ : `- Reclaimed space: ${this.formatBytes(result.reclaimedBytes)}`,
1594
+ '',
1595
+ '## Traces',
1596
+ ];
1597
+ if (result.entries.length === 0) {
1598
+ lines.push('- No .trace bundles matched.');
1599
+ }
1600
+ else {
1601
+ for (const entry of result.entries) {
1602
+ lines.push(this.formatTraceCleanupEntry(entry));
1603
+ }
1604
+ }
1605
+ lines.push('');
1606
+ if (result.dryRun) {
1607
+ lines.push('No files were deleted. Re-run with dryRun: false after the user confirms the traces are no longer needed.');
1608
+ }
1609
+ else if (result.deletedCount > 0) {
1610
+ lines.push('Deleted trace bundles cannot be opened in Instruments.app unless they are restored from backup.');
1611
+ }
1612
+ return lines.join('\n');
1613
+ }
1614
+ formatTraceCleanupEntry(entry) {
1615
+ const details = [
1616
+ entry.sizeBytes !== undefined ? this.formatBytes(entry.sizeBytes) : undefined,
1617
+ entry.ageMinutes !== undefined ? `${entry.ageMinutes.toFixed(1)} min old` : undefined,
1618
+ entry.reason,
1619
+ ].filter(Boolean);
1620
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
1621
+ return `- ${entry.status}: ${entry.path}${suffix}`;
1622
+ }
1623
+ formatBytes(bytes) {
1624
+ const units = ['B', 'KB', 'MB', 'GB'];
1625
+ let value = bytes;
1626
+ let unitIndex = 0;
1627
+ while (value >= 1024 && unitIndex < units.length - 1) {
1628
+ value /= 1024;
1629
+ unitIndex += 1;
1630
+ }
1631
+ return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
1632
+ }
1633
+ instrumentSectionTitle(kind) {
1634
+ switch (kind) {
1635
+ case 'memory':
1636
+ return 'Memory';
1637
+ case 'leaks':
1638
+ return 'Leaks';
1639
+ case 'allocations':
1640
+ return 'Allocations';
1641
+ case 'network':
1642
+ return 'Network';
1643
+ case 'energy':
1644
+ return 'Energy / Power';
1645
+ default:
1646
+ return kind;
1647
+ }
1648
+ }
1649
+ appendCpuHighlights(lines, analysis) {
1650
+ if (analysis.bottlenecks.length > 0) {
1651
+ lines.push('**Top Bottlenecks:**');
1652
+ for (const bottleneck of analysis.bottlenecks.slice(0, 5)) {
1653
+ lines.push(`- ${bottleneck.function}: ${bottleneck.duration.toFixed(0)}ms, ${bottleneck.impact} impact`);
1654
+ }
1655
+ lines.push('');
1656
+ }
1657
+ }
1658
+ appendTimeProfilerStatus(lines, analysis) {
1659
+ if (!analysis.stats.timeProfileError) {
1660
+ return;
1661
+ }
1662
+ lines.push(`**Time Profiler:** failed to parse - ${analysis.stats.timeProfileError}. The trace itself was recorded; this is an analyzer error.`);
1663
+ lines.push('');
1664
+ }
1665
+ appendUserFrameSection(lines, analysis) {
1666
+ const frames = analysis.userFrameProfiles ?? [];
1667
+ if (frames.length === 0) {
1668
+ return;
1669
+ }
1670
+ lines.push('## Top User-Code Frames');
1671
+ for (const frame of frames.slice(0, 10)) {
1672
+ const module = frame.module ? `${frame.module}\`` : '';
1673
+ lines.push(`- ${module}${frame.name}: ${frame.selfTime.toFixed(0)}ms (${frame.percentage.toFixed(1)}%, ${frame.sampleCount} sample${frame.sampleCount === 1 ? '' : 's'})`);
1674
+ }
1675
+ lines.push('');
1676
+ }
1677
+ appendAnalysisWindow(lines, analysis) {
1678
+ const range = analysis.stats.timeRangeMs;
1679
+ if (!range) {
1680
+ return;
1681
+ }
1682
+ lines.push(`**Analysis window:** ${formatHangStartTime(range.startMs)}-${formatHangStartTime(range.endMs)} (${formatHangDuration(range.endMs - range.startMs)})`);
1683
+ lines.push('');
1684
+ }
1685
+ appendHangsSection(lines, analysis) {
1686
+ const hangs = analysis.hangs;
1687
+ if (!hangs || hangs.events.length === 0) {
1688
+ if (!this.hasHangExportSignal(analysis)) {
1689
+ return;
1690
+ }
1691
+ lines.push('## Hangs');
1692
+ lines.push('No exported hang events were found in this trace window.');
1693
+ lines.push('This does not rule out startup or interaction hangs outside the captured window.');
1694
+ const sources = this.hangSourceSchemas(analysis);
1695
+ if (sources.length > 0) {
1696
+ lines.push('');
1697
+ lines.push(`_Source: ${sources.join(', ')}_`);
1698
+ }
1699
+ lines.push('');
1700
+ return;
1701
+ }
1702
+ const total = hangs.events.length;
1703
+ const totalSec = (hangs.totalHangMs / 1000).toFixed(2);
1704
+ const longestSec = (hangs.longestMs / 1000).toFixed(2);
1705
+ lines.push('## Hangs');
1706
+ lines.push(`${total} hang${total > 1 ? 's' : ''} detected (${hangs.severeCount} severe, ${hangs.hangCount} standard, ${hangs.microhangCount} micro). ` +
1707
+ `Total stalled main-thread time: ${totalSec}s. Longest: ${longestSec}s.`);
1708
+ lines.push('');
1709
+ const sortedByDuration = [...hangs.events]
1710
+ .sort((a, b) => b.durationMs - a.durationMs)
1711
+ .slice(0, 10);
1712
+ for (const event of sortedByDuration) {
1713
+ lines.push(`- ${formatHangStartTime(event.startMs)} — ${event.hangType} — ${formatHangDuration(event.durationMs)}` +
1714
+ (event.threadName ? ` — ${event.threadName}` : '') +
1715
+ (event.processName && !event.threadName?.includes(event.processName)
1716
+ ? ` (${event.processName})`
1717
+ : ''));
1718
+ }
1719
+ lines.push('');
1720
+ if (hangs.sourceSchemas.length > 0) {
1721
+ lines.push(`_Source: ${hangs.sourceSchemas.join(', ')}_`);
1722
+ lines.push('');
1723
+ }
1724
+ }
1725
+ hasHangExportSignal(analysis) {
1726
+ return !!analysis.hangs || (analysis.exportAttempts?.some((attempt) => attempt.kind === 'hangs') ?? false);
1727
+ }
1728
+ hangSourceSchemas(analysis) {
1729
+ const schemas = new Set();
1730
+ for (const schema of analysis.hangs?.sourceSchemas ?? []) {
1731
+ schemas.add(schema);
1732
+ }
1733
+ for (const attempt of analysis.exportAttempts ?? []) {
1734
+ if (attempt.kind === 'hangs' && attempt.schema) {
1735
+ schemas.add(attempt.schema);
1736
+ }
1737
+ }
1738
+ return Array.from(schemas);
1739
+ }
1740
+ appendInstrumentSections(lines, analysis) {
1741
+ for (const instrument of analysis.instrumentAnalyses) {
1742
+ lines.push(`## ${this.instrumentSectionTitle(instrument.kind)}`);
1743
+ lines.push(instrument.summary);
1744
+ lines.push('');
1745
+ if (instrument.metrics.length > 0) {
1746
+ lines.push('**Metrics:**');
1747
+ for (const metric of instrument.metrics) {
1748
+ lines.push(`- ${metric.name}: ${metric.value}`);
1749
+ }
1750
+ lines.push('');
1751
+ }
1752
+ if (instrument.findings.length > 0) {
1753
+ lines.push('**Findings:**');
1754
+ for (const finding of instrument.findings) {
1755
+ lines.push(`- ${finding.title}: ${finding.description}`);
1756
+ }
1757
+ lines.push('');
1758
+ }
1759
+ }
1760
+ }
1761
+ appendSupportStatus(lines, analysis) {
1762
+ if (!analysis.supportStatus || analysis.supportStatus.length === 0) {
1763
+ return;
1764
+ }
1765
+ lines.push('## Support Matrix');
1766
+ for (const status of analysis.supportStatus) {
1767
+ lines.push(`- ${this.instrumentSectionTitle(status.kind)}: ${this.supportStatusLabel(status.status)} - ${status.reason}`);
1768
+ }
1769
+ lines.push('');
1770
+ }
1771
+ supportStatusLabel(status) {
1772
+ return status === 'unsupported' ? 'not present in trace' : status;
1773
+ }
1774
+ appendExportDiagnostics(lines, analysis) {
1775
+ if (!analysis.exportAttempts || analysis.exportAttempts.length === 0) {
1776
+ return;
1777
+ }
1778
+ const nonSuccess = analysis.exportAttempts.filter((attempt) => attempt.status !== 'success');
1779
+ if (nonSuccess.length === 0) {
1780
+ return;
1781
+ }
1782
+ lines.push('## Export Diagnostics');
1783
+ for (const attempt of nonSuccess.slice(0, 10)) {
1784
+ const label = attempt.schema ?? attempt.kind;
1785
+ const message = attempt.message ? ` - ${attempt.message}` : '';
1786
+ lines.push(`- ${label}: ${attempt.status}${message}`);
1787
+ }
1788
+ lines.push('');
1789
+ }
1790
+ appendTraceExportFailureNextSteps(lines, analysis) {
1791
+ const tocFailure = analysis.exportAttempts?.some((attempt) => attempt.kind === 'toc' &&
1792
+ attempt.status === 'failed' &&
1793
+ this.isTraceTocExportFailure(attempt.message ?? ''));
1794
+ if (!tocFailure) {
1795
+ return;
1796
+ }
1797
+ lines.push('## Next Steps');
1798
+ lines.push(...this.traceExportFailureNextSteps().map((step) => `- ${step}`));
1799
+ lines.push('');
1800
+ }
1801
+ isTraceTocExportFailure(message) {
1802
+ return /could not export (its )?TOC|could not export the trace TOC|failed to export TOC|Document Missing Template Error/i.test(message);
1803
+ }
1804
+ traceExportFailureNextSteps() {
1805
+ return [
1806
+ 'Treat this trace as saved but not exportable; do not interpret missing Hangs or "no issues" as a valid result.',
1807
+ 'Do not retry the same launch target with more launch templates unless startup-only exportability is being tested; the trace container is failing before table parsing.',
1808
+ 'For an already-running app, retry with profile_running_app using the exact PID in processName.',
1809
+ 'If the environment cannot list processes, ask the user for the PID or have them close duplicate app instances before attaching.',
1810
+ 'For startup hangs on macOS, inspect Performance Diagnostics logs around the launch window for hang-risk warnings, for example: log show --last 30m --style compact --predicate \'process == "AppName" && eventMessage CONTAINS[c] "hang"\'.',
1811
+ 'Use outputFormat: "both" so supportStatus and exportAttempts remain visible.',
1812
+ ];
1813
+ }
1814
+ profileRecommendations(results) {
1815
+ const recommendations = new Set();
1816
+ for (const result of results) {
1817
+ for (const bottleneck of result.analysis?.bottlenecks ?? []) {
1818
+ recommendations.add(`${bottleneck.impact} CPU issue in ${bottleneck.function}: ${bottleneck.suggestion}`);
1819
+ }
1820
+ for (const instrument of result.analysis?.instrumentAnalyses ?? []) {
1821
+ for (const finding of instrument.findings) {
1822
+ recommendations.add(`${finding.severity} ${instrument.title}: ${finding.title} - ${finding.description}`);
1823
+ }
1824
+ }
1825
+ for (const recommendation of result.analysis?.recommendations ?? []) {
1826
+ recommendations.add(`${recommendation.priority} ${recommendation.title}: ${recommendation.description}`);
1827
+ }
1828
+ const hangs = result.analysis?.hangs;
1829
+ if (hangs && hangs.events.length > 0) {
1830
+ recommendations.add(`${hangs.severeCount > 0 ? 'critical' : 'medium'} Main-thread hangs: ` +
1831
+ `${hangs.events.length} hang${hangs.events.length > 1 ? 's' : ''} detected ` +
1832
+ `(${hangs.severeCount} severe); inspect the Hangs section and scoped Top User-Code Frames for main-thread blocking work.`);
1833
+ }
1834
+ if (result.error) {
1835
+ recommendations.add(`Recording failed for ${result.template}: ${result.error}`);
1836
+ }
1837
+ }
1838
+ return Array.from(recommendations);
1839
+ }
1840
+ /**
1841
+ * Format analysis output for human readability
1842
+ */
1843
+ formatAnalysisOutput(analysis) {
1844
+ analysis = this.safeDisplayValue(analysis);
1845
+ const lines = [];
1846
+ lines.push('# Performance Analysis Report');
1847
+ lines.push('');
1848
+ lines.push(`**File:** ${analysis.metadata.fileName}`);
1849
+ lines.push(`**Duration:** ${(analysis.stats.totalTime / 1000).toFixed(2)}s`);
1850
+ lines.push(`**Template:** ${analysis.metadata.template}`);
1851
+ if (analysis.stats.timeRangeMs) {
1852
+ const range = analysis.stats.timeRangeMs;
1853
+ lines.push(`**Analysis window:** ${formatHangStartTime(range.startMs)}-${formatHangStartTime(range.endMs)} (${formatHangDuration(range.endMs - range.startMs)})`);
1854
+ }
1855
+ lines.push('');
1856
+ // Summary
1857
+ lines.push('## Summary');
1858
+ lines.push(analysis.summary);
1859
+ lines.push('');
1860
+ this.appendSupportStatus(lines, analysis);
1861
+ this.appendExportDiagnostics(lines, analysis);
1862
+ this.appendTraceExportFailureNextSteps(lines, analysis);
1863
+ // Statistics
1864
+ lines.push('## Performance Statistics');
1865
+ if (analysis.stats.timeProfileError) {
1866
+ lines.push(`- Time Profiler: failed to parse - ${analysis.stats.timeProfileError}. The trace itself was recorded; this is an analyzer error.`);
1867
+ }
1868
+ else {
1869
+ lines.push(`- Total execution time: ${(analysis.stats.totalTime / 1000).toFixed(2)}s`);
1870
+ lines.push(`- Slow functions: ${analysis.stats.slowFunctions}`);
1871
+ lines.push(`- Average function time: ${analysis.stats.avgFunctionTime.toFixed(2)}ms`);
1872
+ lines.push(`- Max function time: ${analysis.stats.maxFunctionTime.toFixed(2)}ms`);
1873
+ lines.push(`- Threads used: ${analysis.stats.threadCount}`);
1874
+ }
1875
+ lines.push('');
1876
+ // Bottlenecks
1877
+ if (analysis.bottlenecks.length > 0) {
1878
+ lines.push('## Performance Bottlenecks');
1879
+ lines.push('');
1880
+ for (let i = 0; i < Math.min(5, analysis.bottlenecks.length); i++) {
1881
+ const b = analysis.bottlenecks[i];
1882
+ const icon = b.impact === 'critical' ? '🔴' : b.impact === 'high' ? '🟠' : '🟡';
1883
+ lines.push(`### ${icon} ${i + 1}. ${b.function}`);
1884
+ lines.push(`- **Impact:** ${b.impact}`);
1885
+ lines.push(`- **Duration:** ${b.duration.toFixed(0)}ms (${b.percentage.toFixed(1)}% of total)`);
1886
+ lines.push(`- **Call count:** ${b.callCount}`);
1887
+ lines.push(`- **Suggestion:** ${b.suggestion}`);
1888
+ lines.push('');
1889
+ }
1890
+ }
1891
+ this.appendUserFrameSection(lines, analysis);
1892
+ this.appendHangsSection(lines, analysis);
1893
+ if (analysis.instrumentAnalyses.length > 0) {
1894
+ lines.push('## Additional Instrument Analysis');
1895
+ lines.push('');
1896
+ for (const instrument of analysis.instrumentAnalyses) {
1897
+ lines.push(`### ${instrument.title}`);
1898
+ lines.push(instrument.summary);
1899
+ lines.push('');
1900
+ if (instrument.metrics.length > 0) {
1901
+ lines.push('**Metrics:**');
1902
+ for (const metric of instrument.metrics) {
1903
+ lines.push(`- ${metric.name}: ${metric.value}`);
1904
+ }
1905
+ lines.push('');
1906
+ }
1907
+ if (instrument.findings.length > 0) {
1908
+ lines.push('**Findings:**');
1909
+ for (const finding of instrument.findings) {
1910
+ lines.push(`- **${finding.title}:** ${finding.description}`);
1911
+ }
1912
+ lines.push('');
1913
+ }
1914
+ if (instrument.sourceSchemas.length > 0) {
1915
+ lines.push(`_Source: ${instrument.sourceSchemas.join(', ')}_`);
1916
+ lines.push('');
1917
+ }
1918
+ }
1919
+ }
1920
+ // Recommendations
1921
+ if (analysis.recommendations.length > 0) {
1922
+ lines.push('## Optimization Recommendations');
1923
+ lines.push('');
1924
+ for (let i = 0; i < Math.min(3, analysis.recommendations.length); i++) {
1925
+ const r = analysis.recommendations[i];
1926
+ const icon = r.priority === 'high' ? '⚠️' : r.priority === 'medium' ? 'ℹ️' : '💡';
1927
+ lines.push(`### ${icon} ${r.title}`);
1928
+ lines.push(`**Priority:** ${r.priority} | **Type:** ${r.type}`);
1929
+ lines.push('');
1930
+ lines.push(r.description);
1931
+ lines.push('');
1932
+ lines.push(`**Potential improvement:** ${r.potentialImprovement}`);
1933
+ if (r.codeExample) {
1934
+ lines.push('');
1935
+ lines.push('**Example:**');
1936
+ lines.push('```swift');
1937
+ lines.push(r.codeExample);
1938
+ lines.push('```');
1939
+ }
1940
+ lines.push('');
1941
+ }
1942
+ }
1943
+ return lines.join('\n');
1944
+ }
1945
+ /**
1946
+ * Format comparison output
1947
+ */
1948
+ formatComparisonOutput(comparison) {
1949
+ comparison = this.safeDisplayValue(comparison);
1950
+ const lines = [];
1951
+ lines.push('# Trace Comparison Report');
1952
+ lines.push('');
1953
+ lines.push(`**Baseline:** ${comparison.baseline.metadata.fileName}`);
1954
+ lines.push(`**Current:** ${comparison.current.metadata.fileName}`);
1955
+ lines.push('');
1956
+ // Summary
1957
+ lines.push('## Summary');
1958
+ lines.push(comparison.summary);
1959
+ lines.push('');
1960
+ // Performance Delta
1961
+ lines.push('## Performance Delta');
1962
+ const deltaMs = comparison.delta.totalTimeChange;
1963
+ const deltaPercent = comparison.delta.totalTimeChangePercent;
1964
+ const icon = deltaPercent > 5 ? '⚠️' : deltaPercent < -5 ? '✅' : '✓';
1965
+ lines.push(`${icon} Total time change: ${deltaMs > 0 ? '+' : ''}${(deltaMs / 1000).toFixed(2)}s (${deltaPercent > 0 ? '+' : ''}${deltaPercent.toFixed(1)}%)`);
1966
+ lines.push(`- Regressions: ${comparison.delta.functionChanges.regressions}`);
1967
+ lines.push(`- Improvements: ${comparison.delta.functionChanges.improvements}`);
1968
+ lines.push(`- Unchanged: ${comparison.delta.functionChanges.unchanged}`);
1969
+ lines.push('');
1970
+ // Regressions
1971
+ if (comparison.regressions.length > 0) {
1972
+ lines.push('## Regressions');
1973
+ lines.push('');
1974
+ for (let i = 0; i < Math.min(5, comparison.regressions.length); i++) {
1975
+ const r = comparison.regressions[i];
1976
+ const icon = r.severity === 'critical' ? '🔴' : r.severity === 'major' ? '🟠' : '🟡';
1977
+ lines.push(`${icon} **${r.function}** (${r.severity})`);
1978
+ lines.push(` ${r.baselineTime.toFixed(0)}ms → ${r.currentTime.toFixed(0)}ms (+${r.percentageIncrease.toFixed(0)}%)`);
1979
+ lines.push('');
1980
+ }
1981
+ }
1982
+ // Improvements
1983
+ if (comparison.improvements.length > 0) {
1984
+ lines.push('## Improvements');
1985
+ lines.push('');
1986
+ for (let i = 0; i < Math.min(5, comparison.improvements.length); i++) {
1987
+ const imp = comparison.improvements[i];
1988
+ lines.push(`✅ **${imp.function}**`);
1989
+ lines.push(` ${imp.baselineTime.toFixed(0)}ms → ${imp.currentTime.toFixed(0)}ms (-${imp.percentageDecrease.toFixed(0)}%)`);
1990
+ lines.push('');
1991
+ }
1992
+ }
1993
+ return lines.join('\n');
1994
+ }
1995
+ /**
1996
+ * Start the MCP server
1997
+ */
1998
+ async start() {
1999
+ const transport = new StdioServerTransport();
2000
+ await this.server.connect(transport);
2001
+ console.error('Xcode Instruments Trace Analyzer MCP Server running on stdio');
2002
+ }
2003
+ }
2004
+ export async function runCli(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }, deps = defaultDependencies) {
2005
+ const [command, ...extraArgs] = argv;
2006
+ if (!command) {
2007
+ const server = new XCTraceAnalyzerServer(deps);
2008
+ await server.start();
2009
+ return 0;
2010
+ }
2011
+ if (extraArgs.length > 0) {
2012
+ io.stderr.write(`Unexpected arguments: ${extraArgs.join(' ')}\n`);
2013
+ io.stderr.write(formatCliHelp());
2014
+ return 2;
2015
+ }
2016
+ switch (command) {
2017
+ case '--version':
2018
+ case '-v':
2019
+ io.stdout.write(`${SERVER_NAME} ${SERVER_VERSION}\n`);
2020
+ return 0;
2021
+ case '--help':
2022
+ case '-h':
2023
+ io.stdout.write(formatCliHelp());
2024
+ return 0;
2025
+ case '--check':
2026
+ return runXctraceHealthCheck(io, deps);
2027
+ default:
2028
+ io.stderr.write(`Unknown argument: ${command}\n`);
2029
+ io.stderr.write(formatCliHelp());
2030
+ return 2;
2031
+ }
2032
+ }
2033
+ function resolveSecurityOptions(options) {
2034
+ const maxDurationSeconds = options.maxDurationSeconds ??
2035
+ envPositiveNumber('XCTRACE_ANALYZER_MAX_DURATION_SECONDS') ??
2036
+ DEFAULT_MAX_DURATION_SECONDS;
2037
+ if (!Number.isFinite(maxDurationSeconds) || maxDurationSeconds <= 0) {
2038
+ throw new Error('maxDurationSeconds must be a positive number');
2039
+ }
2040
+ return {
2041
+ allowLaunchProfiling: options.allowLaunchProfiling ?? envFlag('XCTRACE_ANALYZER_ALLOW_LAUNCH') ?? false,
2042
+ allowAllProcessesProfiling: options.allowAllProcessesProfiling ?? envFlag('XCTRACE_ANALYZER_ALLOW_ALL_PROCESSES') ?? false,
2043
+ allowExternalTraceOutput: options.allowExternalTraceOutput ?? envFlag('XCTRACE_ANALYZER_ALLOW_EXTERNAL_OUTPUT') ?? false,
2044
+ allowExternalTraceCleanup: options.allowExternalTraceCleanup ?? envFlag('XCTRACE_ANALYZER_ALLOW_EXTERNAL_CLEANUP') ?? false,
2045
+ traceRoot: resolve(options.traceRoot ?? process.env.XCTRACE_ANALYZER_TRACE_ROOT ?? DEFAULT_TRACE_ROOT),
2046
+ maxDurationSeconds,
2047
+ redaction: options.redaction ?? envRedactionMode() ?? 'balanced',
2048
+ };
2049
+ }
2050
+ function readPackageVersion() {
2051
+ try {
2052
+ const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
2053
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
2054
+ return typeof packageJson.version === 'string' ? packageJson.version : '0.0.0';
2055
+ }
2056
+ catch {
2057
+ return '0.0.0';
2058
+ }
2059
+ }
2060
+ export function getDefaultTraceRoot() {
2061
+ return join(homedir(), 'Library', 'Application Support', 'xctrace-analyzer', 'traces');
2062
+ }
2063
+ function formatCliHelp() {
2064
+ return [
2065
+ 'xctrace-analyzer-mcp',
2066
+ '',
2067
+ 'Usage:',
2068
+ ' xctrace-analyzer-mcp Start the MCP stdio server',
2069
+ ' xctrace-analyzer-mcp --check Check local xcrun xctrace availability',
2070
+ ' xctrace-analyzer-mcp --version Print the server version',
2071
+ ' xctrace-analyzer-mcp --help Show this help',
2072
+ '',
2073
+ 'Claude Code install:',
2074
+ ' claude mcp add --transport stdio --scope user xctrace-analyzer -- npx -y @xctrace-analyzer/mcp-server',
2075
+ '',
2076
+ `Default trace root: ${DEFAULT_TRACE_ROOT}`,
2077
+ 'Override with XCTRACE_ANALYZER_TRACE_ROOT=/path/to/traces.',
2078
+ '',
2079
+ ].join('\n');
2080
+ }
2081
+ async function runXctraceHealthCheck(io, deps) {
2082
+ try {
2083
+ const security = resolveSecurityOptions({});
2084
+ const available = await deps.isXCTraceAvailable();
2085
+ io.stdout.write(`${SERVER_NAME}: ${SERVER_VERSION}\n`);
2086
+ if (!available) {
2087
+ io.stdout.write('xcrun xctrace: unavailable\n');
2088
+ io.stdout.write('Install Xcode or Xcode Command Line Tools, then run xcode-select --install if needed.\n');
2089
+ return 1;
2090
+ }
2091
+ const capabilities = deps.getXCTraceCapabilities
2092
+ ? await deps.getXCTraceCapabilities()
2093
+ : await fallbackCapabilities(deps);
2094
+ io.stdout.write('xcrun xctrace: available\n');
2095
+ if (capabilities.version) {
2096
+ io.stdout.write(`version: ${redactText(capabilities.version, 'balanced', true)}\n`);
2097
+ }
2098
+ io.stdout.write(`templates: ${capabilities.templates.length}\n`);
2099
+ io.stdout.write(`devices: ${capabilities.devices.length}\n`);
2100
+ io.stdout.write(`instruments: ${capabilities.instruments.length}\n`);
2101
+ io.stdout.write(`export modes: ${capabilities.exportModes.join(', ') || 'none detected'}\n`);
2102
+ io.stdout.write(`record modes: ${capabilities.recordModes.join(', ') || 'none detected'}\n`);
2103
+ io.stdout.write(`symbolication: ${capabilities.supportsSymbolication ? 'supported' : 'not detected'}\n`);
2104
+ io.stdout.write(`trace root: ${security.traceRoot}\n`);
2105
+ if (capabilities.warnings.length > 0) {
2106
+ io.stdout.write('warnings:\n');
2107
+ for (const warning of capabilities.warnings) {
2108
+ io.stdout.write(`- ${redactText(warning, 'balanced', true)}\n`);
2109
+ }
2110
+ }
2111
+ return 0;
2112
+ }
2113
+ catch (error) {
2114
+ io.stderr.write(`xcrun xctrace check failed: ${formatCliError(error)}\n`);
2115
+ return 1;
2116
+ }
2117
+ }
2118
+ function formatCliError(error) {
2119
+ const message = error instanceof Error ? error.message : String(error);
2120
+ return redactText(message, 'balanced', true);
2121
+ }
2122
+ async function fallbackCapabilities(deps) {
2123
+ const [version, templates, devices] = await Promise.all([
2124
+ deps.getXCTraceVersion(),
2125
+ deps.listTemplates(),
2126
+ deps.listDevices(),
2127
+ ]);
2128
+ return {
2129
+ available: true,
2130
+ version,
2131
+ templates,
2132
+ devices,
2133
+ instruments: [],
2134
+ exportModes: [],
2135
+ recordModes: [],
2136
+ supportsSymbolication: false,
2137
+ warnings: [],
2138
+ };
2139
+ }
2140
+ function envFlag(name) {
2141
+ const value = process.env[name];
2142
+ if (value === undefined) {
2143
+ return undefined;
2144
+ }
2145
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
2146
+ }
2147
+ function envPositiveNumber(name) {
2148
+ const value = process.env[name];
2149
+ if (value === undefined || value.trim() === '') {
2150
+ return undefined;
2151
+ }
2152
+ const parsed = Number(value);
2153
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
2154
+ }
2155
+ function envRedactionMode() {
2156
+ const value = process.env.XCTRACE_ANALYZER_REDACTION?.trim().toLowerCase();
2157
+ if (value === 'balanced' || value === 'strict' || value === 'off') {
2158
+ return value;
2159
+ }
2160
+ return undefined;
2161
+ }
2162
+ function isPathInside(pathValue, root) {
2163
+ const relation = relative(root, pathValue);
2164
+ return relation === '' || (!!relation && !relation.startsWith('..') && !isAbsolute(relation));
2165
+ }
2166
+ function codeFenceFor(value) {
2167
+ const maxBackticks = Math.max(0, ...Array.from(value.matchAll(/`+/g), (match) => match[0].length));
2168
+ return '`'.repeat(Math.max(3, maxBackticks + 1));
2169
+ }
2170
+ function redactText(value, mode, collapseWhitespace) {
2171
+ let output = value.replace(/\r\n?/g, '\n');
2172
+ output = output.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ');
2173
+ if (mode !== 'off') {
2174
+ output = output
2175
+ .replace(/\/Users\/[^/\s]+/g, '/Users/<redacted>')
2176
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer <redacted>')
2177
+ .replace(/([?&][^=\s&]*(?:token|secret|password|key|authorization)[^=\s&]*=)[^&\s]+/gi, '$1<redacted>')
2178
+ .replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|secret|password|authorization)\s*[:=]\s*)(["']?)[^"',\s)]+/gi, '$1$2<redacted>');
2179
+ }
2180
+ if (mode === 'strict') {
2181
+ output = output
2182
+ .replace(/\bhttps?:\/\/[^/\s?#]+/gi, 'https://<host-redacted>')
2183
+ .replace(/\b[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '<host-redacted>');
2184
+ }
2185
+ if (collapseWhitespace) {
2186
+ output = output.replace(/\s+/g, ' ').replace(/```/g, "'''").trim();
2187
+ }
2188
+ return output;
2189
+ }
2190
+ function isMainModule() {
2191
+ return process.argv[1]
2192
+ ? fileURLToPath(import.meta.url) === resolve(process.argv[1])
2193
+ : false;
2194
+ }
2195
+ /** Format a trace-relative offset as `mm:ss.SSS` (matches Instruments display). */
2196
+ function formatHangStartTime(ms) {
2197
+ const totalMs = Math.max(0, Math.round(ms));
2198
+ const minutes = Math.floor(totalMs / 60_000);
2199
+ const seconds = Math.floor((totalMs % 60_000) / 1000);
2200
+ const millis = totalMs % 1000;
2201
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
2202
+ }
2203
+ /** Format a duration in ms as a compact human string (`562 ms`, `4.76 s`). */
2204
+ function formatHangDuration(ms) {
2205
+ if (ms < 1000)
2206
+ return `${ms.toFixed(0)} ms`;
2207
+ return `${(ms / 1000).toFixed(2)} s`;
2208
+ }
2209
+ if (isMainModule()) {
2210
+ runCli().then((exitCode) => {
2211
+ if (exitCode !== 0) {
2212
+ process.exit(exitCode);
2213
+ }
2214
+ }).catch((error) => {
2215
+ console.error('Failed to start server:', error);
2216
+ process.exit(1);
2217
+ });
2218
+ }
2219
+ //# sourceMappingURL=index.js.map