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
4 changes: 4 additions & 0 deletions apps/claude/skills/mobile-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ can show a simulator or emulator with Claude Code's own simulator pane instead:
tool with `action: "attach"`. Otherwise use `device_screenshot` when the user needs
to see the screen.

## App Intelligence

App Intelligence statically discovers Expo Router and React Navigation screens and combines their source dependencies for iOS and Android; it does not require App Flow or a running device. When the user asks to see the app map, call `mobile_open_app_intel` and open its `browserPanel.url` like the simulator panel. Generate or refresh with `mobile_generate_app_intel`, passing `projectPath` when no project is remembered. For a screen or file dependency question, call `mobile_app_intel` with `operation: "screen"` or `"file"`, start with depth 1, and paginate only when `nextOffset` is present.

## Choosing a device

Use the task and the app project to choose the platform. Follow the user's device
Expand Down
82 changes: 82 additions & 0 deletions apps/codex-plugin/test/workspace-opening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,85 @@ for (const notification of ["input", "result"] as const) test(`workspace applies
const unexpected = calls.filter(name => name.endsWith("_stream_session") || name.startsWith("mobile_boot"));
assert.deepEqual(unexpected, []);
});

test("mobile_open_app_intel's appIntelScreen selects that screen in the workspace", async t => {
const dom = new JSDOM('<html data-view="panel"><head><meta name="mobile-dev-environment" content="development"><meta name="mobile-dev-telemetry" content="off"></head><body><div id="root"></div></body></html>', {
pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/",
});
const window = dom.window;
window.Math.random = () => 0;
window.console.debug = () => {};
Object.defineProperty(window.performance, "getEntriesByType", { value: () => [] });
Object.defineProperty(window.performance, "getEntries", { value: () => [] });
window.fetch = async () => new Response("", { status: 200 });
window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
window.HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} });
Object.defineProperty(window.HTMLElement.prototype, "clientWidth", { get: () => 400 });
Object.defineProperty(window.HTMLElement.prototype, "clientHeight", { get: () => 800 });
const snapshot = {
formatVersion: 1,
meta: { projectId: "p", generation: "g", analyzedAt: "2026-11-04T00:00:00Z", analyzerVersion: "t", sourceFingerprint: "f" },
files: { "app/index.tsx": { fingerprint: "x", kind: "source", reach: 1 }, "app/profile.tsx": { fingerprint: "x", kind: "source", reach: 1 } },
packages: {},
screens: {
home: { title: "Home", route: "/", source: "app/index.tsx", layouts: [] },
profile: { title: "Profile", route: "/profile", source: "app/profile.tsx", layouts: [] },
},
edges: [
{ from: "screen:home", to: "file:app/index.tsx", kind: "source", platforms: ["ios", "android"] },
{ from: "screen:profile", to: "file:app/profile.tsx", kind: "source", platforms: ["ios", "android"] },
],
diagnostics: [],
indexes: { screens: { home: ["0"], profile: ["1"] }, incoming: { "file:app/index.tsx": [0], "file:app/profile.tsx": [1] }, outgoing: { "screen:home": [0], "screen:profile": [1] } },
};
let tornDown = false;
function send(message: object) {
const event = new window.MessageEvent("message", { data: { jsonrpc: "2.0", ...message }, source: window });
window.dispatchEvent(event);
}
function result(id: number | string, value: object) { send({ id, result: value }); }
let initialized = false;
const calls: string[] = [];
window.postMessage = message => {
if (message.method === "ui/initialize") {
result(message.id, { protocolVersion: "2026-01-26", hostInfo: { name: "test", version: "1" }, hostCapabilities: { serverTools: {}, serverResources: {} }, hostContext: {} });
} else if (message.method === "ui/notifications/initialized") {
initialized = true;
} else if (message.method === "tools/call") {
const name = message.params.name;
calls.push(name);
if (name === "mobile_app_intel_snapshot") result(message.id, { content: [], structuredContent: { projectPath: "/app", snapshot, recents: ["/app"] } });
else if (name === "mobile_list_simulators") result(message.id, { content: [], structuredContent: { connected: true, managed: false, baseUrl: "", devices: [] } });
else result(message.id, { content: [] });
} else if (message.id === "teardown") {
tornDown = true;
} else if (message.id !== undefined) {
result(message.id, {});
}
};
async function until(condition: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
if (condition()) return;
await setTimeout(5);
}
assert.fail("The workspace did not reach the expected host state.");
}
t.after(async () => {
send({ id: "teardown", method: "ui/resource-teardown", params: {} });
await until(() => tornDown);
window.close();
});
window.eval(built.outputFiles[0].text);
await until(() => initialized);
send({ method: "ui/notifications/tool-result", params: { content: [], structuredContent: { panelTarget: { workspaceTool: "app-intel", appIntelScreen: "profile" } } } });
// The entry-point rail starts collapsed; open it to see the selected screen.
await until(() => window.document.querySelector('button[aria-controls="app-intel-entries"]') !== null);
(window.document.querySelector('button[aria-controls="app-intel-entries"]') as HTMLButtonElement).click();
await until(() => window.document.querySelector('[data-entry="profile"]') !== null);
await setTimeout(10);
const selected = window.document.querySelector('[data-entry="profile"]');
assert.equal(selected?.getAttribute("aria-current"), "true");
const home = window.document.querySelector('[data-entry="home"]');
assert.equal(home?.getAttribute("aria-current"), "false");
assert.deepEqual(calls.filter(name => name.startsWith("mobile_list_")), [], "App Intelligence does not start device discovery");
});
2 changes: 1 addition & 1 deletion apps/codex-plugin/tooling/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ try {
assert.equal(resource.contents[0].mimeType, "text/html;profile=mcp-app");
assert.ok(resource.contents[0].text.includes('id="root"'));
// React creates these controls from the bundled script after the app mounts.
for (const control of ["canvas", "logs-drawer", "log-chat", "tool-select", "tool-performance", "tool-tracesift", "performance-drawer", "performance-app", "platform-select", "simulator-panels"]) {
for (const control of ["canvas", "logs-drawer", "log-chat", "tool-select", "tool-performance", "tool-app-intel", "app-intelligence-panel", "tool-tracesift", "performance-drawer", "performance-app", "platform-select", "simulator-panels"]) {
assert.ok(resource.contents[0].text.includes(control), `Missing bundled UI control: ${control}`);
}
assert.ok(!resource.contents[0].text.includes("<!-- APP_SCRIPT -->"));
Expand Down
3 changes: 3 additions & 0 deletions apps/mac/Sources/MobileDev/Tool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ enum Tool: String, CaseIterable, Identifiable {
case logs
case performance
case storage
case appIntel = "app-intel"
case tracesift

var id: Self { self }
Expand All @@ -12,6 +13,7 @@ enum Tool: String, CaseIterable, Identifiable {
case .logs: "Logs"
case .performance: "Performance"
case .storage: "Storage"
case .appIntel: "App Intel"
case .tracesift: "TraceSift"
}
}
Expand All @@ -21,6 +23,7 @@ enum Tool: String, CaseIterable, Identifiable {
case .logs: "terminal"
case .performance: "waveform.path.ecg"
case .storage: "cylinder.split.1x2"
case .appIntel: "point.3.connected.trianglepath.dotted"
case .tracesift: "chart.bar.xaxis"
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mac/web/mac-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PerformanceView } from "@mobile-dev/ui/components/performance-view.tsx"
import { SimulatorView } from "@mobile-dev/ui/components/simulator-view.tsx";
import { StorageView } from "@mobile-dev/ui/components/storage-view.tsx";
import { TraceSiftView } from "@mobile-dev/ui/components/tracesift-view.tsx";
import { AppIntelligenceView } from "@mobile-dev/ui/components/app-intelligence-view.tsx";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@mobile-dev/ui/components/ui/resizable.tsx";


Expand All @@ -29,6 +30,7 @@ export function MacWorkspace({ panels, tool, layout }: { panels: WorkspaceTools;
const toolView = tool === "logs" ? <LogsView panel={panels.logs} />
: tool === "performance" ? <PerformanceView panel={panels.performance} />
: tool === "storage" ? <StorageView panel={panels.storage} />
: tool === "app-intel" ? <AppIntelligenceView panel={panels["app-intel"]} />
: tool === "tracesift" ? <TraceSiftView panel={panels.tracesift} />
: <EmptyTool layout={layout} />;
return <main className="flex h-dvh flex-col">
Expand Down
25 changes: 24 additions & 1 deletion docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ The plugin servers for Codex, Claude and Cursor serve agent-device's commands as
| Node responsiveness | Automatic event-loop delay, utilization and process uptime. |
| UI JavaScript heap | `ui.memory.heap_used`, `ui.memory.heap_total` and `ui.memory.heap_limit` in bytes at startup and every 30 seconds while visible, when Chromium's `performance.memory` is available. `ui.memory.supported` distinguishes unsupported contexts. These estimates do not measure total renderer or GPU memory. |
| UI responsiveness | Browser tracing captures available web vitals. Custom metrics record visible animation-frame intervals, intervals over 50 ms, Event Timing interaction durations, long tasks and long animation frames where supported. |
| Product surfaces | Metrics carry `surface=app-flow`, `simulator`, `logs`, `performance`, `recording` or `comparison`, plus view, visible device layout and monitoring state. App Flow readiness/graph work, log filter time, buffered/filtered rows, performance batch processing, canvas draw time and time to first video frame help explain slow surfaces. |
| Product surfaces | Metrics carry `surface=app-flow`, `simulator`, `logs`, `performance`, `storage`, `app-intel`, `tracesift`, `recording` or `comparison`, plus view, visible device layout and monitoring state. App Flow readiness/graph work, log filter time, buffered/filtered rows, performance batch processing, App Intelligence generation and graph size, canvas draw time and time to first video frame help explain slow surfaces. |
| Frame capture | `ui.screenshot.capture` measures synchronous canvas PNG encoding and base64 extraction in milliseconds for Select captures and physical iOS screenshots. `ui.screenshot.encode` measures PNG decoding, bounded JPEG encoding, and base64 extraction for chat attachments in milliseconds. Screenshot tools retain sampled MCP traces and report handled capture, attachment, and clipboard failures without image content. |
| Pasted input | Command-V paste uses the existing ordered device input queue and `ui.device_input.round_trip` timing through MCP acknowledgement. It retains simulator surface/device context and existing input failure reporting. Clipboard content and validation failures are not sent to Sentry. |
| Storage | Every five minutes, a server with agent-device measures its session state directory and the shared Apple runner cache in bytes, from the runtime's first start. It skips symlinks and sends only the storage kind and size. |
Expand Down Expand Up @@ -707,6 +707,29 @@ existing centralized Sentry initialization, environment and telemetry opt-out.
Native rebuilds retain matching Baguette dSYMs, and release uploads continue to
upload those symbols with the plugin release.

App Intelligence uses the `app-intel` surface. `app_intel.operation.duration` and
`.samples` measure bounded generation, query, snapshot, clear and folder-picker
operations by success, failure or cancellation. The UI records generation/load/picker round
trips, surface readiness, and gauges only the counts of files and screens in the
displayed snapshot. `ui.app_intel.flow_layout` times building and placing the
per-screen dependency graph — the hop layering, its boundary grouping and the
geometry for the chosen reading direction — and `ui.app_intel.flow_nodes` gauges
how many cards it drew, which is what drives that cost. The panel no longer has a
directory treemap, so the Atlas measurements `ui.app_intel.atlas_layout`,
`ui.app_intel.atlas_cells`, `ui.app_intel.view_switch` and `ui.app_intel.zoom` are
gone rather than renamed; `flow_layout` and `flow_nodes` keep their meaning and
units, so their history stays comparable even though they now cover grouping and
placement as well. `ui.app_intel.group_collapse`, `.group_expand` and
`.group_show_all` count folding a boundary away, bringing it back and opening one
past its row cap, `ui.app_intel.boundary_highlight` counts lighting one boundary's
connectors up, `ui.app_intel.project_switch` counts picking a remembered
project instead of the host folder picker, `ui.app_intel.clear` counts throwing a
saved graph away, and `ui.app_intel.boundary_filter`
counts how often the entry-point filter is changed. All of these count the change
only; which boundary, project or direction was picked is not an attribute. Project paths, source paths, screen and package names, source
contents, queries, diagnostics, graph edges and snapshot identifiers remain local
and are never telemetry attributes.

A Fast screen sweep now runs with no decision provider unless the panel's per-project "Judge unclear screens with AI" switch is on, so the judge path described above is reachable in Fast only when that switch asked for it; with it off, a screen whose runtime evidence is incomplete is recorded unreached instead of being sent to the judge, and `app_flow.runtime.verify` emits no `judge` arrivals for that run. The series, its unit and its attributes are unchanged — the runtime/judge split simply shifts further towards `runtime` for Fast runs started with the switch off. The panel adds one UI count beside `ui.app_flow.capture_ui_states_start` and with the same shape, `ui.app_flow.judge_unclear_screens_start`, once per user-started run that has the switch on, so how often Fast pays for the decision loop is visible; it carries nothing beyond the shared surface attributes — no project path, screen, route or verdict. A Fast run with the switch off is also started straight from the panel rather than through a chat message, so it records `ui.app_flow.start` where a chat-started sweep records none; that is the existing split between the two start paths, not a new measurement.

Since 0.1.141, deterministic Fast harvests route parameters from the link
Expand Down
Loading
Loading