@vscode/component-explorer-cli 0.1.1-8 → 0.2.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/SECURITY.md +14 -0
  3. package/dist/WorktreePool.d.ts +22 -0
  4. package/dist/WorktreePool.d.ts.map +1 -0
  5. package/dist/WorktreePool.js +58 -0
  6. package/dist/WorktreePool.js.map +1 -0
  7. package/dist/WorktreePool.test.d.ts +2 -0
  8. package/dist/WorktreePool.test.d.ts.map +1 -0
  9. package/dist/_virtual/_build-info.js +4 -0
  10. package/dist/_virtual/_build-info.js.map +1 -0
  11. package/dist/browserPage.d.ts +5 -0
  12. package/dist/browserPage.d.ts.map +1 -1
  13. package/dist/browserPage.js +28 -2
  14. package/dist/browserPage.js.map +1 -1
  15. package/dist/commands/acceptCommand.d.ts.map +1 -1
  16. package/dist/commands/acceptCommand.js +3 -2
  17. package/dist/commands/acceptCommand.js.map +1 -1
  18. package/dist/commands/checkStabilityCommand.d.ts +12 -0
  19. package/dist/commands/checkStabilityCommand.d.ts.map +1 -0
  20. package/dist/commands/checkStabilityCommand.js +84 -0
  21. package/dist/commands/checkStabilityCommand.js.map +1 -0
  22. package/dist/commands/compareCommand.d.ts +1 -0
  23. package/dist/commands/compareCommand.d.ts.map +1 -1
  24. package/dist/commands/compareCommand.js +25 -4
  25. package/dist/commands/compareCommand.js.map +1 -1
  26. package/dist/commands/mcpCommand.d.ts +1 -0
  27. package/dist/commands/mcpCommand.d.ts.map +1 -1
  28. package/dist/commands/mcpCommand.js +13 -5
  29. package/dist/commands/mcpCommand.js.map +1 -1
  30. package/dist/commands/screenshotCommand.d.ts +2 -0
  31. package/dist/commands/screenshotCommand.d.ts.map +1 -1
  32. package/dist/commands/screenshotCommand.js +15 -4
  33. package/dist/commands/screenshotCommand.js.map +1 -1
  34. package/dist/commands/serveCommand.d.ts +2 -0
  35. package/dist/commands/serveCommand.d.ts.map +1 -1
  36. package/dist/commands/serveCommand.js +36 -11
  37. package/dist/commands/serveCommand.js.map +1 -1
  38. package/dist/commands/watchCommand.d.ts +2 -0
  39. package/dist/commands/watchCommand.d.ts.map +1 -1
  40. package/dist/commands/watchCommand.js +10 -63
  41. package/dist/commands/watchCommand.js.map +1 -1
  42. package/dist/comparison.d.ts +11 -1
  43. package/dist/comparison.d.ts.map +1 -1
  44. package/dist/comparison.js +25 -11
  45. package/dist/comparison.js.map +1 -1
  46. package/dist/component-explorer-config.schema.json +97 -58
  47. package/dist/componentExplorer.d.ts +13 -17
  48. package/dist/componentExplorer.d.ts.map +1 -1
  49. package/dist/componentExplorer.js +49 -31
  50. package/dist/componentExplorer.js.map +1 -1
  51. package/dist/daemon/DaemonContext.d.ts +4 -0
  52. package/dist/daemon/DaemonContext.d.ts.map +1 -0
  53. package/dist/daemon/DaemonService.d.ts +92 -23
  54. package/dist/daemon/DaemonService.d.ts.map +1 -1
  55. package/dist/daemon/DaemonService.js +473 -118
  56. package/dist/daemon/DaemonService.js.map +1 -1
  57. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  58. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  59. package/dist/daemon/lifecycle.d.ts +8 -3
  60. package/dist/daemon/lifecycle.d.ts.map +1 -1
  61. package/dist/daemon/lifecycle.js +28 -24
  62. package/dist/daemon/lifecycle.js.map +1 -1
  63. package/dist/daemon/pipeClient.d.ts +6 -1
  64. package/dist/daemon/pipeClient.d.ts.map +1 -1
  65. package/dist/daemon/pipeClient.js +97 -5
  66. package/dist/daemon/pipeClient.js.map +1 -1
  67. package/dist/daemon/pipeServer.d.ts +2 -1
  68. package/dist/daemon/pipeServer.d.ts.map +1 -1
  69. package/dist/daemon/pipeServer.js +62 -3
  70. package/dist/daemon/pipeServer.js.map +1 -1
  71. package/dist/daemon/version.d.ts +10 -0
  72. package/dist/daemon/version.d.ts.map +1 -0
  73. package/dist/daemon/version.js +17 -0
  74. package/dist/daemon/version.js.map +1 -0
  75. package/dist/dependencyInstaller.d.ts +2 -2
  76. package/dist/dependencyInstaller.d.ts.map +1 -1
  77. package/dist/dependencyInstaller.js.map +1 -1
  78. package/dist/formatValue.d.ts +2 -0
  79. package/dist/formatValue.d.ts.map +1 -0
  80. package/dist/formatValue.js +96 -0
  81. package/dist/formatValue.js.map +1 -0
  82. package/dist/formatValue.test.d.ts +2 -0
  83. package/dist/formatValue.test.d.ts.map +1 -0
  84. package/dist/git/gitIndexResolver.d.ts +25 -0
  85. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  86. package/dist/git/gitIndexResolver.js +91 -0
  87. package/dist/git/gitIndexResolver.js.map +1 -0
  88. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  89. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  90. package/dist/git/gitService.d.ts +2 -0
  91. package/dist/git/gitService.d.ts.map +1 -1
  92. package/dist/git/gitService.js +6 -0
  93. package/dist/git/gitService.js.map +1 -1
  94. package/dist/git/gitWorktreeManager.d.ts +6 -0
  95. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  96. package/dist/git/gitWorktreeManager.js +42 -13
  97. package/dist/git/gitWorktreeManager.js.map +1 -1
  98. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  99. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  100. package/dist/git/testUtils.d.ts +13 -0
  101. package/dist/git/testUtils.d.ts.map +1 -0
  102. package/dist/httpServer.d.ts +6 -1
  103. package/dist/httpServer.d.ts.map +1 -1
  104. package/dist/httpServer.js +17 -3
  105. package/dist/httpServer.js.map +1 -1
  106. package/dist/index.js +11 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logger.d.ts +1 -0
  109. package/dist/logger.d.ts.map +1 -1
  110. package/dist/logger.js +7 -1
  111. package/dist/logger.js.map +1 -1
  112. package/dist/mcp/McpServer.d.ts +19 -5
  113. package/dist/mcp/McpServer.d.ts.map +1 -1
  114. package/dist/mcp/McpServer.js +447 -97
  115. package/dist/mcp/McpServer.js.map +1 -1
  116. package/dist/mcp/TaskManager.d.ts +28 -0
  117. package/dist/mcp/TaskManager.d.ts.map +1 -0
  118. package/dist/mcp/TaskManager.js +54 -0
  119. package/dist/mcp/TaskManager.js.map +1 -0
  120. package/dist/packages/simple-api/dist/{chunk-Q24JOMNK.js → chunk-TAEFVNPN.js} +1 -1
  121. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  122. package/dist/packages/simple-api/dist/express.js +11 -3
  123. package/dist/packages/simple-api/dist/express.js.map +1 -1
  124. package/dist/utils.d.ts +7 -0
  125. package/dist/utils.d.ts.map +1 -1
  126. package/dist/utils.js +6 -7
  127. package/dist/utils.js.map +1 -1
  128. package/dist/watchConfig.d.ts +19 -12
  129. package/dist/watchConfig.d.ts.map +1 -1
  130. package/dist/watchConfig.js +43 -48
  131. package/dist/watchConfig.js.map +1 -1
  132. package/package.json +21 -4
  133. package/dist/packages/simple-api/dist/chunk-Q24JOMNK.js.map +0 -1
