what-devtools-mcp 0.8.4 → 0.11.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.
@@ -262,7 +262,7 @@ const LINT_RULES = [
262
262
  code: 'ERR_MISSING_CLEANUP',
263
263
  message: `Effect sets up ${resource} but does not return a cleanup function — memory leak risk.`,
264
264
  line,
265
- suggestedFix: `Return a cleanup function: return () => remove${resource === 'event listener' ? 'EventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
265
+ suggestedFix: `Return a cleanup function: return () => ${resource === 'event listener' ? 'removeEventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
266
266
  });
267
267
  }
268
268
  }
@@ -431,6 +431,136 @@ const LINT_RULES = [
431
431
  return issues;
432
432
  },
433
433
  },
434
+ {
435
+ // -----------------------------------------------------------------------
436
+ // destructured-props-lose-reactivity
437
+ //
438
+ // What Framework components run ONCE — the function body is not re-run on
439
+ // prop change. The reactive props proxy auto-tracks reads via `props.foo`,
440
+ // but `const { foo } = props` snapshots the current value and detaches it
441
+ // from the proxy. Subsequent updates from the parent are invisible.
442
+ // -----------------------------------------------------------------------
443
+ id: 'destructured-props-lose-reactivity',
444
+ code: 'ERR_DESTRUCTURED_PROPS',
445
+ severity: 'warning',
446
+ test(code) {
447
+ const issues = [];
448
+ // Find component functions whose first param is `props` (any binding).
449
+ // We support both `function Foo(props) {` and `const Foo = (props) =>`.
450
+ const componentPatterns = [
451
+ /function\s+([A-Z]\w*)\s*\(\s*(\w+)\s*\)\s*\{/g,
452
+ /(?:const|let)\s+([A-Z]\w*)\s*=\s*\(\s*(\w+)\s*\)\s*=>/g,
453
+ ];
454
+ for (const pattern of componentPatterns) {
455
+ let compMatch;
456
+ while ((compMatch = pattern.exec(code)) !== null) {
457
+ const propsBinding = compMatch[2];
458
+ // Skip if the param is already destructured at the signature
459
+ // (that's a separate, more legible smell; we focus on body-level
460
+ // destructuring inside the component).
461
+ if (propsBinding === 'props' || /^[a-z]/.test(propsBinding)) {
462
+ // Walk forward and find the component body extent (braces).
463
+ const startIdx = compMatch.index + compMatch[0].length;
464
+ let braceDepth = 1;
465
+ let bodyEnd = startIdx;
466
+ for (let i = startIdx; i < code.length && braceDepth > 0; i++) {
467
+ if (code[i] === '{') braceDepth++;
468
+ if (code[i] === '}') braceDepth--;
469
+ bodyEnd = i;
470
+ }
471
+ const body = code.slice(startIdx, bodyEnd);
472
+ // Find: const|let { ... } = props (the actual binding name).
473
+ const destructPattern = new RegExp(
474
+ `(?:const|let)\\s*\\{([^}]+)\\}\\s*=\\s*${propsBinding}\\b`,
475
+ 'g'
476
+ );
477
+ let dm;
478
+ while ((dm = destructPattern.exec(body)) !== null) {
479
+ const fields = dm[1].split(',').map(s => s.trim().split(/[:=]/)[0].trim()).filter(Boolean);
480
+ const line = code.slice(0, startIdx + dm.index).split('\n').length;
481
+ issues.push({
482
+ severity: 'warning',
483
+ code: 'ERR_DESTRUCTURED_PROPS',
484
+ message: `Destructuring '${propsBinding}' in the component body snapshots props and loses reactivity. Components run ONCE — '${propsBinding}.${fields[0] || 'foo'}' tracks via the props proxy, but '{ ${fields.join(', ')} } = ${propsBinding}' does not.`,
485
+ line,
486
+ suggestedFix: `Read props directly inside JSX or effects: \`${propsBinding}.${fields[0] || 'foo'}\` — or wrap each in an accessor: const ${fields[0] || 'foo'} = () => ${propsBinding}.${fields[0] || 'foo'}.`,
487
+ });
488
+ }
489
+ }
490
+ }
491
+ }
492
+ return issues;
493
+ },
494
+ },
495
+ {
496
+ // -----------------------------------------------------------------------
497
+ // module-scope-signal-missing-name
498
+ //
499
+ // Module-scope signals are global state. Without a debug name (second arg
500
+ // to signal()), they appear as `signal_42` in devtools and what_signals,
501
+ // making cross-tool debugging much harder. This is a hint, not an error.
502
+ // -----------------------------------------------------------------------
503
+ id: 'module-scope-signal-missing-name',
504
+ code: 'HINT_SIGNAL_MISSING_NAME',
505
+ severity: 'info',
506
+ test(code) {
507
+ const issues = [];
508
+ // Look for signal/computed declarations at the top level — i.e., not
509
+ // indented (or only minimally) and not inside a function. A simple but
510
+ // reliable heuristic: lines that match `^(?:export\s+)?(?:const|let)
511
+ // \s+\w+\s*=\s*(signal|computed)\s*\(` AND the opening call has only
512
+ // one argument (no comma at the same paren depth before the close).
513
+ const lines = code.split('\n');
514
+ const declRe = /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(signal|computed)\s*\(/;
515
+ // Track simple brace depth so we skip signals declared inside a function.
516
+ let depth = 0;
517
+ for (let i = 0; i < lines.length; i++) {
518
+ const line = lines[i];
519
+ // Pre-line depth determines whether we're at module scope.
520
+ const wasModuleScope = depth === 0;
521
+ // Update depth after — we want to evaluate the line at its starting scope.
522
+ for (let j = 0; j < line.length; j++) {
523
+ if (line[j] === '{') depth++;
524
+ else if (line[j] === '}') depth = Math.max(0, depth - 1);
525
+ }
526
+ if (!wasModuleScope) continue;
527
+ const m = line.match(declRe);
528
+ if (!m) continue;
529
+ const sigName = m[1];
530
+ const kind = m[2];
531
+ // Reconstruct the full call args by walking parens forward.
532
+ const callStart = m.index + m[0].length; // position right after the opening '('
533
+ // The match is on `line` only; rebuild full call across lines.
534
+ let scan = code.indexOf(line, 0);
535
+ // Find absolute position of the opening paren of signal/computed(...)
536
+ const lineStart = code.split('\n').slice(0, i).join('\n').length + (i > 0 ? 1 : 0);
537
+ const openIdx = lineStart + callStart - 1; // points at the '('
538
+ // Walk to matching close.
539
+ let d = 1, j = openIdx + 1, commasAtDepth1 = 0;
540
+ for (; j < code.length && d > 0; j++) {
541
+ const ch = code[j];
542
+ if (ch === '(') d++;
543
+ else if (ch === ')') d--;
544
+ else if (ch === ',' && d === 1) commasAtDepth1++;
545
+ // Treat string literals naively — bail out of this check if we see a
546
+ // backtick, since template literals make this regex unreliable.
547
+ }
548
+ // For signal(), only one arg means no debug name.
549
+ // For computed(), arity is always 1 — skip; debug name is via .debugName?
550
+ if (kind === 'signal' && commasAtDepth1 === 0) {
551
+ issues.push({
552
+ severity: 'info',
553
+ code: 'HINT_SIGNAL_MISSING_NAME',
554
+ message: `Module-scope signal '${sigName}' has no debug name. It will appear as 'signal_N' in devtools and what_signals — agents and humans both find it harder to trace.`,
555
+ line: i + 1,
556
+ suggestedFix: `Add a debug name as the second argument: signal(<initial>, '${sigName}').`,
557
+ });
558
+ }
559
+ void scan;
560
+ }
561
+ return issues;
562
+ },
563
+ },
434
564
  ];
435
565
 
436
566
  // --- Scaffold Templates ---
@@ -641,7 +771,7 @@ export function registerAgentTools(server, bridge) {
641
771
  'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
642
772
  {
643
773
  code: z.string().describe('The What Framework code snippet to analyze'),
644
- rules: z.array(z.string()).optional().describe('Specific rule IDs to run (default: all). Options: missing-signal-read, innerhtml-without-html, effect-writes-read-signal, missing-cleanup, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect'),
774
+ rules: z.array(z.string()).optional().describe('Specific rule IDs to run (default: all). Options: missing-signal-read, innerhtml-without-html, effect-writes-read-signal, missing-cleanup, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect, destructured-props-lose-reactivity, module-scope-signal-missing-name'),
645
775
  },
646
776
  async ({ code, rules: ruleFilter }) => {
647
777
  let rulesToRun = LINT_RULES;
@@ -896,7 +1026,13 @@ export function registerAgentTools(server, bridge) {
896
1026
  ? `Performance concerns: ${issues.join('; ')}.`
897
1027
  : `Healthy. ${signals.length} signals, ${effects.length} effects, ${components.length} components. ${memoryStr} estimated.`;
898
1028
 
899
- return ok({
1029
+ // Suppress noisy "every signal has 1 subscriber" output — agents would
1030
+ // chase it as a signal. Only emit largestSubscribers when at least one
1031
+ // signal has 2+ subscribers worth investigating.
1032
+ const maxSubs = largestSubscribers.length > 0
1033
+ ? largestSubscribers[0].subscriberCount
1034
+ : 0;
1035
+ const result = {
900
1036
  summary,
901
1037
  counts: {
902
1038
  signals: signals.length,
@@ -904,7 +1040,6 @@ export function registerAgentTools(server, bridge) {
904
1040
  components: components.length,
905
1041
  },
906
1042
  hotEffects,
907
- largestSubscribers,
908
1043
  eventRate,
909
1044
  memoryEstimate: memoryStr,
910
1045
  memoryBytes: totalEstimate,
@@ -914,7 +1049,9 @@ export function registerAgentTools(server, bridge) {
914
1049
  'Consider using batch() to group signal writes.',
915
1050
  'Use computed() for derived values instead of effects.',
916
1051
  ] : [],
917
- });
1052
+ };
1053
+ if (maxSubs >= 2) result.largestSubscribers = largestSubscribers;
1054
+ return ok(result);
918
1055
  }
919
1056
  );
920
1057
 
@@ -996,4 +1133,152 @@ export function registerAgentTools(server, bridge) {
996
1133
  });
997
1134
  }
998
1135
  );
