Remote commands are the execution channel for controllers. A controller
(another desktop acting as a peer, or the iOS app) sends a command
envelope to the ADE brain; the brain's in-process services
resolves it through syncRemoteCommandService, runs the underlying
action against its in-process services, and replies with command_ack
and then command_result.
Source file: apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
(~2,840 lines). The desktop tree's
apps/desktop/src/main/services/sync/syncRemoteCommandService.ts is a
one-line re-export of the canonical module.
Terminology note: brain is the always-on machine-owned ADE process.
Some wire and code identifiers also say host or syncHost because
those names predate the current glossary; they refer to the brain/sync
authority unless this document explicitly says otherwise.
A controller sends:
{
type: "command",
version: 1,
requestId: "uuid",
payload: {
commandId: "uuid",
action: "lanes.create" | "chat.send" | ...,
args: { ... }
}
}The brain responds in two envelopes:
// command_ack — receipt and preliminary disposition
{
type: "command_ack",
payload: {
commandId: "uuid",
accepted: boolean,
status: "accepted" | "rejected",
message: string | null
}
}
// command_result — execution outcome
{
type: "command_result",
payload: {
commandId: "uuid",
ok: boolean,
result?: unknown,
error?: { code: string, message: string }
}
}Every action carries a SyncRemoteCommandDescriptor with both a
scope and a policy:
type SyncRemoteCommandDescriptor = {
action: SyncRemoteCommandAction;
scope: "runtime" | "project";
policy: SyncRemoteCommandPolicy;
};
type SyncRemoteCommandPolicy = {
viewerAllowed: boolean; // can a read-only controller invoke?
requiresApproval?: boolean; // brain prompts operator before executing
localOnly?: boolean; // never sent over the wire; local-only
queueable?: boolean; // queue locally if offline, replay on reconnect
};The scope label matters because the brain serves multiple projects
at once. runtime-scoped commands (machine-wide diagnostics, project
catalog reads, settings) run without a project binding. project-scoped
commands (everything that mutates lane / chat / PR state inside a
project) require the brain to have an active project AND the caller to
have bundled a matching projectId on the envelope. The brain enforces
this with explicit error codes:
code: missing_project— the brain has a project open but the command did not includeprojectId. Re-select the project on the controller and retry.code: project_not_open— caller asked for a project the brain does not currently have open. Drive aproject_switch_requestfirst.
Controllers read SyncRemoteCommandDescriptors from the brain (via the
getSupportedActions / getDescriptors surface) and gate UI
accordingly — the brain's policy and scope are always authoritative.
Commands are registered by calling register(action, policy, handler, scope = "project") inside createSyncRemoteCommandService. The
registry is a Map<SyncRemoteCommandAction, RegisteredRemoteCommand>
built at service construction. Handlers receive parsed-and-validated
args and either return a result or throw; thrown errors are wrapped
into the command_result.error envelope. The default scope is
"project" because most actions need an open project to make sense;
runtime-scoped registrations are explicit.
Listed in order of appearance in the registry:
Lanes (lanes.*)
list,refreshSnapshots,getDetail,listUnregisteredWorktreescreate,createChild,createFromUnstaged,importBranch,attach,adoptAttachedrename,reparent,updateAppearancearchive,unarchive,deletegetStackChain,getChildrenrebaseStart,rebasePush,rebaseRollback,rebaseAbortlistRebaseSuggestions,dismissRebaseSuggestion,deferRebaseSuggestionlistAutoRebaseStatuseslistTemplates,getDefaultTemplateinitEnv,getEnvStatus,applyTemplatepresence.announce,presence.release— controller marks a lane as currently open / no longer open; the brain decoratesLaneSummary.devicesOpenwith a 60 s TTL and fans out updates via the brain-status broadcast (brain_status).
lanes.reparent accepts { laneId, newParentLaneId, stackBaseBranchRef? }. The optional base ref is trimmed before
dispatch; when present, the brain resolves it in the project repo
preferring origin/<branch>, persists it as the lane's base_ref,
and rebases the lane onto that resolved branch. When omitted, the brain
uses the selected parent lane's current branch.
lanes.refreshSnapshots accepts lightweight-decoration flags:
includeConflictStatus, includeRebaseSuggestions, and
includeAutoRebaseStatus. Mobile list refreshes set these to false
when they only need runtime/session bucket updates, avoiding extra git
and rebase-status work on routine refreshes. lanes.getDetail reads the
requested lane through the scoped lane-summary path and then fetches the
detail overlays for that lane, instead of forcing a full lane list as a
side effect of opening a detail screen.
Work (work.*)
listSessions,updateSessionMeta,runQuickCommand,startCliSession,sendToSession,stopRuntime
Chat (chat.*)
listSessions,getSummary,getTranscript
chat.getTranscript supports cursor pagination: responses carry an
opaque index-based nextCursor, and requests can pass cursor to
page strictly-older history. Calls without a cursor behave exactly as
before.
create,send,interrupt,steer,cancelSteer,editSteer,dispatchSteer,cancelDispatchedSteer,approve,respondToInputrestart,updateSession,archive,unarchive,delete,models,modelCatalog
chat.modelCatalog accepts { mode?, refreshProvider?, cursorSource? }
where mode is "cached" | "refresh-stale" | "force" (default
"cached") and refreshProvider is "opencode" | "cursor" | "droid" | "lmstudio" | "ollama". cursorSource ("sdk" | "cli" | "all", default
"all") scopes which Cursor discovery source the host probes
synchronously — chat-style surfaces pass "sdk" so the refresh stays off
the slower cursor-agent CLI spawn while the CLI flavor revalidates in
the background. The brain returns the full provider-grouped catalog used
by the desktop and TUI ModelPickers and the iOS Work model sheet; only
explicit force / refresh-stale calls trigger a runtime probe.
chat.dispatchSteer (Claude SDK only) takes
{ sessionId, steerId, mode: "inline" | "interrupt" } and either folds
a queued steer into the active turn or interrupts the active turn so
the queued message runs next; it returns { ok, dispatchedAt }.
chat.cancelDispatchedSteer rescinds an inline dispatch before the
model reads it, returning { ok, cancelled }. The iOS companion uses
both via SyncService.dispatchChatSteer /
cancelDispatchedChatSteer.
Git (git.*)
getChanges,getFilestageFile,stageAll,unstageFile,unstageAll,discardFile,restoreStagedFilecommit,generateCommitMessage,listRecentCommits,listCommitFiles,getCommitMessage,getFileHistoryrevertCommit,cherryPickCommit,createTag,resetToCommitisCommitInLaneHistory— checks whether a givencommitShais reachable from the lane's current HEAD; used by controllers before surfacing destructive operations on commits that may belong to a different branchstashPush,stashList,stashApply,stashPop,stashDropfetch,pull,sync,push,getSyncStatusundoLastHeadChange,redoLastHeadChange— paired recovery actions that re-read HEAD before acting and refuse when the lane has moved since the operation they targetgetConflictState,rebaseContinue,rebaseAbort,mergeContinue,mergeAbort— the merge variants mirror the rebase pair so the iOS lane conflict banner can continue or abort an in-progress merge, not just a rebaselistBranches,checkoutBranch
git.pull accepts an optional mode argument
("ff-only" | "rebase" | "merge", default ff-only) so controllers
can pick the strategy without having to send three separate actions.
Unknown mode values are rejected with a clear error.
git.resetToCommit takes { laneId, commitSha, mode } where mode
is one of soft | mixed | hard; ADE records the operation as
git_reset_<mode> so undo/redo lookups can pair it up later.
git.createTag takes { laneId, commitSha, tagName, message? };
omitting message creates a lightweight tag.
git.isCommitInLaneHistory takes { laneId, commitSha } and returns
a boolean.
Files
files.writeTextAtomic
Conflicts (conflicts.*)
getLaneStatus,listOverlaps,getBatchAssessment
PRs (prs.*)
list,refresh,getDetail,getStatusgetChecks,getReviews,getComments,getFilescreateFromLane,createQueue,draftDescription,land,close,reopen,requestReviewers,rerunChecks,addCommentsimulateIntegration,commitIntegration,listIntegrationWorkflows,updateIntegrationProposal,deleteIntegrationProposal,startIntegrationResolution,recheckIntegrationSteplandQueueNext,startQueueAutomation,pauseQueueAutomation,resumeQueueAutomation,cancelQueueAutomationgetMobileSnapshot— aggregate read that returnsPrMobileSnapshot(summaries, stacks, per-PR capabilities, create-PR eligibility, workflow cards). Consumed by the iOS PRs tab; seeios-companion.mdfor the shape.
CTO (cto.*)
removeAgent— drop a worker from the team and trigger aworkerHeartbeatService.syncFromConfigresync so the live roster reflects the removal immediately. Phone-driven CTO management uses this in tandem withsetAgentStatus,triggerAgentWakeup, androllbackAgentRevision.
The canonical list is typed as SyncRemoteCommandAction in
apps/desktop/src/shared/types/sync.ts.
Each action has a dedicated parse function (e.g. parseCreateLaneArgs,
parseAgentChatSendArgs, parseCreatePrArgs) that:
- Accepts
Record<string, unknown>. - Validates required fields with
requireString/requireStringArray/requireService. - Coerces optional fields through
asTrimmedString,asOptionalNumber,asOptionalBoolean,asStringArray. - Returns the typed args object expected by the brain's in-process service.
Helpers (asTrimmedString, asStringArray, requireString, etc.) live
at the top of the file. A non-conforming args object causes the parser
to throw an explicit error like "lanes.create requires name."; that
error reaches the controller as command_result.error.message.
Handlers are thin glue onto the brain's in-process services. Most look like:
register("lanes.create",
{ viewerAllowed: true, queueable: true },
async (payload) => args.laneService.create(parseCreateLaneArgs(payload)));A handful have more logic:
work.runQuickCommand— constructs aPtyCreateArgs, callsptyService.create, and returns the PTY handle for the controller to subscribe to viaterminal_subscribe.work.startCliSession— runtime-side mobile CLI launcher used by the iOS Work "new session" surface. Args are validated throughparseStartCliSessionArgs, which restrictsproviderto the allowlistclaude | codex | cursor | droid | opencode | shell(any other value throws"work.startCliSession requires provider."), clampscolsto[20, 240]androwsto[4, 120], and truncatesinitialInputat 20 KB.model/modelId,reasoningEffort, andfastModeflow into the same launch builder as desktop; the oldercodexFastModewire name is accepted only as a compatibility alias. Provider-specific argv, env, and shell preambles come frombuildTrackedCliLaunchCommandinapps/desktop/src/shared/cliLaunch.ts— the same module the desktop Work tab uses — so the runtime owns the startup-command shape and a phone cannot smuggle in a free-form shell command (theshellprovider takes no startup payload at all). The runtime resolves the requested lane worktree before building that launch payload, so ADE guidance andADE_AGENT_SKILLS_DIRSprefer lane-local.claude/.agents/.ade/.codexskill dirs and bundled ADE resources instead of whichever project root the daemon process happened to start from. Claude launches mint a pre-assigned--session-idupfront viarandomUUID()so continuation works as soon as the row exists. WheninitialInputis present, it is passed toptyService.createasargs.initialInputwith aninitialInputDelayMs(default 750 ms for CLI launches) so the agent CLI input protocol handles bracketed-paste submission after the TUI has had time to initialize. This replaces the older pattern of post-createwriteBySessionIdkeystrokes. The result isSyncStartCliSessionResult({ sessionId, ptyId, session: TerminalSessionSummary | null }) — the controller can immediately render the session card and callterminal_subscribewithout an extra round-trip. The command-result journal persists only the returned session handle and summary, not theinitialInputtext, so reconnect replay does not leak the user's prompt into the runtime-side ledger.work.sendToSession— sends text to an existing durable Work CLI session. If the PTY is live, the runtime writes into it; if the process ended and the session is resumable, the runtime starts the provider continuation internally and attaches the runtime to the same session id.work.stopRuntime— looks up the session's PTY id and disposes the PTY without deleting the durable session row or transcript.chat.create— resolves a missingmodelto the first available provider model viaagentChatService.getAvailableModelsbefore forwarding.lanes.suggestName— background lane naming for the mobile auto-create flow (desktop parity withagentChatService.suggestLaneNameFromPrompt). Takes{ prompt, modelId, laneId, fallbackName? }, calls the host's small naming model, and returns{ name }. The handler is deliberately not queueable so an offline phone fails fast and the client uses its own deterministic fallback instead of receiving a stale queued suggestion. Naming honors the hosttitleGenerationEnabledsetting and clamps the result; a missingagentChatService, a thrown error, or an empty name all fall back to the suppliedfallbackName(or, when none was passed, a prompt-derivedderiveDeterministicLaneNameFromPrompt), so naming can never block or fail lane creation. The iOS caller (SyncService.suggestLaneName, raced against a 10s deadline inWorkNewChatScreen) catches any throw and uses the same deterministic name.lanes.initEnv/lanes.applyTemplate— resolves the lane's overlay context (resolveLaneOverlayContext), merges overrides with the template's env init config, and invokeslaneEnvironmentService.initLaneEnvironment.lanes.list— delegates tolaneService.listthen runsbuildLaneListSnapshotsto produce the richer payload the iOS Lanes tab consumes (runtime bucket summaries, rebase suggestions, auto-rebase statuses, batch assessment).prs.refresh— delegates toprService.refresh, then re-lists PRs and returns both the PR list and the snapshots in a single response.prs.getMobileSnapshot— callsprService.getMobileSnapshot, which builds stack chains fromlaneService.list, classifies each PR's action capabilities, resolves per-lane create-PR eligibility (usingresolveStableLaneBaseBranch), and collects queue / integration / rebase workflow cards from the DB andconflictService.scanRebaseNeeds()(the same source the desktop Rebase tab consumes).lanes.dismissRebaseSuggestion/lanes.deferRebaseSuggestion— dual-write the lane state. The handler callsconflictService.dismissRebase(laneId)/conflictService.deferRebase(laneId, until)first so the nextprs.getMobileSnapshotrebuild reflects the action immediately, then forwards torebaseSuggestionService.dismiss/deferfor the legacy desktop banner.deferclamps the requested minutes to[5, 7 days]before computing the absoluteuntilISO string.lanes.presence.announce/lanes.presence.release— handled insyncHostServicedirectly (not in the remote command registry); the brain upserts a per-laneDeviceMarkermap and decorates outgoingLaneSummarypayloads withdevicesOpen.
syncHostService wraps command results for lanes.list,
lanes.getDetail, lanes.refreshSnapshots, lanes.getChildren,
lanes.create, lanes.createChild, lanes.createFromUnstaged,
lanes.importBranch, lanes.attach, and lanes.adoptAttached to
inject LaneSummary.devicesOpen from the presence map. Controllers
therefore see up-to-date presence without a separate query.
createSyncRemoteCommandService takes a long list of optional runtime
services:
{
laneService, // always required
prService, // always required
ptyService, // always required
sessionService, // always required
fileService, // always required
gitService?,
diffService?,
conflictService?,
agentChatService?,
projectConfigService?,
portAllocationService?,
laneEnvironmentService?,
laneTemplateService?,
rebaseSuggestionService?,
autoRebaseService?,
logger,
}Optional services that are missing cause their dependent actions to
throw "<service> not available." at call time. The requireService
helper centralises that check. This pattern lets a narrower runtime
construct only the services it can actually back without crashing at
command registration — useful for headless/manual runtime setups that, for
example, intentionally skip the chat service.
The service exposes:
getSupportedActions(): SyncRemoteCommandAction[];
getDescriptors(): SyncRemoteCommandDescriptor[];
getPolicy(action: string): SyncRemoteCommandPolicy | null;
execute(payload: SyncCommandPayload): Promise<unknown>;Controllers typically read descriptors at connection time, cache
them, and refresh on brain-status broadcasts (brain_status). The iOS Lanes /
Files / Work / PRs tabs use this to render action buttons only for
commands the current runtime supports under the current policy.
Every execution logs sync.remote_command.execute at debug level
with the action and policy. Failed executions log at warn / error
from the underlying service. No args are logged by default — most
payloads are mundane, but chat text fields and file relPath values
can be sensitive.
- Changeset sync remains the channel for state reads. A
controller observes the effect of a command through replicated
lanes,sessions,linear_workflow_runs, etc. rows arriving after the runtime finishes the command. - Terminal sub-protocol pairs with
work.runQuickCommand,work.startCliSession,work.sendToSession, andwork.stopRuntime. The controller invokes the command, then sendsterminal_subscribewith the returned session id to stream output and enable input/resize control. - Chat sub-protocol pairs with
chat.create/chat.send+chat_subscribe. Same pattern: create / send the message through a command, subscribe to the transcript stream for incremental events.chat.sendwaits for the runtime-side dispatch acknowledgement before returningok, so the phone does not clear its local echo while the desktop is still preparing the turn. - File access sub-protocol (
file_request/file_response) is a separate envelope from remote commands; it handles large binary payloads and streaming reads outside the command surface to avoid bloating the command envelope.
parseAgentChatSendArgs and parseAgentChatSteerArgs accept the full
AgentChatSendArgs surface: sessionId, text, attachments (via
parseAgentChatFileRefs, array of { path, type: "file" | "image" }),
displayText, reasoningEffort, executionMode, interactionMode.
Steers accept sessionId, text, and attachments. Controllers
(phones and desktop peers) can therefore attach files/images and
specify reasoning / execution / interaction modes remotely; the
runtime-side agentChatService consumes the same shape end-to-end.
parseCreateLaneArgs / parseCreateChildLaneArgs accept an optional
linearIssue: LaneLinearIssue | null so a controller can create a
lane already attached to a Linear ticket; laneService.create
derives the branch name (linearIssueBranchName) and persists the
issue into lane_linear_issues.
parseCreatePrArgs and parseDraftPrDescriptionArgs accept
closeLinearIssueOnMerge: boolean. When the lane has a connected
issue, this flag drives whether prService injects Fixes IDENT
(closes the issue when the PR merges) or Refs IDENT (links
without closing) into the PR body via ensureLinearPrReference.
Brain-status (brain_status) envelopes carry the brain's LinearConnectionStatus,
which now includes optional organizationId, organizationName,
organizationUrlKey, and organizationLogoUrl fields populated by
the brain when the Linear workspace is connected. Controllers use
these to render the workspace brand on Linear-related surfaces
without fetching them separately.
parseChatModelsArgs accepts { provider, activateRuntime?, cursorSource? }
(cursorSource is "sdk" | "cli" | "all", mirroring chat.modelCatalog).
When chat.create is missing an explicit model, resolveChatCreateArgs
forwards activateRuntime: true only for the opencode provider so
the brain actually launches the OpenCode probe server before resolving
a default model. All other providers use passive (cache-only) resolution;
see the chat README for the passive/active contract. The iOS companion's
chat.models request sets activateRuntime: true for cursor/droid and
cursorSource: "sdk" for cursor so a fresh key surfaces SDK models on the
first fetch instead of returning an empty passive cache.
chat.modelsreturns the brain's model catalog. A controller must not hardcode model IDs. The brain is authoritative about which models are wired up, which providers have credentials, and what the default model is.lanes.deleteandlanes.archiveare queueable. A disconnected controller can enqueue deletes that replay on reconnect. Be aware when reasoning about "why did this lane disappear" — check the command queue, not just the local DB.prs.createFromLanerequires GitHub auth on the brain. Headless brains resolve auth the same way the desktop does: a stored PAT, then env tokens (ADE_GITHUB_TOKEN/GITHUB_TOKEN/GH_TOKEN), then theghCLI resolved from known absolute install locations (launchd's minimal PATH does not include Homebrew), then readinggh'shosts.ymloauth token directly (both host-level and nestedusers:<login>:token layouts). Only when none of those yield a token does the command fail with a clear error before reaching GitHub.work.runQuickCommandalways creates a PTY. There is no "run a command, give me just the output" variant; the controller must subscribe to the terminal stream and stop the process withwork.stopRuntime. A daemon configured without a real PTY service (rare; only used in some headless test harnesses) will surfacepty service not availablefor this command.work.startCliSessionprovider list is brain-controlled. The controller cannot passcommand/args/startupCommandoverrides — the brain derives those from the provider name throughbuildTrackedCliLaunchCommand. To add a new provider you extendapps/desktop/src/shared/cliLaunch.tsand theparseCliProviderallowlist together; a phone client that hardcodes the new id without a brain update will get a "requires provider" error.files.writeTextAtomicdoes not invoke git hooks or editors. It writes atomically to the lane worktree and that is all. Services that care about post-write side effects (lint, formatters) watch the filesystem independently.- Mobile file mutations are no longer read-only-gated. Files are
freely editable from the phone: the old
mobileReadOnly/ edit-protection write gate was removed on both sides (the iOSensureMobileFileMutationsAllowedcheck and the brain'sassertWriteAllowed/MOBILE_MUTATING_FILE_ACTIONSenforcement), matching the desktop edit-protection removal. ThemobileReadOnlyfield still rides the workspace payload but no longer blocks writes. Path-safety and the external-workspace block below are unchanged. - External desktop file opens are not mobile-visible. Desktop
files.openExternalPathworkspaces usekind: "external"andexternal-local:*ids. The sync host filters them from mobilelistWorkspacesand rejects every mobile file action that targets one, including reads and search, because those roots can point anywhere on the desktop user's local filesystem. requireServicethrows lazily. A runtime missing a service does not cause registration to fail; it causes the first invocation of a command that needs that service to fail with a specific message. Tests should exercise each command path rather than assume "registered means callable."- Policy is runtime-declared, not controller-configurable. The controller cannot opt itself into commands the runtime marked non-viewer-allowed. If a phone needs an action that is policy-gated, the fix is a runtime-side policy change, not a client workaround.