@@ -9,6 +9,7 @@ import '../external/vscode-observables/observables/dist/observableInternal/obser
9
9
  import '../external/vscode-observables/observables/dist/observableInternal/utils/utils.js';
10
10
  import '../external/vscode-observables/observables/dist/observableInternal/observables/observableFromEvent.js';
11
11
  import { buildExplorerUrl } from '../utils.js';
12
+ import { TaskManager } from './TaskManager.js';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Client-local state
@@ -70,9 +71,22 @@ function noDaemonError(hint) {
70
71
  // ---------------------------------------------------------------------------
71
72
  class DaemonConnection {
72
73
  client;
74
+ _stale = false;
73
75
  constructor(client) {
74
76
  this.client = client;
75
77
  }
78
+ get isStale() { return this._stale; }
79
+ markStale() { this._stale = true; }
80
+ }
81
+ function isPipeConnectionError(e) {
82
+ if (!(e instanceof Error)) {
83
+ return false;
84
+ }
85
+ const code = e.code;
86
+ if (code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EPIPE') {
87
+ return true;
88
+ }
89
+ return /connect ENOENT|ECONNREFUSED|ECONNRESET|EPIPE/.test(e.message);
76
90
  }
77
91
  // ---------------------------------------------------------------------------
78
92
  // ComponentExplorerMcpServer
@@ -87,6 +101,8 @@ class ComponentExplorerMcpServer extends Disposable {
87
101
  }
88
102
  _mcp;
89
103
  _watchList = new WatchList();
104
+ _taskManager = new TaskManager();
105
+ _taskLastReportedIndex = new Map();
90
106
  _pollFn;
91
107
  _noAutostartHint;
92
108
  _multiSessionTools = [];
@@ -97,6 +113,7 @@ class ComponentExplorerMcpServer extends Disposable {
97
113
  this._daemonConnection = _daemonConnection;
98
114
  this._pollFn = options.pollFn;
99
115
  this._noAutostartHint = options.noAutostartHint;
116
+ this._callTimeoutMs = options.callTimeoutMs ?? ComponentExplorerMcpServer._DEFAULT_CALL_TIMEOUT_MS;
100
117
  this._mcp = new McpServer({
101
118
  name: 'component-explorer',
102
119
  version: '0.1.0',
@@ -108,7 +125,6 @@ class ComponentExplorerMcpServer extends Disposable {
108
125
  }));
109
126
  }
110
127
  async _onDaemonChanged(daemon) {
111
- // Cancel any existing event stream
112
128
  this._eventStreamAbortController?.abort();
113
129
  this._eventStreamAbortController = undefined;
114
130
  if (!daemon) {
@@ -116,10 +132,9 @@ class ComponentExplorerMcpServer extends Disposable {
116
132
  this._log('info', { type: 'daemon-disconnected' });
117
133
  return;
118
134
  }
119
- // Fetch sessions and start event listener
120
135
  try {
121
136
  this._sessions = await daemon.methods.sessions();
122
- this._log('info', { type: 'daemon-connected', sessions: this._sessions.length });
137
+ this._log('debug', { type: 'daemon-connected', sessions: this._sessions.length });
123
138
  this._updateMultiSessionToolVisibility();
124
139
  this._startEventListener(daemon);
125
140
  }
@@ -128,37 +143,77 @@ class ComponentExplorerMcpServer extends Disposable {
128
143
  this._sessions = [];
129
144
  }
130
145
  }
131
- _getDaemon() {
132
- return this._daemonConnection.get()?.client;
146
+ _getConnection() {
147
+ const conn = this._daemonConnection.get();
148
+ if (conn?.isStale) {
149
+ return undefined;
150
+ }
151
+ return conn;
133
152
  }
134
- /**
135
- * If no daemon is connected and we have a pollFn, poll immediately and wait
136
- * up to 3 seconds for the daemon to become available.
137
- */
138
153
  async _waitForDaemon() {
139
- let daemon = this._getDaemon();
140
- if (daemon || !this._pollFn) {
141
- return daemon;
154
+ let conn = this._getConnection();
155
+ if (conn) {
156
+ return conn.client;
142
157
  }
143
- // Poll immediately and wait up to 3 seconds
158
+ if (!this._pollFn) {
159
+ return undefined;
160
+ }
161
+ this._log('debug', { type: 'waiting-for-daemon' });
144
162
  const startTime = Date.now();
145
163
  const timeout = 3000;
146
164
  while (Date.now() - startTime < timeout) {
147
165
  await this._pollFn();
148
- daemon = this._getDaemon();
149
- if (daemon) {
150
- return daemon;
166
+ conn = this._getConnection();
167
+ if (conn) {
168
+ return conn.client;
151
169
  }
152
- // Wait 200ms before next poll
153
170
  await new Promise(resolve => setTimeout(resolve, 200));
154
171
  }
155
172
  return undefined;
156
173
  }
174
+ _handleDisconnect() {
175
+ const conn = this._daemonConnection.get();
176
+ if (conn && !conn.isStale) {
177
+ conn.markStale();
178
+ this._sessions = [];
179
+ this._log('debug', { type: 'daemon-connection-lost' });
180
+ }
181
+ }
157
182
  _noDaemonError() {
158
183
  return noDaemonError(this._noAutostartHint);
159
184
  }
185
+ static _DEFAULT_CALL_TIMEOUT_MS = 15_000;
186
+ _callTimeoutMs;
187
+ async _withDaemon(fn, options) {
188
+ const daemon = await this._waitForDaemon();
189
+ if (!daemon) {
190
+ return this._noDaemonError();
191
+ }
192
+ try {
193
+ if (options?.noTimeout) {
194
+ return await fn(daemon);
195
+ }
196
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('__mcp_timeout__')), this._callTimeoutMs));
197
+ return await Promise.race([fn(daemon), timeout]);
198
+ }
199
+ catch (e) {
200
+ if (e instanceof Error && e.message === '__mcp_timeout__') {
201
+ return {
202
+ content: [{ type: 'text', text: `Error: Operation timed out after ${this._callTimeoutMs / 1000}s. Retry, if the error persists, restart the involved session using the restart_session tool and retry.` }],
203
+ isError: true,
204
+ };
205
+ }
206
+ if (isPipeConnectionError(e)) {
207
+ this._log('debug', { type: 'daemon-call-failed', error: String(e) });
208
+ this._handleDisconnect();
209
+ return this._noDaemonError();
210
+ }
211
+ throw e;
212
+ }
213
+ }
160
214
  _log(level, data) {
161
- this._mcp.sendLoggingMessage({ level, logger: 'daemon', data }).catch(() => { });
215
+ const mcpLevel = level === 'trace' ? 'debug' : level;
216
+ this._mcp.sendLoggingMessage({ level: mcpLevel, logger: 'daemon', data }).catch(() => { });
162
217
  }
163
218
  _startEventListener(daemon) {
164
219
  const controller = new AbortController();
@@ -173,7 +228,7 @@ class ComponentExplorerMcpServer extends Disposable {
173
228
  if (event.type === 'source-change' && event.sessionName && event.sourceTreeId) {
174
229
  this._updateSessionSourceTreeId(event.sessionName, event.sourceTreeId);
175
230
  }
176
- if (event.type === 'ref-change') {
231
+ if (event.type === 'ref-change' || event.type === 'session-change') {
177
232
  await this._refreshSessions();
178
233
  }
179
234
  this._log(event.type === 'log' && event.level === 'debug' ? 'debug' : 'info', event);
@@ -209,12 +264,22 @@ class ComponentExplorerMcpServer extends Disposable {
209
264
  }
210
265
  }
211
266
  async _refreshSessions() {
212
- const daemon = this._getDaemon();
213
- if (daemon) {
214
- const prevCount = this._sessions.length;
215
- this._sessions = await daemon.methods.sessions();
216
- if (this._sessions.length !== prevCount) {
217
- this._updateMultiSessionToolVisibility();
267
+ const conn = this._getConnection();
268
+ if (conn) {
269
+ try {
270
+ const prevCount = this._sessions.length;
271
+ this._sessions = await conn.client.methods.sessions();
272
+ if (this._sessions.length !== prevCount) {
273
+ this._updateMultiSessionToolVisibility();
274
+ }
275
+ }
276
+ catch (e) {
277
+ if (isPipeConnectionError(e)) {
278
+ this._handleDisconnect();
279
+ }
280
+ else {
281
+ throw e;
282
+ }
218
283
  }
219
284
  }
220
285
  }
@@ -243,6 +308,30 @@ class ComponentExplorerMcpServer extends Disposable {
243
308
  }
244
309
  }
245
310
  // -- Tool registration ---------------------------------------------------
311
+ _filterFixtures(allFixtures, fixtureIdPattern, labelPattern) {
312
+ let fixtureIdRegex;
313
+ if (fixtureIdPattern) {
314
+ try {
315
+ fixtureIdRegex = new RegExp(fixtureIdPattern);
316
+ }
317
+ catch {
318
+ return { error: `Error: Invalid fixtureIdPattern: ${fixtureIdPattern}` };
319
+ }
320
+ }
321
+ let labelRegex;
322
+ if (labelPattern) {
323
+ try {
324
+ labelRegex = new RegExp(labelPattern);
325
+ }
326
+ catch {
327
+ return { error: `Error: Invalid labelPattern: ${labelPattern}` };
328
+ }
329
+ }
330
+ return {
331
+ fixtures: allFixtures.filter(f => (!fixtureIdRegex || fixtureIdRegex.test(f.fixtureId)) &&
332
+ (!labelRegex || f.labels.some(l => labelRegex.test(l)))),
333
+ };
334
+ }
246
335
  _registerTools() {
247
336
  this._registerListFixtures();
248
337
  this._registerScreenshot();
@@ -256,30 +345,40 @@ class ComponentExplorerMcpServer extends Disposable {
256
345
  this._registerWatchCompare();
257
346
  this._registerWaitForUpdate();
258
347
  this._registerSessions();
348
+ this._registerRestartSession();
349
+ this._registerOpenSession();
350
+ this._registerCloseSession();
351
+ this._registerUpdateSessionRef();
259
352
  this._registerGetUrl();
353
+ this._registerCheckStability();
354
+ this._registerCheckTask();
355
+ this._registerCancelTask();
260
356
  }
261
357
  _registerListFixtures() {
262
358
  this._mcp.registerTool('list_fixtures', {
263
359
  description: 'List all fixtures from a session',
264
360
  inputSchema: {
361
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
362
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
265
363
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
266
364
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
267
365
  },
268
366
  annotations: { readOnlyHint: true },
269
- }, async (args) => {
270
- const daemon = await this._waitForDaemon();
271
- if (!daemon) {
272
- return this._noDaemonError();
273
- }
367
+ }, async (args) => this._withDaemon(async (daemon) => {
274
368
  const sessionName = args.sessionName ?? this._defaultSessionName();
369
+ this._log('debug', { type: 'tool-call', tool: 'list_fixtures', sessionName });
275
370
  return this._withSourceTreeRetry(async () => {
276
371
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
277
- const fixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
372
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
373
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
374
+ if ('error' in filtered) {
375
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
376
+ }
278
377
  return {
279
- content: [{ type: 'text', text: JSON.stringify(fixtures, null, 2) }],
378
+ content: [{ type: 'text', text: JSON.stringify(filtered.fixtures, null, 2) }],
280
379
  };
281
380
  });
282
- });
381
+ }));
283
382
  }
284
383
  _registerScreenshot() {
285
384
  this._mcp.registerTool('screenshot', {
@@ -292,12 +391,10 @@ class ComponentExplorerMcpServer extends Disposable {
292
391
  stabilityCheck: z.boolean().optional().describe('If true, takes three screenshots (at render time, ~500ms, ~3000ms) to check visual stability. Expensive (~3s extra) — only use it when you need to verify rendering stability..'),
293
392
  },
294
393
  annotations: { readOnlyHint: true },
295
- }, async (args) => {
296
- const daemon = await this._waitForDaemon();
297
- if (!daemon) {
298
- return this._noDaemonError();
299
- }
394
+ }, async (args) => this._withDaemon(async (daemon) => {
300
395
  const sessionName = args.sessionName ?? this._defaultSessionName();
396
+ this._log('debug', { type: 'tool-call', tool: 'screenshot', fixtureId: args.fixtureId, sessionName });
397
+ this._log('trace', { type: 'tool-args', tool: 'screenshot', args });
301
398
  return this._withSourceTreeRetry(async () => {
302
399
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
303
400
  const result = await daemon.methods.screenshots.take({
@@ -313,8 +410,17 @@ class ComponentExplorerMcpServer extends Disposable {
313
410
  hash: r.hash,
314
411
  sourceTreeId: r.sourceTreeId,
315
412
  };
316
- if (r.errors && r.errors.length > 0) {
317
- info.errors = r.errors;
413
+ if (r.hasError) {
414
+ info.hasError = true;
415
+ }
416
+ if (r.error) {
417
+ info.error = r.error;
418
+ }
419
+ if (r.events && r.events.length > 0) {
420
+ info.events = r.events;
421
+ }
422
+ if (r.resultData !== undefined) {
423
+ info.resultData = r.resultData;
318
424
  }
319
425
  if (r.isStable !== undefined) {
320
426
  info.isStable = r.isStable;
@@ -340,7 +446,7 @@ class ComponentExplorerMcpServer extends Disposable {
340
446
  content.unshift({ type: 'text', text: JSON.stringify(info, null, 2) });
341
447
  return { content };
342
448
  });
343
- });
449
+ }));
344
450
  }
345
451
  _registerCompareScreenshot() {
346
452
  const tool = this._mcp.registerTool('compare_screenshot', {
@@ -353,13 +459,10 @@ class ComponentExplorerMcpServer extends Disposable {
353
459
  currentSourceTreeId: z.string().optional().describe('Current source tree ID (defaults to latest known)'),
354
460
  },
355
461
  annotations: { readOnlyHint: true },
356
- }, async (args) => {
357
- const daemon = await this._waitForDaemon();
358
- if (!daemon) {
359
- return this._noDaemonError();
360
- }
462
+ }, async (args) => this._withDaemon(async (daemon) => {
361
463
  const baselineSessionName = args.baselineSessionName ?? this._defaultBaselineSessionName();
362
464
  const currentSessionName = args.currentSessionName ?? this._defaultCurrentSessionName();
465
+ this._log('debug', { type: 'tool-call', tool: 'compare_screenshot', fixtureId: args.fixtureId, baselineSessionName, currentSessionName });
363
466
  return this._withSourceTreeRetry(async () => {
364
467
  const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
365
468
  const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
@@ -376,12 +479,32 @@ class ComponentExplorerMcpServer extends Disposable {
376
479
  match: r.match,
377
480
  baselineHash: r.baselineHash,
378
481
  currentHash: r.currentHash,
482
+ baselineSourceTreeId,
483
+ currentSourceTreeId,
379
484
  };
380
- if (r.baselineErrors && r.baselineErrors.length > 0) {
381
- info.baselineErrors = r.baselineErrors;
485
+ if (r.baselineHasError) {
486
+ info.baselineHasError = true;
487
+ }
488
+ if (r.baselineError) {
489
+ info.baselineError = r.baselineError;
490
+ }
491
+ if (r.baselineEvents && r.baselineEvents.length > 0) {
492
+ info.baselineEvents = r.baselineEvents;
493
+ }
494
+ if (r.baselineResultData !== undefined) {
495
+ info.baselineResultData = r.baselineResultData;
382
496
  }
383
- if (r.currentErrors && r.currentErrors.length > 0) {
384
- info.currentErrors = r.currentErrors;
497
+ if (r.currentHasError) {
498
+ info.currentHasError = true;
499
+ }
500
+ if (r.currentError) {
501
+ info.currentError = r.currentError;
502
+ }
503
+ if (r.currentEvents && r.currentEvents.length > 0) {
504
+ info.currentEvents = r.currentEvents;
505
+ }
506
+ if (r.currentResultData !== undefined) {
507
+ info.currentResultData = r.currentResultData;
385
508
  }
386
509
  if (r.approval) {
387
510
  info.approval = r.approval;
@@ -397,7 +520,7 @@ class ComponentExplorerMcpServer extends Disposable {
397
520
  }
398
521
  return { content };
399
522
  });
400
- });
523
+ }));
401
524
  tool.disable();
402
525
  this._multiSessionTools.push(tool);
403
526
  }
@@ -410,11 +533,7 @@ class ComponentExplorerMcpServer extends Disposable {
410
533
  modifiedHash: z.string(),
411
534
  comment: z.string().describe('Reason for approving this diff'),
412
535
  },
413
- }, async (args) => {
414
- const daemon = await this._waitForDaemon();
415
- if (!daemon) {
416
- return this._noDaemonError();
417
- }
536
+ }, async (args) => this._withDaemon(async (daemon) => {
418
537
  await daemon.methods.approvals.approve(args);
419
538
  return {
420
539
  content: [{
@@ -422,7 +541,7 @@ class ComponentExplorerMcpServer extends Disposable {
422
541
  text: `Approved diff for ${args.fixtureId}: ${args.originalHash} → ${args.modifiedHash}`,
423
542
  }],
424
543
  };
425
- });
544
+ }));
426
545
  tool.disable();
427
546
  this._multiSessionTools.push(tool);
428
547
  }
@@ -438,12 +557,10 @@ class ComponentExplorerMcpServer extends Disposable {
438
557
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
439
558
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
440
559
  },
441
- }, async (args) => {
442
- const daemon = await this._waitForDaemon();
443
- if (!daemon) {
444
- return this._noDaemonError();
445
- }
560
+ }, async (args) => this._withDaemon(async (daemon) => {
446
561
  const sessionName = args.sessionName ?? this._defaultSessionName();
562
+ this._log('debug', { type: 'tool-call', tool: 'evaluate_js', sessionName, hasFixtureId: !!args.fixtureId });
563
+ this._log('trace', { type: 'tool-args', tool: 'evaluate_js', expressionLength: args.expression.length, fixtureId: args.fixtureId });
447
564
  return this._withSourceTreeRetry(async () => {
448
565
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
449
566
  const result = await daemon.methods.evaluate({
@@ -464,7 +581,7 @@ class ComponentExplorerMcpServer extends Disposable {
464
581
  content: [{ type: 'text', text }],
465
582
  };
466
583
  });
467
- });
584
+ }));
468
585
  }
469
586
  _registerDebugReloadPage() {
470
587
  this._mcp.registerTool('debug_reload_page', {
@@ -475,11 +592,7 @@ class ComponentExplorerMcpServer extends Disposable {
475
592
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
476
593
  },
477
594
  annotations: { destructiveHint: true },
478
- }, async (args) => {
479
- const daemon = await this._waitForDaemon();
480
- if (!daemon) {
481
- return this._noDaemonError();
482
- }
595
+ }, async (args) => this._withDaemon(async (daemon) => {
483
596
  const sessionName = args.sessionName ?? this._defaultSessionName();
484
597
  const sourceTreeId = this._sourceTreeId(sessionName);
485
598
  await daemon.methods.evaluate({
@@ -490,7 +603,7 @@ class ComponentExplorerMcpServer extends Disposable {
490
603
  return {
491
604
  content: [{ type: 'text', text: `Reloaded page for session '${sessionName}'.` }],
492
605
  };
493
- });
606
+ }));
494
607
  }
495
608
  _registerWatchAdd() {
496
609
  this._mcp.registerTool('watch_add', {
@@ -550,11 +663,7 @@ class ComponentExplorerMcpServer extends Disposable {
550
663
  currentSourceTreeId: z.string().optional().describe('Current source tree ID (defaults to latest known)'),
551
664
  },
552
665
  annotations: { readOnlyHint: true },
553
- }, async (args) => {
554
- const daemon = await this._waitForDaemon();
555
- if (!daemon) {
556
- return this._noDaemonError();
557
- }
666
+ }, async (args) => this._withDaemon(async (daemon) => {
558
667
  const ids = [...this._watchList.fixtureIds];
559
668
  if (ids.length === 0) {
560
669
  return { content: [{ type: 'text', text: 'Watch list is empty. Use watch_add or watch_set first.' }] };
@@ -607,7 +716,7 @@ class ComponentExplorerMcpServer extends Disposable {
607
716
  content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
608
717
  };
609
718
  });
610
- });
719
+ }));
611
720
  tool.disable();