1136
+
1137
+ // -----------------------------------------------------------------------
1138
+ // Tool 6 — what_record_window
1139
+ //
1140
+ // Opens a sampling window (default 1s, max 30s), captures which effects
1141
+ // re-ran during that window, and returns a ranked list — most-fired first.
1142
+ //
1143
+ // Differs from `what_perf` (cumulative runCount since boot) and from
1144
+ // `what_watch` (raw event log). This one is the one-call "which effects
1145
+ // re-ran during this action?" answer, which is what you want when
1146
+ // profiling a single user interaction (drag-drop, button click, route
1147
+ // change). Pair with `what_set_signal` or a user-driven action to scope
1148
+ // the recording.
1149
+ // -----------------------------------------------------------------------
1150
+
1151
+ server.tool(
1152
+ 'what_record_window',
1153
+ 'Sample which effects re-ran during a time window. Captures runCount before and after a configurable duration, then returns a ranked delta. Use this to identify which effects fire during a specific action (e.g., a drag, a click, or a route change). Default 1000ms.',
1154
+ {
1155
+ duration: z.number().optional().default(1000).describe('Sampling window in ms (default: 1000, max: 30000, min: 50)'),
1156
+ topN: z.number().optional().default(20).describe('Maximum number of effects to return (default: 20)'),
1157
+ includeZero: z.boolean().optional().default(false).describe('Include effects that did not re-run (default: false — only changed effects)'),
1158
+ },
1159
+ async ({ duration, topN, includeZero }) => {
1160
+ if (!bridge.isConnected()) return noConnection('what_record_window');
1161
+
1162
+ const ms = Math.min(Math.max(duration ?? 1000, 50), 30000);
1163
+ const limit = Math.min(Math.max(topN ?? 20, 1), 200);
1164
+
1165
+ // ---- Take baseline ----
1166
+ let baseline;
1167
+ try {
1168
+ baseline = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
1169
+ } catch {
1170
+ baseline = bridge.getSnapshot();
1171
+ }
1172
+ if (!baseline) {
1173
+ return errorResponse('No snapshot available for baseline.', [
1174
+ 'Refresh the browser page so the devtools client registers its state.',
1175
+ ]);
1176
+ }
1177
+
1178
+ const baselineRunCounts = new Map();
1179
+ const baselineEffectMeta = new Map();
1180
+ for (const e of baseline.effects || []) {
1181
+ baselineRunCounts.set(e.id, e.runCount || 0);
1182
+ baselineEffectMeta.set(e.id, {
1183
+ id: e.id,
1184
+ name: e.name || `effect_${e.id}`,
1185
+ componentId: e.componentId,
1186
+ depCount: (e.depSignalIds || e.deps || []).length,
1187
+ });
1188
+ }
1189
+ const baselineEventCount = bridge.getEvents
1190
+ ? bridge.getEvents(Date.now() - 1).length
1191
+ : 0;
1192
+ const startTs = Date.now();
1193
+
1194
+ // ---- Wait for the window ----
1195
+ await new Promise((resolve) => setTimeout(resolve, ms));
1196
+
1197
+ // ---- Take post-window snapshot ----
1198
+ let after;
1199
+ try {
1200
+ after = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
1201
+ } catch {
1202
+ after = bridge.getSnapshot();
1203
+ }
1204
+ if (!after) {
1205
+ return errorResponse('No snapshot available after the recording window.', [
1206
+ 'The browser may have lost connection mid-recording.',
1207
+ ]);
1208
+ }
1209
+
1210
+ // ---- Compute delta ----
1211
+ const ranked = [];
1212
+ const newEffects = [];
1213
+ const seen = new Set();
1214
+ for (const e of after.effects || []) {
1215
+ seen.add(e.id);
1216
+ const before = baselineRunCounts.get(e.id);
1217
+ const nowCount = e.runCount || 0;
1218
+ if (before === undefined) {
1219
+ // Effect was created during the window
1220
+ newEffects.push({
1221
+ id: e.id,
1222
+ name: e.name || `effect_${e.id}`,
1223
+ componentId: e.componentId,
1224
+ runCount: nowCount,
1225
+ depCount: (e.depSignalIds || e.deps || []).length,
1226
+ });
1227
+ continue;
1228
+ }
1229
+ const delta = nowCount - before;
1230
+ if (delta > 0 || includeZero) {
1231
+ ranked.push({
1232
+ id: e.id,
1233
+ name: e.name || baselineEffectMeta.get(e.id)?.name || `effect_${e.id}`,
1234
+ componentId: e.componentId ?? baselineEffectMeta.get(e.id)?.componentId,
1235
+ runs: delta,
1236
+ totalRuns: nowCount,
1237
+ depCount: (e.depSignalIds || e.deps || []).length,
1238
+ });
1239
+ }
1240
+ }
1241
+ const disposedDuring = [];
1242
+ for (const [id, meta] of baselineEffectMeta) {
1243
+ if (!seen.has(id)) disposedDuring.push(meta);
1244
+ }
1245
+
1246
+ ranked.sort((a, b) => b.runs - a.runs);
1247
+ const top = ranked.slice(0, limit);
1248
+
1249
+ const totalRuns = ranked.reduce((sum, e) => sum + e.runs, 0);
1250
+ const distinctEffects = ranked.length;
1251
+ const eventsDuring = bridge.getEvents
1252
+ ? bridge.getEvents(startTs).length
1253
+ : null;
1254
+
1255
+ const summary = totalRuns === 0
1256
+ ? `No effects re-ran during the ${ms}ms window. App is idle (or no reactive state changed).`
1257
+ : `${totalRuns} effect run${totalRuns !== 1 ? 's' : ''} across ${distinctEffects} distinct effect${distinctEffects !== 1 ? 's' : ''} in ${ms}ms.`;
1258
+
1259
+ const nextSteps = [];
1260
+ if (top.length > 0 && top[0].runs >= 10) {
1261
+ nextSteps.push(`Hot effect "${top[0].name}" ran ${top[0].runs} times — inspect with what_dependency_graph({effectId: ${top[0].id}, direction: "upstream"}).`);
1262
+ }
1263
+ if (newEffects.length > 5) {
1264
+ nextSteps.push(`${newEffects.length} effects were created during the window — likely a re-mount cycle. Check what_diff_snapshot for component churn.`);
1265
+ }
1266
+ if (disposedDuring.length > 0 && newEffects.length > 0) {
1267
+ nextSteps.push(`${disposedDuring.length} effects were disposed and ${newEffects.length} were created — component tree is being torn down and rebuilt.`);
1268
+ }
1269
+
1270
+ return ok({
1271
+ summary,
1272
+ windowMs: ms,
1273
+ totalRuns,
1274
+ distinctEffects,
1275
+ topEffects: top,
1276
+ newEffectsCount: newEffects.length,
1277
+ newEffects: newEffects.slice(0, limit),
1278
+ disposedCount: disposedDuring.length,
1279
+ eventsDuring,
1280
+ nextSteps,
1281
+ });
1282
+ }
1283
+ );
999
1284
  }
