Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Fallback: `xcrun simctl` (iOS) + `adb` (Android) for device lifecycle (boot / in

### MCP Server (cdp-bridge)

**74 tools** exposed via MCP (re-audited 2026-05-18; counted from `trackedTool()` calls in `scripts/cdp-bridge/src/index.ts`). Five conceptual families:
**75 tools** exposed via MCP (re-audited 2026-05-29; counted from `trackedTool()` calls in `scripts/cdp-bridge/src/index.ts`). Five conceptual families:

**CDP tools** — React internals via Chrome DevTools Protocol over WebSocket:
- `cdp_status` — health check with domain capabilities + reconnect state
Expand Down
2 changes: 1 addition & 1 deletion hooks/detect-rn-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ if [ "$has_rn_config" = true ]; then
fi

cat <<'EOF'
React Native project detected. The rn-dev-agent plugin is active with 51 MCP tools.
React Native project detected. The rn-dev-agent plugin is active with 75 MCP tools.

## How to interact with the running app

Expand Down
26 changes: 17 additions & 9 deletions scripts/cdp-bridge/dist/agent-device-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
import { okResult, failResult } from './utils.js';
import { isFastRunnerAvailable, startFastRunner, } from './runners/rn-fast-runner-client.js';
import { refCenter, getScreenRect, clearRefMap } from './fast-runner-ref-map.js';
import { refCenter, getScreenRect, clearRefMap, isRefMapFresh } from './fast-runner-ref-map.js';
import { resolveBundleId } from './project-config.js';
const execFile = promisify(execFileCb);
/**
Expand Down Expand Up @@ -284,22 +284,26 @@ const RN_FAST_RUNNER_COMMANDS = new Set([
export function getCachedScreenRect() {
return getScreenRect();
}
function buildRunIOSArgs(cliArgs, bundleId) {
export function buildRunIOSArgs(cliArgs, bundleId) {
const cmd = cliArgs[0];
const positionals = cliArgs.slice(1).filter((a) => !a.startsWith('--'));
switch (cmd) {
case 'press':
case 'tap': {
const ref = positionals[0];
if (ref && ref.startsWith('@')) {
const center = refCenter(ref);
const center = isRefMapFresh() ? refCenter(ref) : null;
if (!center) {
return { command: 'tap', _staleRef: ref, ...(bundleId ? { bundleId } : {}) };
}
return { command: 'tap', x: center.x, y: center.y, ...(bundleId ? { bundleId } : {}) };
}
const [xS, yS] = positionals;
return { command: 'tap', x: Number(xS), y: Number(yS), ...(bundleId ? { bundleId } : {}) };
const x = Number(xS), y = Number(yS);
if (Number.isNaN(x) || Number.isNaN(y)) {
throw new Error(`buildRunIOSArgs: tap requires a @ref or numeric x, y`);
}
return { command: 'tap', x, y, ...(bundleId ? { bundleId } : {}) };
}
case 'fill':
case 'type': {
Expand All @@ -310,7 +314,7 @@ function buildRunIOSArgs(cliArgs, bundleId) {
const ref = positionals[0];
const text = positionals.slice(1).join(' ');
if (ref && ref.startsWith('@')) {
const center = refCenter(ref);
const center = isRefMapFresh() ? refCenter(ref) : null;
if (!center) {
return { command: 'type', _staleRef: ref, text, ...(bundleId ? { bundleId } : {}) };
}
Expand Down Expand Up @@ -414,7 +418,7 @@ function androidPositionals(cliArgs) {
}
return out;
}
function buildRunAndroidArgs(cliArgs, bundleId) {
export function buildRunAndroidArgs(cliArgs, bundleId) {
const cmd = cliArgs[0];
const positionals = androidPositionals(cliArgs);
const withBundle = bundleId ? { bundleId } : {};
Expand All @@ -423,20 +427,24 @@ function buildRunAndroidArgs(cliArgs, bundleId) {
case 'tap': {
const ref = positionals[0];
if (ref && ref.startsWith('@')) {
const center = refCenter(ref);
const center = isRefMapFresh() ? refCenter(ref) : null;
if (!center)
return { command: 'tap', _staleRef: ref, ...withBundle };
return { command: 'tap', x: center.x, y: center.y, ...withBundle };
}
const [xS, yS] = positionals;
return { command: 'tap', x: Number(xS), y: Number(yS), ...withBundle };
const x = Number(xS), y = Number(yS);
if (Number.isNaN(x) || Number.isNaN(y)) {
throw new Error(`buildRunAndroidArgs: tap requires a @ref or numeric x, y`);
}
return { command: 'tap', x, y, ...withBundle };
}
case 'fill':
case 'type': {
const ref = positionals[0];
const text = positionals.slice(1).join(' ');
if (ref && ref.startsWith('@')) {
const center = refCenter(ref);
const center = isRefMapFresh() ? refCenter(ref) : null;
if (!center)
return { command: 'type', _staleRef: ref, text, ...withBundle };
return { command: 'type', x: center.x, y: center.y, text, ...withBundle };
Expand Down
28 changes: 25 additions & 3 deletions scripts/cdp-bridge/dist/cdp/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ discoverFn = discover) {
throw new Error(selectionWarning ?? 'No matching CDP targets found.');
}
let connectedTarget = null;
for (const candidate of sorted) {
for (let idx = 0; idx < sorted.length; idx++) {
const candidate = sorted[idx];
const isLast = idx === sorted.length - 1;
try {
await connectToTarget(ctx, candidate);
const devCheck = await ctx.evaluate('typeof __DEV__ !== "undefined" && __DEV__ === true');
Expand All @@ -76,7 +78,7 @@ discoverFn = discover) {
break;
}
console.error(`CDP: target ${candidate.id} (${candidate.title}) has __DEV__=${devCheck.value}, skipping`);
if (sorted.indexOf(candidate) < sorted.length - 1) {
if (!isLast) {
closeAndResetWs(ctx);
ctx.setState('disconnected');
ctx.setHelpersInjected(false);
Expand All @@ -87,7 +89,7 @@ discoverFn = discover) {
connectedTarget = candidate;
}
catch (err) {
if (sorted.indexOf(candidate) < sorted.length - 1)
if (!isLast)
continue;
throw err;
}
Expand Down Expand Up @@ -210,15 +212,34 @@ function connectWs(ctx, url) {
maxPayload: 100 * 1024 * 1024,
});
let settled = false;
// Backstop: handshakeTimeout should emit 'error', but if the socket ever
// wedges without firing open/error/close it would leak with its listeners.
// Terminate it after a grace window so it can't linger.
const guard = setTimeout(() => {
if (settled)
return;
settled = true;
try {
ws.terminate();
}
catch { /* already gone */ }
reject(new Error('WebSocket connect timed out'));
}, 7000);
ws.on('open', () => {
settled = true;
clearTimeout(guard);
ctx.setWs(ws);
ctx.setState('connected');
resolve();
});
ws.on('error', (err) => {
if (!settled) {
settled = true;
clearTimeout(guard);
try {
ws.terminate();
}
catch { /* already closing */ }
reject(err);
}
else {
Expand All @@ -231,6 +252,7 @@ function connectWs(ctx, url) {
ws.on('close', (code) => {
if (!settled) {
settled = true;
clearTimeout(guard);
reject(new Error(`WebSocket closed before connecting: ${code}`));
return;
}
Expand Down
6 changes: 5 additions & 1 deletion scripts/cdp-bridge/dist/cdp/multiplexer.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ export class CDPMultiplexer {
return;
}
const route = this.routingTable.get(upstreamId);
if (!route)
if (!route) {
// No routing entry for this upstream id — a late reply after the entry was
// swept, or an id-space collision. Log it (debug) so it isn't silent.
logger.debug(this.opts.logTag, `dropping Hermes reply with unrouted id=${upstreamId}`);
return;
}
this.routingTable.delete(upstreamId);
m.id = route.consumerOriginalId;
const rewritten = JSON.stringify(m);
Expand Down
2 changes: 1 addition & 1 deletion scripts/cdp-bridge/dist/domain/path-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class PathTraversalError extends Error {
}
// ── Action ID validation ────────────────────────────────────────────
// Action IDs flow into `.rn-agent/actions/<id>.yaml` and the matching
// sidecar `<id>.action.json`. They must start with an alphanumeric
// sidecar `.rn-agent/state/<id>.state.json`. They must start with an alphanumeric
// character, then accept hyphen/underscore/dot/alphanumeric. We
// deliberately exclude `..`, `/`, `\`, and control characters so the
// ID can never escape its parent directory.
Expand Down
68 changes: 42 additions & 26 deletions scripts/cdp-bridge/dist/domain/repair-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,24 +136,35 @@ export function replaceIdSelector(body, oldId, newId) {
const lines = body.split('\n');
const out = [];
let replacements = 0;
// Match the line-trimmed `id: <quoted-or-bare><oldId><quoted-or-bare>`.
// Capture leading whitespace + quote style so we can preserve them.
// Allowed quotes: `"X"`, `'X'`, or bare `X` if it has no spaces.
// Match the line-trimmed `id: <quoted-or-bare><oldId>`, preserving leading
// whitespace, quote style, and any trailing `# comment`. Three explicit
// shapes (double / single / bare) keep this in lockstep with
// extractIdSelectors and the maestro-error-parser matched-quote grammar:
// a double-quoted value may contain `'` and vice-versa.
const escapedOld = oldId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`^(\\s*)id:\\s*("?)${escapedOld}("?)\\s*$`);
const reSingle = new RegExp(`^(\\s*)id:\\s*(')${escapedOld}(')\\s*$`);
const dq = new RegExp(`^(\\s*)id:\\s*"${escapedOld}"(\\s*(?:#.*)?)$`);
const sq = new RegExp(`^(\\s*)id:\\s*'${escapedOld}'(\\s*(?:#.*)?)$`);
const bare = new RegExp(`^(\\s*)id:\\s*${escapedOld}(\\s*(?:#.*)?)$`);
for (const line of lines) {
const m = line.match(re) ?? line.match(reSingle);
let m = line.match(dq);
if (m) {
const indent = m[1];
const quoteOpen = m[2] ?? '';
const quoteClose = m[3] ?? '';
out.push(`${indent}id: ${quoteOpen}${newId}${quoteClose}`);
out.push(`${m[1]}id: "${newId}"${m[2]}`);
replacements++;
continue;
}
else {
out.push(line);
m = line.match(sq);
if (m) {
out.push(`${m[1]}id: '${newId}'${m[2]}`);
replacements++;
continue;
}
m = line.match(bare);
if (m) {
out.push(`${m[1]}id: ${newId}${m[2]}`);
replacements++;
continue;
}
out.push(line);
}
return { body: out.join('\n'), replacements };
}
Expand All @@ -168,22 +179,27 @@ export function replaceIdSelector(body, oldId, newId) {
export function extractIdSelectors(body) {
const out = [];
const lines = body.split('\n');
// Issue #102 A2: previous regex `[^"'\\n]*?` greedily included
// trailing inline comments — `id: foo-bar # human note` captured
// `foo-bar # human note` as the testID. Hand-authored YAML
// sometimes has these comments; the bug fails safely (no fuzzy
// match) but produces a UX issue. Fix: strip trailing
// `\s+#.*` from the captured group before pushing. Also tighten
// the bare-form regex to reject `#` directly so quoted forms are
// unaffected.
const re = /^\s*id:\s*"?'?([^"'\s][^"'\n]*?)"?'?\s*$/;
// Mirror the maestro-error-parser matched-quote grammar (PR #115) so the
// failure parser and this extractor agree on what a testID is. Previously
// the char class `[^"'\s]` rejected any testID containing a quote (e.g.
// `user's-task`), so attemptRepair's gate short-circuited to
// 'no-stale-selector' and auto-repair silently no-op'd for ids the parser
// had correctly extracted. Three explicit shapes:
// id: "value" — value may contain '
// id: 'value' — value may contain "
// id: value — bare; strip a trailing ` # comment` (Issue #102 A2)
const dq = /^\s*id:\s*"([^"\n]*)"\s*(?:#.*)?$/;
const sq = /^\s*id:\s*'([^'\n]*)'\s*(?:#.*)?$/;
const bare = /^\s*id:\s*([^"'#\s][^#\n]*?)\s*(?:#.*)?$/;
for (const line of lines) {
const m = line.match(re);
const m = line.match(dq) ?? line.match(sq);
if (m) {
// Trim any trailing `<space>#<rest-of-line>` that the lazy
// capture could not exclude.
const cleaned = m[1].replace(/\s+#.*$/, '');
out.push(cleaned);
out.push(m[1]);
continue;
}
const b = line.match(bare);
if (b) {
out.push(b[1].trimEnd());
}
}
return out;
Expand Down
8 changes: 5 additions & 3 deletions scripts/cdp-bridge/dist/domain/reusable-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,12 @@ export function appendRepairRecord(state, record) {
* Check whether a self-repair attempt is within the rolling-24h budget.
* Pure function — `now` is injectable for tests.
*/
export function repairBudgetAvailable(state, now = () => new Date()) {
export function recentRepairCount(state, now = () => new Date()) {
const cutoff = now().getTime() - 24 * 60 * 60 * 1000;
const recent = state.repairHistory.filter((r) => new Date(r.timestamp).getTime() >= cutoff);
return recent.length < REPAIR_BUDGET.ATTEMPTS_PER_24H;
return state.repairHistory.filter((r) => new Date(r.timestamp).getTime() >= cutoff).length;
}
export function repairBudgetAvailable(state, now = () => new Date()) {
return recentRepairCount(state, now) < REPAIR_BUDGET.ATTEMPTS_PER_24H;
}
/**
* Promote `experimental → active` after a clean replay.
Expand Down
12 changes: 12 additions & 0 deletions scripts/cdp-bridge/dist/domain/sidecar-io.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ export function loadOrInitSidecar(yamlFilePath, now = () => new Date()) {
Array.isArray(parsed.runHistory) &&
Array.isArray(parsed.repairHistory) &&
typeof parsed.stats === 'object') {
// A sidecar missing lastSeenMtimeMs (e.g. written before the field
// existed) would silently disable the human-edit guard, since
// yamlEditedSinceLastSeen compares against undefined. Re-seed just that
// field from the YAML's mtime — preserves run/repair history.
if (typeof parsed.lastSeenMtimeMs !== 'number') {
try {
parsed.lastSeenMtimeMs = statSync(yamlFilePath).mtimeMs;
}
catch {
parsed.lastSeenMtimeMs = 0;
}
}
return parsed;
}
// Fall through — corrupted; return fresh.
Expand Down
28 changes: 18 additions & 10 deletions scripts/cdp-bridge/dist/experience/compact.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ export function groupFailures(events) {
if (isGhostRecovery) {
stats.ghost_recovered++;
}
else if (event.result === 'FAIL' || event.result === 'ERROR') {
stats.failed++;
}
else {
stats.passed++;
// The filter above only admits fails/ghost-recoveries, so a non-ghost
// event here is always FAIL/ERROR — the prior `else { passed++ }` branch
// was unreachable (stats.passed stayed 0 forever).
stats.failed++;
}
if (event.family_id && !stats.family_id) {
stats.family_id = event.family_id;
Expand All @@ -96,19 +96,27 @@ export function groupFailures(events) {
* Generate candidate heuristics from failure stats.
* Only generates for patterns with >= MIN_OCCURRENCES and >= MIN_SUCCESS_RATE.
*/
// Files are written as `candidate-<id>.md` where id is RS-C<n>/FP-C<n>
// (writeCandidateFile lowercases it → `candidate-rs-c1.md`). The prior
// `/candidate-(\d+)/` never matched (a letter always follows the dash), so the
// counter stayed 1 and each compaction cycle clobbered earlier candidate files.
export function nextCandidateId(existingFilenames) {
let nextId = 1;
for (const f of existingFilenames) {
const match = f.match(/candidate-(?:rs|fp)-c(\d+)/i);
if (match)
nextId = Math.max(nextId, parseInt(match[1], 10) + 1);
}
return nextId;
}
export function generateCandidates(stats, events) {
const candidates = [];
let nextId = 1;
// Find the highest existing candidate ID
const candidatesDir = join(AGENT_DIR, 'candidates');
if (existsSync(candidatesDir)) {
try {
const existing = readdirSync(candidatesDir).filter(f => f.startsWith('candidate-'));
for (const f of existing) {
const match = f.match(/candidate-(\d+)/);
if (match)
nextId = Math.max(nextId, parseInt(match[1], 10) + 1);
}
nextId = nextCandidateId(readdirSync(candidatesDir).filter(f => f.startsWith('candidate-')));
}
catch { /* best-effort */ }
}
Expand Down
10 changes: 9 additions & 1 deletion scripts/cdp-bridge/dist/experience/fingerprint.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ export function captureFingerprint() {
fp.rn_version = extractVersion(allDeps, 'react-native');
fp.expo_sdk = extractVersion(allDeps, 'expo');
fp.key_deps = extractKeyDeps(allDeps);
fp.engine = allDeps['hermes-engine'] ? 'hermes' : (allDeps['jsc-android'] ? 'jsc' : 'hermes');
// jsc-android means the project opted out of Hermes. Otherwise Hermes is the
// modern RN default (bundled inside react-native since 0.70, so there is no
// standalone hermes-engine dep to key on). With no RN signal at all, leave it
// null rather than blindly claiming 'hermes'.
fp.engine = allDeps['jsc-android']
? 'jsc'
: (allDeps['hermes-engine'] || allDeps['react-native'])
? 'hermes'
: null;
const appJson = readJson(join(root, 'app.json'));
if (appJson) {
const expo = appJson.expo;
Expand Down
Loading
Loading