612
721
  this._multiSessionTools.push(tool);
613
722
  }
@@ -622,14 +731,9 @@ class ComponentExplorerMcpServer extends Disposable {
622
731
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
623
732
  },
624
733
  annotations: { readOnlyHint: true },
625
- }, async (args) => {
626
- const daemon = await this._waitForDaemon();
627
- if (!daemon) {
628
- return this._noDaemonError();
629
- }
734
+ }, async (args) => this._withDaemon(async (daemon) => {
630
735
  const sessionName = args.sessionName ?? this._defaultSessionName();
631
736
  const knownSourceTreeId = args.sourceTreeId;
632
- // Check if already changed
633
737
  const currentSourceTreeId = this._sourceTreeId(sessionName);
634
738
  if (currentSourceTreeId && currentSourceTreeId !== knownSourceTreeId) {
635
739
  return this._waitForUpdateResult(daemon, sessionName, currentSourceTreeId);
@@ -638,8 +742,13 @@ class ComponentExplorerMcpServer extends Disposable {
638
742
  const events = await daemon.methods.events();
639
743
  const iterator = events[Symbol.asyncIterator]();
640
744
  try {
641
- const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), 5000));
745
+ const deadline = Date.now() + 5000;
642
746
  while (true) {
747
+ const remaining = deadline - Date.now();
748
+ if (remaining <= 0) {
749
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
750
+ }
751
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), remaining));
643
752
  const next = iterator.next();