@@ -70,6 +70,37 @@ export function registerExtendedTools(server, bridge) {
70
70
  };
71
71
  }
72
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Helper: framework-wide signal value preview policy.
75
+ //
76
+ // Goal: agents see the SAME shape across every tool that surfaces a signal
77
+ // value (what_signals, what_dependency_graph, what_signal_trace, …).
78
+ //
79
+ // Policy:
80
+ // - Primitives (number/boolean/null/undefined): return as-is.
81
+ // - Strings: full when small, truncated with ellipsis when long.
82
+ // - Arrays/objects: full structure when its JSON stringification fits
83
+ // under PREVIEW_LIMIT chars; otherwise return a truncated JSON string.
84
+ //
85
+ // Threshold chosen to fit "small list of small items" while protecting
86
+ // dep-graph topology output from token blow-ups on huge structures.
87
+ // ---------------------------------------------------------------------------
88
+ const PREVIEW_LIMIT = 100;
89
+ function previewSignalValue(raw) {
90
+ if (raw == null) return raw;
91
+ const t = typeof raw;
92
+ if (t === 'number' || t === 'boolean') return raw;
93
+ if (t === 'string') return raw.length > PREVIEW_LIMIT ? raw.slice(0, PREVIEW_LIMIT) + '…' : raw;
94
+ if (t === 'object') {
95
+ let json;
96
+ try { json = JSON.stringify(raw); } catch { return String(raw); }
97
+ if (json == null) return String(raw);
98
+ if (json.length <= PREVIEW_LIMIT) return raw;
99
+ return json.slice(0, PREVIEW_LIMIT) + '…';
100
+ }
101
+ return String(raw);
102
+ }
103
+
73
104
  // ---------------------------------------------------------------------------
