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 runtime that owns the project. The launch terminal, CDP attachment, screencast frames, screenshots, and source-matching all execute on the runtime host; the renderer just streams the resulting frames and chips. Because Electron apps under inspection live on the runtime host's filesystem, App Control naturally runs on whichever machine has the source tree.
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— pure shell-command helpers for detecting direct Electron launches, detecting package-manager script launches, rewriting package scripts to inject App Control debug flags, and preserving the{ADE_APP_CONTROL_DEBUG_FLAGS}opt-in path.appControlService.test.ts— service tests.appControlLaunchCommand.test.ts— launch-command rewrite coverage.
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:
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.
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.
-
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:<laneId>:<projectRoot>"]).
Two modes:
- Control — shows live screencast frames, Run-tab style 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.getStatusto gate the header toggle on platform support only when lane tool drawers are visible. 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 and runs its ownAppControlSessionsubscription. 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).
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.
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(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 rewriting it to--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).
- 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. - 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.
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).
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.
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.
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.
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.