644
753
  const result = await Promise.race([next, timeout]);
645
754
  if (result === 'timeout') {
@@ -654,7 +763,10 @@ class ComponentExplorerMcpServer extends Disposable {
654
763
  this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
655
764
  }
656
765
  if (ev.type === 'ref-change') {
657
- await this._refreshSessions();
766
+ const refreshResult = await Promise.race([this._refreshSessions(), timeout]);
767
+ if (refreshResult === 'timeout') {
768
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
769
+ }
658
770
  }
659
771
  const newSourceTreeId = this._sourceTreeId(sessionName);
660
772
  if (newSourceTreeId && newSourceTreeId !== knownSourceTreeId) {
@@ -665,7 +777,7 @@ class ComponentExplorerMcpServer extends Disposable {
665
777
  finally {
666
778
  await iterator.return?.();
667
779
  }
668
- });
780
+ }));
669
781
  }
670
782
  async _waitForUpdateResult(daemon, sessionName, sourceTreeId) {
671
783
  const watchedIds = [...this._watchList.fixtureIds];
@@ -708,16 +820,96 @@ class ComponentExplorerMcpServer extends Disposable {
708
820
  this._mcp.registerTool('sessions', {
709
821
  description: 'List active sessions with their names, URLs, and current sourceTreeIds',
710
822
  annotations: { readOnlyHint: true },
711
- }, async () => {
712
- const daemon = await this._waitForDaemon();
713
- if (!daemon) {
714
- return this._noDaemonError();
715
- }
823
+ }, async () => this._withDaemon(async (_daemon) => {
716
824
  await this._refreshSessions();
717
825
  return {
718
826
  content: [{ type: 'text', text: JSON.stringify(this._sessions, null, 2) }],
719
827
  };
720
- });
828
+ }));
829
+ }
830
+ _registerRestartSession() {
831
+ this._mcp.registerTool('restart_session', {
832
+ description: 'Restart a session by disposing its browser page and dev server, then recreating them. ' +
833
+ 'Use this when a session appears stuck (e.g. after a timeout).',
834
+ inputSchema: {
835
+ sessionName: z.string().optional().describe('Session name to restart (defaults to first session)'),
836
+ },
837
+ annotations: { destructiveHint: true },
838
+ }, async (args) => this._withDaemon(async (daemon) => {
839
+ const sessionName = args.sessionName ?? this._defaultSessionName();
840
+ this._log('info', { type: 'tool-call', tool: 'restart_session', sessionName });
841
+ const sessions = await daemon.methods.restartSession({ sessionName });
842
+ this._sessions = sessions;
843
+ return {
844
+ content: [{ type: 'text', text: `Session '${sessionName}' restarted.\n` + JSON.stringify(sessions, null, 2) }],
845
+ };
846
+ }));
847
+ }
848
+ _registerOpenSession() {
849
+ this._mcp.registerTool('open_session', {
850
+ description: 'Open a new worktree-backed session at a given git ref. ' +
851
+ 'The ref can be a branch name, tag, commit SHA, or the special value "INDEX" to snapshot staged changes. ' +
852
+ 'The daemon allocates a reusable worktree slot from a fixed pool (max configured in component-explorer.json). ' +
853
+ 'Returns the updated session list on success.',
854
+ inputSchema: {
855
+ name: z.string().describe('Unique session name (e.g. "baseline", "bisect")'),
856
+ ref: z.string().describe('Git ref: branch, tag, commit SHA, or "INDEX" for staged changes'),
857
+ },
858
+ }, async (args) => this._withDaemon(async (daemon) => {
859
+ this._log('info', { type: 'tool-call', tool: 'open_session', name: args.name, ref: args.ref });
860
+ const result = await daemon.methods.openSession({ name: args.name, ref: args.ref });
861
+ if ('error' in result) {
862
+ return { content: [{ type: 'text', text: result.error }], isError: true };
863
+ }
864
+ this._sessions = result.sessions;
865
+ this._updateMultiSessionToolVisibility();
866
+ return {
867
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
868
+ };
869
+ }, { noTimeout: true }));
870
+ }
871
+ _registerCloseSession() {
872
+ this._mcp.registerTool('close_session', {
873
+ description: 'Close a dynamic worktree session and release its worktree slot back to the pool. ' +
874
+ 'Cannot close static sessions configured in component-explorer.json.',
875
+ inputSchema: {
876
+ name: z.string().describe('Session name to close'),
877
+ },
878
+ annotations: { destructiveHint: true },
879
+ }, async (args) => this._withDaemon(async (daemon) => {
880
+ this._log('info', { type: 'tool-call', tool: 'close_session', name: args.name });
881
+ const result = await daemon.methods.closeSession({ name: args.name });
882
+ if ('error' in result) {
883
+ return { content: [{ type: 'text', text: result.error }], isError: true };
884
+ }
885
+ this._sessions = result.sessions;
886
+ this._updateMultiSessionToolVisibility();
887
+ return {
888
+ content: [{ type: 'text', text: `Session '${args.name}' closed.\n` + JSON.stringify(result.sessions, null, 2) }],
889
+ };
890
+ }));
891
+ }
892
+ _registerUpdateSessionRef() {
893
+ this._mcp.registerTool('update_session_ref', {
894
+ description: 'Change the git ref of an existing dynamic session. ' +
895
+ 'The worktree is checked out to the new ref and Vite\'s HMR handles the incremental update (no server restart). ' +
896
+ 'Fails if the worktree has uncommitted changes — the error will list the dirty files. ' +
897
+ 'The ref can be a branch, tag, commit SHA, or "INDEX" for staged changes.',
898
+ inputSchema: {
899
+ name: z.string().describe('Session name to update'),
900
+ ref: z.string().describe('New git ref: branch, tag, commit SHA, or "INDEX"'),
901
+ },
902
+ }, async (args) => this._withDaemon(async (daemon) => {
903
+ this._log('info', { type: 'tool-call', tool: 'update_session_ref', name: args.name, ref: args.ref });
904
+ const result = await daemon.methods.updateSessionRef({ name: args.name, ref: args.ref });
905
+ if ('error' in result) {
906
+ return { content: [{ type: 'text', text: result.error }], isError: true };
907
+ }
908
+ this._sessions = result.sessions;
909
+ return {
910
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
911
+ };
912
+ }, { noTimeout: true }));
721
913
  }
