App Control
August 17, 2026 · View on GitHub
App Control is ADE's bridge for driving developer-owned app sessions from inside a chat. The first supported AppControlAppKind is electron: ADE launches (or connects to) an Electron renderer that exposes a Chrome DevTools Protocol port, captures screenshots and DOM elements, resolves elements back to their source files, and lets the user attach screenshot-backed UI context to a chat as AppControlContextItems.
App Control is intentionally a bridge. Other automation stacks — Playwright, agent-browser, browser-use, Claude's computer_use — can attach to the same Electron app and continue to be useful. ADE's job is to keep the launch state, the visible launch terminal, screenshots, DOM/selector packets, source candidates, and chat-attached context coherent across those tools.
App Control runs on the machine the chat runs on. The launch terminal, CDP attachment, screencast frames, screenshots, and source-matching all execute on that host; the renderer just streams the resulting frames and chips. Because Electron apps under inspection live on the host's filesystem, App Control naturally runs on whichever machine has the source tree — which is the machine of the chat driving it, not necessarily the machine the project tab is bound to. ChatAppControlPanel therefore takes a runtimePin and carries it on every call.
Source file map
Service (apps/desktop/src/main/services/appControl/)
appControlService.ts— the broker. Resolves launch parameters, runs the Electron app inside a chat-owned PTY (so the user sees stdout/stderr), polls the CDP HTTP endpoint for ready targets, attaches a long-livedCdpClientWebSocket, and exposes the high-level operations consumed via IPC and the ADE CLI:- lifecycle:
getStatus,launch/launchInTerminal,connect,stop,dispose,listTargets,attachToTarget - window controls:
focusWindow,minimizeWindow— explicit user actions for raising or minimizing the controlled Electron window - capture:
screenshot,getSnapshot(screenshot + DOM elements) - context:
inspectPoint,selectPoint— produce anAppControlContextItemfrom screenshot or viewport coordinates with element + source-file matches - input:
click,typeText,scroll,dispatchKey - launch terminal passthrough:
readTerminal,writeTerminal,signalTerminal - screencast frames stream out via the
onEventchannel (type: "frame")
- lifecycle:
appControlLaunchCommand.ts— launch parsing and rewrite helpers for direct Electron and package-manager script commands. On Windows, resolvableelectron/npx electronand package-script launches become structured command/argv/env descriptors; shell fallbacks emit explicit PowerShell or cmd syntax. POSIX retains the existing shell rewrite semantics. The literal{ADE_APP_CONTROL_DEBUG_FLAGS}opt-in path remains available on every platform.appControlService.test.ts— service tests.appControlLaunchCommand.test.ts— launch-command rewrite coverage.
Shared types
apps/desktop/src/shared/types/appControl.ts— the type contract:- identity:
AppControlAppKind,AppControlProvider(cdp|os-accessibility|computer-use|external),AppControlSession(status:starting|running|connected|stopping|exited|stopped|failed). Sessions carry bothprojectRootandlaneIdso the renderer can detect when an active App Control session is attached to a different lane than the active Work / chat lane and surface a mismatch warning.AppControlConnectArgsaccepts an optionallaneId;connect()resolves the final lane id through the sameresolveLaneIdstrategy aslaunch()andlaunchInTerminal()(caller-supplied id wins; otherwisechatSessionIdresolves it). - capture:
AppControlScreenshot,AppControlScreen,AppControlElement,AppControlFrame,AppControlSnapshot,AppControlSnapshotProvider,AppControlScreencastFrame. - coordinate spaces:
AppControlCoordinateSpaceis"screenshot"for bitmap pixels or"viewport"for CDP CSS viewport coordinates. Live renderer clicks use viewport coordinates so CDP input lands on the actual element under the pointer. - context:
AppControlContextItem(kind: "app_control_element"),AppControlSourceMatch,AppControlInspectResult,AppControlSelectResult. - inputs:
AppControlLaunchArgs,AppControlConnectArgs,AppControlStopArgs,AppControlClickArgs,AppControlTypeTextArgs,AppControlInspectPointArgs. - eventing:
AppControlEventPayloadunion (session-started,session-updated,session-stopped,selection,frame). AppControlStatusreportsplatform,supported, the active session, and per-provider availability.
- identity:
IPC (apps/desktop/src/shared/ipc.ts)
Channels live under ade.appControl.*:
ade.appControl.getStatusade.appControl.launch/ade.appControl.launchInTerminalade.appControl.connectade.appControl.stopade.appControl.focusWindow/ade.appControl.minimizeWindowade.appControl.screenshotade.appControl.getSnapshotade.appControl.inspectPoint/ade.appControl.selectPointade.appControl.click/ade.appControl.typeText/ade.appControl.scroll/ade.appControl.dispatchKeyade.appControl.listTargets/ade.appControl.attachToTargetade.appControl.event(push channel; carriesAppControlEventPayload, including screencast frames)
registerIpc.ts rate-limits launch/snapshot/click/type calls and validates argument shapes via appControlRecord. Heavy operations (launch, getSnapshot, inspectPoint, selectPoint, screenshot, connect, stop, focusWindow, minimizeWindow, click, typeText) bypass the global 30 s IPC timeout — CDP screenshot/screencast operations can legitimately exceed it.
The companion chat terminal surface lives at ade.terminal.* and shares the same backend as PTY:
ade.terminal.list— list chat-attached terminals (filterable bychatSessionId/laneId).ade.terminal.read— read scrollback byterminalId, liveptyId, orchatSessionId(resolves to the chat's active terminal).ade.terminal.write/ade.terminal.signal— send input orSIGINT/SIGTERM/SIGKILL.ade.terminal.activeForChat— fetch the currently active terminal for a chat.
Preload bridge
apps/desktop/src/preload/preload.tsexposeswindow.ade.appControl(matching the IPC list above plus anonEventsubscription) andwindow.ade.terminal(list,read,write,signal,activeForChat).apps/desktop/src/preload/global.d.tscarries the renderer-facing typings.
Renderer
-
apps/desktop/src/renderer/components/chat/ChatAppControlPanel.tsx— the App Control panel. Two mount points:- Under
AgentChatPane's in-chat drawer (chat-scoped,sessionIdset, persisted undersessionStorage["ade.chat.appControlPanel.chat:<sessionId>"]). - Inside the Work right-edge sidebar's
app-controltab (apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx, lane-scoped,sessionId={null}+laneIdset, persisted undersessionStorage["ade.chat.appControlPanel.lane:<machineKey>:<laneId>:<projectRoot>"]). The machine key is part of the state key because a launch command and CDP port describe a process on one machine, a lane id is only unique within its machine, and the same project root exists on both sides of a cross-machine checkout.
Both mounts take
runtimePin, the machine the panel drives. Status reads,listTargets,getSnapshot/screenshot,inspectPoint/selectPoint, launch / connect / attach / stop,focusWindow/minimizeWindow, every input call, and theappControl.onEventsubscription all carry it. The event half matters as much as the reads: without a pinned subscription a pinned panel got status reads and no live updates — no screencast frames, no session transitions — while the bound machine's stream described a different app entirely.Two modes:
- Control — shows live screencast frames, launch/connect controls, explicit Show/Minimize window buttons, click/type input, and quick actions for
terminal write(answer a prompt) andterminal signal(interrupt). Live clicks and wheel events are mapped to viewport coordinates before CDP input dispatch. - Inspect — overlays a DevTools-style outline on the screenshot or live frame. Hovering calls backend
inspectPoint; clicking commits viaselectPoint, producing anAppControlContextItemthat the chat composer attaches as a context chip plus an attachment.
Connect / launch calls forward the resolved
laneIdso the resultingAppControlSessionrecords its launching lane. - Under
-
apps/desktop/src/renderer/components/chat/AgentChatPane.tsxmounts the chat-scoped panel, ownsappControlContextItems, and renders App Control chips alongside file attachments. The pane pollsade.appControl.getStatus(chatRuntimePin)to gate the header toggle on platform support only when lane tool drawers are visible. Support is a property of the machine the chat runs on, so it asks that machine; skipping the probe for a remote project left the toggle permanently hidden, and hiding the toggle is what kept the panel from ever opening to un-skip it. When mounted as a Work tile (hideLaneToolDrawers={true}) the in-chat App Control drawer toggle and status poll are suppressed because the Work sidebar owns that drawer at lane scope; selections from the sidebar still flow into the chat composer through theade:agent-chat:add-app-control-contextwindow event. -
apps/desktop/src/renderer/components/terminals/WorkSidebar.tsxmounts the lane-scoped panel under theapp-controltab, keyedwork-appcontrol:<pinKey>so a machine switch remounts it, and runs its own pinnedappControl.getStatus/onEventpair against the same machine (skipped entirely when that machine is known offline). Lane names in its messages resolve against the pinned machine's lanes, since a foreign chat's lane is absent from the tab-bound lane list. When the active session'slaneIddiffers from the sidebar's active lane it shows aWarningBanner("App Control is attached to a different lane…"); the user can still control the existing session, but selections will not attach to the active lane's chat until the tool session is relaunched against the matching lane. -
apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsxreadsAppControlSessionto decorate the App Control launch terminal tab with a status tone (active/warn/error).
ADE CLI
apps/ade-cli/src/cli.ts registers two new top-level command groups:
ade app-control <sub>:status,actions(list every callableapp_controlaction)launch,connect,focus,minimize,stopscreenshot,snapshot,inspect,selectclick,type,scroll,key(inspect,select,click, andscrollaccept--coords screenshot|viewport)targets,attachlogs,terminal write,terminal signal— operate on the active App Control launch terminal
ade terminal <sub>:list,active,read,write,signal— control the in-chat terminal owned by a chat session.
apps/ade-cli/src/bootstrap.ts constructs an AppControlService for headless mode using the same resolveLaneId strategy as the desktop main process.
The agent guidance built by apps/desktop/src/shared/adeCliGuidance.ts tells agents to use socket-backed ADE CLI surfaces when live desktop state matters, to read the relevant Agent Skill for detailed App Control steps, and to register proof artifacts through ade proof ... after captures.
Action registry
apps/desktop/src/main/services/adeActions/registry.ts adds two domains:
app_control— every public method onAppControlService(getStatus,launch,launchInTerminal,connect,stop,focusWindow,minimizeWindow,screenshot,getSnapshot,inspectPoint,selectPoint,click,typeText,scroll,dispatchKey,listTargets,attachToTarget,readTerminal,writeTerminal,signalTerminal).terminal—list,read,write,signal,activeForChatagainstptyServiceso headless agents can control chat-owned terminals.
Launch and connect flow
launch(args) is the primary entry point.
- Argument resolution.
appKinddefaults to"electron".cwdis normalized against the resolvedprojectRootand rejected if it escapes the lane worktree (ensureCwdInsideRoot).cdpPortis allocated viafindFreePort()when not supplied.ADE_APP_CONTROL_CDP_PORTandADE_APP_CONTROL_DEBUG_FLAGSare computed and either:- substituted into a literal
{ADE_APP_CONTROL_DEBUG_FLAGS}placeholder in the command, or - injected when the command looks like a package script (
npm/pnpm/yarn/bun run dev) by resolving the package script and adding--inspect/--remote-debugging-portflags, or - appended directly when the command looks like a
npx electron/electroninvocation, or - exported via the spawned shell's environment for any other custom launcher (custom launchers are expected to forward one of those env vars to
--remote-debugging-port). On Windows, recognized direct/package commands are passed toptyServiceas a structured executable, argv, environment, and cwd. This avoids POSIXPATH=...:$PATHsyntax and preserves spaces, quotes,%,$, and&. A command that genuinely needs a shell falls back to platform-specific PowerShell or cmd quoting. Structured parsing is deliberately Windows-only; macOS/Linux custom shell semantics are not reinterpreted.
- substituted into a literal
- Visible chat terminal. Instead of spawning a hidden child process, the service runs the resolved command through the chat-owned PTY (
ptyService.create(...)withchatSessionId). The user sees the stdout/stderr in the chat terminal drawer, and the App Control session records the resultingterminalSessionId+terminalPtyId. App Control's CDP provider is supported on Windows; OS-level computer use remains a separate macOS-only capability. - CDP discovery.
listCdpTargets(port)pollshttp://127.0.0.1:<port>/jsonevery 500 ms. A health-check timer keeps polling at 2 s once a target is selected.pickCdpTargetpreferspage>webview> anything with a non-devtools://URL. - Attach.
CdpClient.connect(webSocketDebuggerUrl)opens the long-lived WebSocket. The session transitionsstarting→running→connectedandcdpEndpoint/cdpTargetIdare filled in.Page.startScreencastis enabled lazily so the renderer panel can paint frames. - Health. If the WebSocket drops, the session moves back to
running(terminal still alive) orfailed(terminal exited).lastErrorcarries the last CDP failure for the renderer to display.
connect(args) is the same flow without the launch step — useful when an agent already has an Electron app running with --remote-debugging-port=<port>.
Routine capture and input paths do not raise or normalize the external Electron window. The panel exposes explicit Show and Minimize controls backed by focusWindow() and minimizeWindow() for the cases where the user wants to manage that window.
stop({ force }) closes the CDP socket, signals the launch terminal (SIGINT then SIGKILL on force), drops cached frames, and emits session-stopped. dispose() is the shutdown path.
Snapshot and source matching
getSnapshot() runs in two parts inside the renderer process:
- DOM collector (
cdpDomSnapshotScript) walks the document, ranks elements by interactivity, capturestagName, ARIArole, computedlabel, value, a stableselector(id → testid → tag.class),data-testid/data-test/data-qa, geometry (logical + pixelframe), and a smallmetadatabag (text, ARIA bits, common React-DevTools markers likedata-component,data-source-file,data-source-line). Up toMAX_DOM_ELEMENTS = 450entries are returned. Point inspection uses CDPDOM.getNodeForLocation+Runtime.callFunctionOnfirst so hover/select outlines snap to the actual control under the pointer; the in-pagecdpPointSnapshotScriptremains as a fallback for targets that do not expose node lookup. - Source matching runs in the main process.
collectSourceFiles(projectRoot)indexes a capped list of.ts/.tsx/.js/.jsx/.html/.cssfiles (skipping.git,.ade,node_modules,dist, etc.) andfindSourceMatchessearches for the element'sdata-component,data-testid,id, label text, or selector tokens. Matches are returned asAppControlSourceMatch[]withconfidence: "exact" | "candidate"and a small snippet.
inspectPoint({ x, y, coordinateSpace }) returns an AppControlInspectResult with the hit element, all surrounding elements (via nearbyElements), and the source candidates — without committing anything to chat. The primary hit-test path uses CDP DOM.getNodeForLocation to resolve the backend node at the viewport point, then DOM.resolveNode to get a remote object, and finally Runtime.callFunctionOn with CDP_NODE_METADATA_FUNCTION to extract role, label, selector, geometry, and metadata from the resolved node. This snaps to the actual control under the pointer instead of relying on the in-page elementFromPoint fallback. The in-page cdpPointSnapshotScript remains as a fallback for targets that do not expose CDP node lookup. selectPoint() is the same call but produces a final AppControlContextItem (with provider, componentId, sourceFile, sourceLine, metadata, screenshotDataUrl, selectedAt) ready to attach to the active chat composer. Both calls fall back to a coordinate-fallback provider when the DOM hit-test misses (e.g. inside an <iframe> ADE cannot reach).
Input
click({ x, y, scale, coordinateSpace })sends CDPInput.dispatchMouseEventat viewport coordinates. The default coordinate space is"screenshot"for backwards-compatible CLI/API calls, and the renderer panel sends"viewport"so live-frame clicks land on the actual element under the pointer without any scale conversion. Screenshot-space coordinates are normalized to viewport space using independent x and y scale factors derived from the most recentPage.screencastFramemetadata (deviceWidth/ image-width for x,deviceHeight/ image-height for y), so non-uniform scaling (e.g. a resized window) does not skew click targets. Sharedservices/shared/imageDimensions.tsextracts width/height from both PNG and JPEG screenshot buffers (JPEG parsing scans SOF markers). For hidden renderers, App Control triesdispatchDomClickfirst as a synthetic in-page fallback.typeText({ text })callsInput.insertText.dispatchKey({ type, key, code, text, modifiers })is the lower-level escape hatch for shortcuts and special keys.scroll({ x, y, deltaX, deltaY, coordinateSpace })isInput.dispatchMouseEventwithtype: "mouseWheel".- All input calls go through a single shared
CdpClient(withCdp) so the WebSocket isn't reopened per click; this measurably reduces input latency.
Chat-owned terminal model
The App Control launch terminal is a regular ADE chat terminal — it inserts a terminal_sessions row and routes through ptyService. To make these terminals first-class for chat agents, the branch widens the schema and PTY service:
terminal_sessionsgains achat_session_idcolumn (nullable, indexed). Set when a PTY is created withchatSessionIdinPtyCreateArgs.ptyServicekeeps two in-memory maps:terminalChatSessions(terminalId → chatSessionId) andactiveTerminalByChatSession. Disposing a chat terminal automatically promotes the most recently created sibling soterminal.read --chat-session <id>always resolves a sensible target.- New service methods (also exposed as ADE actions):
listTerminals,readTerminal,writeTerminal,signalTerminal,activeForChat. They accept either an explicitterminalId/ptyIdor achatSessionId(which resolves to the chat's active terminal).
agentChatService populates ADE_CHAT_SESSION_ID, ADE_LANE_ID, ADE_PROJECT_ROOT, and ADE_WORKSPACE_ROOT in the agent runtime environment (buildAgentRuntimeEnv), so an in-chat Claude/Codex agent can call ade --socket app-control logs or ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text without resolving the chat ID itself.
Provider model
AppControlStatus.providers reports availability per AppControlProvider:
cdp— Chrome DevTools Protocol against an Electron renderer. Fully supported; this is whatlaunch/connectdrive.os-accessibility— placeholder for future macOS AX-based control of non-Electron apps. Currentlyavailable: false.computer-use— placeholder for delegating to Claudecomputer_use/ Ghost OS-style backends.external— when an external automation tool (Playwright, agent-browser) holds the connection.
Only one App Control session is active per project at a time. Re-launching/connecting with force: true cleans up the previous session first.
Cross-links
README.md— the proof-artifact broker; App Control sits next to it but does not write tocomputer_use_artifacts.../chat/composer-and-ui.md— composer chip rendering forAppControlContextItems.../terminals-and-sessions/README.md—chat_session_idcolumn and the newade.terminal.*IPC surface.../agents/tool-registration.md— howADE_CHAT_SESSION_IDreaches the agent runtime and howapp_control/terminalADE CLI domains are exposed.