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.
- package/package.json +13 -3
- package/src/bridge.js +57 -5
- package/src/client-commands.js +60 -28
- package/src/client.js +118 -65
- package/src/index.js +1 -1
- package/src/tools-agent.js +290 -5
- package/src/tools-extended.js +81 -19
- package/src/tools.js +164 -8
- package/src/vite-plugin.js +45 -12
package/src/tools-agent.js
CHANGED
|
@@ -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 () =>
|
|
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
|
-
|
|
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
|
}
|
package/src/tools-extended.js
CHANGED
|
@@ -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
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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 policy — full 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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 = [];
|