722
914
  _registerGetUrl() {
723
915
  this._mcp.registerTool('get_url', {
@@ -732,15 +924,24 @@ class ComponentExplorerMcpServer extends Disposable {
732
924
  annotations: { readOnlyHint: true },
733
925
  }, async (args) => {
734
926
  const sessionName = args.sessionName ?? this._defaultSessionName();
735
- const session = this._sessions.find(s => s.name === sessionName);
927
+ let session = this._sessions.find(s => s.name === sessionName);
736
928
  if (!session) {
737
- // Try to refresh sessions if we don't have the requested session
738
929
  const daemon = await this._waitForDaemon();
739
930
  if (daemon) {
740
- await this._refreshSessions();
931
+ try {
932
+ await this._refreshSessions();
933
+ }
934
+ catch (e) {
935
+ if (isPipeConnectionError(e)) {
936
+ this._handleDisconnect();
937
+ }
938
+ else {
939
+ throw e;
940
+ }
941
+ }
741
942
  }
742
- const refreshedSession = this._sessions.find(s => s.name === sessionName);
743
- if (!refreshedSession) {
943
+ session = this._sessions.find(s => s.name === sessionName);
944
+ if (!session) {
744
945
  return {
745
946
  content: [{
746
947
  type: 'text',
@@ -750,8 +951,7 @@ class ComponentExplorerMcpServer extends Disposable {
750
951
  };
751
952
  }
752
953
  }
753
- const resolved = session ?? this._sessions.find(s => s.name === sessionName);
754
- const baseUrl = resolved && !resolved.isLoading ? resolved.serverUrl : undefined;
954
+ const baseUrl = session && !session.isLoading ? session.serverUrl : undefined;
755
955
  if (!baseUrl) {
756
956
  return {
757
957
  content: [{
@@ -788,6 +988,156 @@ class ComponentExplorerMcpServer extends Disposable {
788
988
  };
789
989
  });
790
990
  }
991
+ _registerCheckStability() {
992
+ this._mcp.registerTool('check_stability', {
993
+ description: 'Check rendering stability of fixtures. Each fixture is unmounted, re-mounted, and screenshotted 3 times (~3s per fixture). ' +
994
+ 'Returns results directly if finished within ~10s, otherwise returns a taskId for polling via check_task. ' +
995
+ 'When returning a taskId, includes partial results collected so far.',
996
+ inputSchema: {
997
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
998
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
999
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
1000
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
1001
+ },
1002
+ annotations: { readOnlyHint: true },
1003
+ }, async (args) => this._withDaemon(async (daemon) => {
1004
+ const sessionName = args.sessionName ?? this._defaultSessionName();
1005
+ this._log('debug', { type: 'tool-call', tool: 'check_stability', sessionName, fixtureIdPattern: args.fixtureIdPattern, labelPattern: args.labelPattern });
1006
+ return this._withSourceTreeRetry(async () => {
1007
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
1008
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
1009
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
1010
+ if ('error' in filtered) {
1011
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
1012
+ }
1013
+ const fixtures = filtered.fixtures;
1014
+ this._log('info', { type: 'check-stability-start', total: fixtures.length, filtered: allFixtures.length - fixtures.length });
1015
+ const task = this._taskManager.startTask(async (report, signal) => {
1016
+ const results = [];
1017
+ report({ completed: 0, total: fixtures.length, partialResult: results });
1018
+ for (let i = 0; i < fixtures.length; i++) {
1019
+ if (signal.aborted) {
1020
+ break;
1021
+ }
1022
+ const fixture = fixtures[i];
1023
+ this._log('info', { type: 'check-stability-progress', fixtureId: fixture.fixtureId, index: i + 1, total: fixtures.length });
1024
+ const result = await daemon.methods.screenshots.take({
1025
+ fixtureId: fixture.fixtureId,
1026
+ sessionName,
1027
+ sourceTreeId,
1028
+ includeImage: false,
1029
+ stabilityCheck: true,
1030
+ });
1031
+ const r = result;
1032
+ results.push({
1033
+ fixtureId: fixture.fixtureId,
1034
+ isStable: r.isStable ?? true,
1035
+ screenshots: r.stabilityScreenshots?.map(s => ({ hash: s.hash, delayMs: s.delayMs })) ?? [],
1036
+ });
1037
+ report({ completed: i + 1, total: fixtures.length, partialResult: results });
1038
+ }
1039
+ const stable = results.filter(r => r.isStable).length;
1040
+ return {
1041
+ fixtures: results,
1042
+ summary: { total: results.length, stable, unstable: results.length - stable },
1043
+ };
1044
+ });
1045
+ const waited = await this._taskManager.waitForTask(task.id, 10_000);
1046
+ if (!waited) {
1047
+ return { content: [{ type: 'text', text: 'Error: task disappeared' }], isError: true };
1048
+ }
1049
+ if (waited.done) {
1050
+ return {
1051
+ content: [{ type: 'text', text: JSON.stringify(waited.result, null, 2) }],
1052
+ };
1053
+ }
1054
+ const partial = waited.progress.partialResult;
1055
+ this._taskLastReportedIndex.set(task.id, partial.length);
1056
+ return {
1057
+ content: [{
1058
+ type: 'text',
1059
+ text: JSON.stringify({
1060
+ taskId: task.id,
1061
+ status: 'running',
1062
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1063
+ elapsedMs: waited.elapsedMs,
1064
+ results: partial,
1065
+ }, null, 2),
1066
+ }],
1067
+ };
1068
+ });
1069
+ }));
1070
+ }
1071
+ _registerCheckTask() {
1072
+ this._mcp.registerTool('check_task', {
1073
+ description: 'Check on a running task. Waits up to ~2s for completion; if still running, returns progress and new results since last check.',
1074
+ inputSchema: {
1075
+ taskId: z.string().describe('The task ID returned by a previous tool call'),
1076
+ },
1077
+ annotations: { readOnlyHint: true },
1078
+ }, async (args) => {
1079
+ const waited = await this._taskManager.waitForTask(args.taskId, 2_000);
1080
+ if (!waited) {
1081
+ this._taskLastReportedIndex.delete(args.taskId);
1082
+ return {
1083
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1084
+ isError: true,
1085
+ };
1086
+ }
1087
+ if (waited.done) {
1088
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1089
+ this._taskLastReportedIndex.delete(args.taskId);
1090
+ const fullResult = waited.result;
1091
+ const newResults = fullResult.fixtures.slice(lastIndex);
1092
+ return {
1093
+ content: [{
1094
+ type: 'text',
1095
+ text: JSON.stringify({
1096
+ status: 'done',
1097
+ newResults,
1098
+ summary: fullResult.summary,
1099
+ }, null, 2),
1100
+ }],
1101
+ };
1102
+ }
1103
+ const partial = waited.progress.partialResult;
1104
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1105
+ const newResults = partial.slice(lastIndex);
1106
+ this._taskLastReportedIndex.set(args.taskId, partial.length);
1107
+ return {
1108
+ content: [{
1109
+ type: 'text',
1110
+ text: JSON.stringify({
1111
+ taskId: args.taskId,
1112
+ status: 'running',
1113
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1114
+ elapsedMs: waited.elapsedMs,
1115
+ newResults,
1116
+ }, null, 2),
1117
+ }],
1118
+ };
1119
+ });
1120
+ }
1121
+ _registerCancelTask() {
1122
+ this._mcp.registerTool('cancel_task', {
1123
+ description: 'Cancel a running task',
1124
+ inputSchema: {
1125
+ taskId: z.string().describe('The task ID to cancel'),
1126
+ },
1127
+ }, async (args) => {
1128
+ const task = this._taskManager.getTask(args.taskId);
1129
+ if (!task) {
1130
+ return {
1131
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1132
+ isError: true,
1133
+ };
1134
+ }
1135
+ this._taskManager.removeTask(args.taskId);
1136
+ return {
1137
+ content: [{ type: 'text', text: `Task '${args.taskId}' cancelled.` }],
1138
+ };
1139
+ });
1140
+ }
791
1141
  }
792
1142
 
793
1143
  export { ComponentExplorerMcpServer, DaemonConnection };