74
105
  // Helper: get a fresh or cached snapshot
75
106
  // ---------------------------------------------------------------------------
@@ -353,16 +384,10 @@ export function registerExtendedTools(server, bridge) {
353
384
  const id = Number(idStr);
354
385
  if (type === 'signal') {
355
386
  const s = signalMap.get(id);
356
- // Truncate values to avoid token waste graph topology is what matters
357
- const raw = s?.value;
358
- let value;
359
- if (raw == null) value = raw;
360
- else if (typeof raw === 'string') value = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;
361
- else if (Array.isArray(raw)) value = `Array(${raw.length})`;
362
- else if (typeof raw === 'object') {
363
- const json = JSON.stringify(raw);
364
- value = json.length > 80 ? json.slice(0, 80) + '…' : raw;
365
- } else value = raw;
387
+ // Use the framework-wide value-preview policyfull values when
388
+ // small, truncated JSON when large. Keeps output consistent with
389
+ // what_signals so agents see the same shape across tools.
390
+ const value = previewSignalValue(s?.value);
366
391
  nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value });
367
392
  } else {
368
393
  const e = effectMap.get(id);
@@ -403,12 +428,22 @@ export function registerExtendedTools(server, bridge) {
403
428
  async ({ code, timeout }) => {
404
429
  // Allow safe read-only property access without the unsafe flag
405
430
  const trimmed = (code || '').trim();
406
- const isSafeRead = /^[\w.[\]'"]+$/.test(trimmed) && !trimmed.includes('=') ||
407
- /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(trimmed) ||
408
- /^window\.(innerWidth|innerHeight|devicePixelRatio|outerWidth|outerHeight)$/.test(trimmed) ||
409
- /^navigator\.(userAgent|language|platform|onLine|hardwareConcurrency)$/.test(trimmed) ||
410
- /^location\.(href|hostname|pathname|protocol|port|origin)$/.test(trimmed) ||
411
- /^screen\.(width|height|availWidth|availHeight|colorDepth)$/.test(trimmed);
431
+ // Strict safe-read: only allow dotted property access on known safe globals.
432
+ // Each segment must be a simple identifier (no brackets, quotes, or calls).
433
+ const SAFE_GLOBALS = new Set(['document', 'window', 'navigator', 'location', 'screen', 'performance', 'console']);
434
+ const segments = trimmed.split('.');
435
+ const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
436
+ const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
437
+ // Sensitive property paths: reads that exfiltrate secrets/state must
438
+ // require the explicit unsafe flag (document.cookie, window.localStorage,
439
+ // navigator.credentials...). Mirror of SENSITIVE_PATHS in client-commands.js.
440
+ const SENSITIVE_PATHS = new Set([
441
+ 'cookie', 'localStorage', 'sessionStorage', 'indexedDB', 'credentials',
442
+ 'geolocation', 'clipboard', 'serviceWorker', 'caches', 'opener',
443
+ ]);
444
+ const isSafeRead = segments.length >= 2 &&
445
+ SAFE_GLOBALS.has(segments[0]) &&
446
+ segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s) && !SENSITIVE_PATHS.has(s));
412
447
 
413
448
  if (!unsafeEvalEnabled && !isSafeRead) {
414
449
  return errorResponse(
@@ -833,8 +868,19 @@ export function registerExtendedTools(server, bridge) {
833
868
  async ({ path, replace }) => {
834
869
  if (!bridge.isConnected()) return noConnection('what_navigate');
835
870
 
871
+ // Validate URL — reject dangerous protocols
872
+ const trimmedPath = path.trim();
873
+ const normalized = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
874
+ const isRelative = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
875
+ if (!isRelative && !/^https?:/.test(normalized)) {
876
+ return errorResponse(`Blocked navigation to unsafe URL: "${path}"`, [
877
+ 'Only relative paths (/foo, ./bar, #hash, ?query) and http(s) URLs are allowed.',
878
+ 'javascript:, data:, and vbscript: URLs are rejected for security.',
879
+ ]);
880
+ }
881
+
836
882
  try {
837
- const result = await bridge.sendCommand('navigate', { path, replace });
883
+ const result = await bridge.sendCommand('navigate', { path: trimmedPath, replace });
838
884
  if (result.error) {
839
885
  return errorResponse(result.error, [
840
886
  'Check that the path is valid.',
@@ -860,7 +906,7 @@ export function registerExtendedTools(server, bridge) {
860
906
 
861
907
  server.tool(
862
908
  'what_explain',
863
- 'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool.',
909
+ 'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool. Component IDs are ephemeral — they change on mount/unmount, so re-query what_components after any signal write that may have remounted the tree.',
864
910
  {
865
911
  componentId: z.number().describe('Component ID to explain (from what_components)'),
866
912
  includeDOM: z.boolean().optional().default(true).describe('Include rendered DOM output (default: true)'),
@@ -937,8 +983,9 @@ export function registerExtendedTools(server, bridge) {
937
983
  {
938
984
  signalId: z.number().describe('Signal ID to trace (from what_signals)'),
939
985
  depth: z.number().optional().default(2).describe('Causal chain depth — how many levels of effect->signal->effect to trace (default: 2)'),
986
+ auto_watch_ms: z.number().optional().default(500).describe('If no recent writes were captured, briefly listen for events for this many ms before returning (default: 500, set to 0 to disable).'),
940
987
  },
941
- async ({ signalId, depth }) => {
988
+ async ({ signalId, depth, auto_watch_ms }) => {
942
989
  const { snapshot, err } = await freshSnapshot('what_signal_trace');
943
990
  if (err) return err;
944
991
 
@@ -959,6 +1006,21 @@ export function registerExtendedTools(server, bridge) {
959
1006
  if (writers.error) writers = { recentWrites: [], totalWrites: 0, note: writers.error };
960
1007
  } catch {}
961
1008
 
1009
+ // Auto-arm what_watch when no writes have been captured yet. The
1010
+ // signal-writer ring buffer is populated by initEventTracking, which
1011
+ // runs on first extended command. If the caller hits what_signal_trace
1012
+ // first, the buffer is empty even though the browser is producing
1013
+ // events. Listen briefly so the user gets a result on the first try
1014
+ // instead of having to manually chain what_watch -> what_signal_trace.
1015
+ const watchMs = Math.min(Math.max(Number(auto_watch_ms) || 0, 0), 5000);
1016
+ if (watchMs > 0 && (!writers.recentWrites || writers.recentWrites.length === 0)) {
1017
+ await new Promise(r => setTimeout(r, watchMs));
1018
+ try {
1019
+ const refetched = await bridge.sendCommand('get-signal-writers', { signalId }, 5000);
1020
+ if (refetched && !refetched.error) writers = refetched;
1021
+ } catch {}
1022
+ }
1023
+
962
1024
  // Build causal chain
963
1025
  // For each writer effect, find what signals it depends on
964
1026
  const chain = [];