Workspace Controller
The phone asks the desktop to switch which workspace Cursor / VS Code has open. Destructive in the sense that it reloads the IDE window — handle accordingly in UI.
Capability
workspace_activationWithout this capability the server rejects open_workspace with errorCode: 'capability_not_negotiated'. Native clients negotiate it only when they actually expose a workspace-switch UI.
Wire shapes
Client → Server
{ "type": "open_workspace",
"requestId": "r-1",
"traceId": "t-...",
"path": "/root/projects/another-repo",
"mode": "reuseWindow" | "newWindow",
"source": "pwa" | "native-ios" | "native-android" // free-form telemetry hint
}Server → Client
// Acknowledgement BEFORE invoke (see "Ack-before-invoke" below)
{ "type": "workspace_opening",
"requestId": "r-1",
"traceId": "t-...",
"path": "/root/projects/another-repo",
"realPath": "/root/projects/another-repo", // canonical realpath
"mode": "reuseWindow",
"seqId": N
}
// Failure (validator rejected, or invoke failed)
{ "type": "workspace_open_failed",
"requestId": "r-1",
"traceId": "t-...",
"path": "/root/projects/another-repo", // SUBMITTED path, NOT realpath
"reason": "outside_allowed_roots" | "invalid_path" | "capability_not_negotiated" | <other>,
"detail": "Optional human-readable detail",
"seqId": N
}
// Implicit success signal — the NEXT `connected` payload after Cursor reloads
// carries the new workspacePath. There is NO explicit `workspace_opened`.
{ "type": "connected",
"workspacePath": "/root/projects/another-repo",
...
}Two modes — different reliability properties
reuseWindow
- Server validates the target, sends
workspace_openingACK. - Server invokes
vscode.openFolder(uri, { forceReuseWindow: true }). - The current Cursor window reloads. The extension host dies and reactivates in the new workspace.
- The phone’s WebSocket disconnects with
meaning: 'abnormal_closure'. - The phone reconnects. The next
connectedpayload’sworkspacePathis the authoritative success signal.
newWindow
- Server invokes
vscode.openFolder(uri, { forceNewWindow: true }). - The original window’s extension stays alive — the phone keeps controlling it.
- A new Cursor window opens, but its extension cannot bind port 18100 (EADDRINUSE) — that new window is NOT controllable from the phone in today’s protocol.
- The dialog should time out (15 s recommended) and the user should manually focus the new window on the desktop.
This asymmetry is a real limitation. Don’t promise multi-window control on newWindow until the architecture supports it (recent diagnostic findings on multi-window EADDRINUSE in docs/architecture/2026-05-20-system-architecture in the source repo for context).
Ack-before-invoke pattern
The extension’s MessageHandler sends workspace_opening BEFORE invoking vscode.openFolder. This is non-negotiable for reuseWindow because the extension dies within milliseconds of the invoke. If the ack hasn’t been sent yet, the client never knows the operation started.
The native client should:
- Send
open_workspace. - Wait for
workspace_openingORworkspace_open_failed(with a 15 s timeout). - If
workspace_openingarrived and mode wasreuseWindow, expect a disconnect within 1–2 s — show “Opening…” UI. - After reconnect, watch the next
connected.workspacePath. If it matches the realpath fromworkspace_opening, success. If not, the open didn’t actually take.
What native UI should render
- Open Workspace dialog:
- Path input or picker (constrained to entries the server reports as allowed — see
list_project_roots/list_projects). - Mode selector: reuseWindow / newWindow.
- “Open” button.
- Path input or picker (constrained to entries the server reports as allowed — see
- Pending state: after sending
open_workspace, show “Opening…” with a spinner and a 15 s timeout. - Success: dialog closes when the post-reload
connected.workspacePathmatches. Top of app updates to show the new workspace. - Failure: render
reason(open errorCode) +detail(human string). Don’t try to recover automatically; let the user retry or cancel.
Path validation
The server enforces projectRoots (an allowlist of directory prefixes) and a system-paths blocklist. The native client should not mirror this allowlist locally — let the server reject, and surface the reason as data. Mirrored allowlists drift; server-side allowlists don’t.
Multi-window risk to surface in UI
Per recent investigation, opening a second Cursor window (intentionally or via newWindow) creates a state where the new window’s extension cannot bind port 18100. If the phone is in local mode and connected to that second window, the phone shows “Offline.”
Native clients should:
- Surface the
environmentfield fromconnectedso the user can see which workstation they’re controlling. - Surface
workspacePathin the header so the user can see which workspace. - Be tolerant of disconnects after
open_workspace— they are expected forreuseWindow, not a bug.
What native UI must NOT do
- ❌ Open a workspace by manipulating files directly (e.g., writing a
.code-workspacefile). Onlyopen_workspaceis the supported path. - ❌ Try to “auto-recover” from
workspace_open_failedby retrying with a different mode. Show the error; let the user choose. - ❌ Cache
realPathand assume it’s stable across server restarts. Always trust the latestconnected.workspacePath.
Reference implementation
- Wire types:
packages/protocol/src/messages.ts—OpenWorkspaceMessage,WorkspaceOpeningMessage,WorkspaceOpenFailedMessage. - PWA dialog:
packages/web/src/components/OpenWorkspaceDialog.tsx. - PWA dispatcher:
packages/web/src/core/dispatcher.ts—case 'workspace_opening',case 'workspace_open_failed'. - Server side:
packages/extension/src/workspace-controller.ts(the validator).