feat(vscodex): add remote Codex collaboration module

This commit is contained in:
fawney
2026-09-01 20:08:36 +08:00
parent 1aab31a148
commit 5059093d29
102 changed files with 38569 additions and 11 deletions
@@ -0,0 +1,3 @@
node_modules/
dist/
*.vsix
@@ -0,0 +1,9 @@
src/**
.gitignore
tsconfig.json
**/*.map
node_modules/@types/**
node_modules/typescript/**
node_modules/.package-lock.json
*.tsbuildinfo
*.vsix
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Codex Remote Collaboration contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+237
View File
@@ -0,0 +1,237 @@
# Codex Remote Collaboration VS Code Bridge
This extension connects local and Aether relay channels to one switchable Codex
control host. **Synchronous mode** follows the conversation currently shown by
the official Codex VS Code extension through its private IPC protocol and does
not spawn a `codex` process. **Asynchronous mode** starts an independent
app-server and lets the Web UI list, resume, create, and select conversations.
The attached conversation remains visible and usable in the official Codex
panel. Remote operators can observe its output, submit a new turn or steer the
active turn, interrupt it, and answer supported approval/input requests.
The mode can be changed from the Web UI without reconnecting either relay.
Synchronous mode makes the official panel the only conversation-navigation
owner; asynchronous mode restores the browser history and new-conversation
actions. A running turn or pending request blocks mode changes.
## Requirements
- The official `openai.chatgpt` VS Code extension is installed and signed in.
- The target Codex conversation is open and owned by that extension.
- The bridge and official extension run as the same OS user. The default Unix
socket is `$CODEX_HOME/ipc/ipc.sock`, normally `~/.codex/ipc/ipc.sock`.
- For a loopback `ws://` URL, the extension starts and owns its bundled relay
automatically. Remote and `wss://` relay URLs remain externally hosted.
The IPC follower protocol is private and versioned, not a public OpenAI API.
An official extension update can require a compatible bridge update. Strict
stream-version checks are enabled by default so an unknown protocol fails
closed instead of being interpreted optimistically.
## Build and install
```sh
npm install
npm run check
npm run build
npx --yes @vscode/vsce package
code --install-extension codex-remote-collab-0.4.0.vsix --force
```
Run **Developer: Reload Window** after installing or replacing the VSIX.
## Configure control modes
For the local default, no separate relay command is required. The extension
starts the bundled relay on the host and port from `codexRemoteCollab.localRelayUrl`.
To run the development relay manually, disable
`codexRemoteCollab.autoStartLocalRelay` and use:
```sh
HOST=127.0.0.1 PORT=8787 CODEX_REMOTE_MODE=host npm start
```
To opt into authentication later, set `CODEX_REMOTE_AUTH=required` and the three
token variables before starting the relay.
Set the extension configuration:
```json
{
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
"codexRemoteCollab.controlMode": "sync",
"codexRemoteCollab.autoDiscoverThread": true,
"codexRemoteCollab.autoStart": true
}
```
Then:
1. Open the target conversation in the official Codex panel.
2. Reload VS Code once after installing the companion extension. The local relay and bridge start automatically; no token is needed for loopback. The status item opens the Web console and is not a connect/disconnect toggle.
3. Open the relay web console; it connects automatically on localhost. The web UI uses a
Codex-style conversation stream with a bottom composer; Enter sends and Shift+Enter
inserts a newline. There is no separate connect/disconnect step for the local relay.
If the browser says that it is waiting for the VS Code host or the recent-session list is
empty, verify that `codexRemoteCollab.localRelayUrl` uses the same port as the relay and run
**Developer: Reload Window**. Keep `codexRemoteCollab.threadId` empty unless a specific
conversation must be pinned; an old closed ID can prevent startup until it is cleared.
When authentication is enabled, run **Codex Remote: Set Relay Token** with the
host token. It is stored in `vscode.SecretStorage`, not in settings; the browser
uses the operator or viewer token separately.
With no configured thread ID, the bridge ranks recent VS Code rollout metadata
and shows only candidates verified by live IPC owner discovery and a matching
follower snapshot. Explicit Codex Desktop tasks, closed, stale, and other
non-attachable history entries are omitted.
In synchronous mode, switching the conversation in the official Codex panel
also switches the Web projection after the new owner snapshot is ready. The
Web UI cannot list, select, or create conversations in this mode. Switch to
asynchronous mode when the browser should own conversation navigation.
To avoid ambiguity when several Codex windows are open, run **Codex Remote: Set Existing Thread ID**.
An empty value restores automatic discovery.
Useful commands:
- **Codex Remote: Start Bridge** / **Stop Bridge**
- **Codex Remote: Set Existing Thread ID**
- **Codex Remote: Set Relay Token**
- **Codex Remote: Pair with Aether**
- **Codex Remote: Configure Aether Cloud Relay**
- **Codex Remote: Send Input**
- **Codex Remote: Show Snapshot**
## Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| `codexRemoteCollab.controlMode` | `sync` | `sync` follows VS Code; `async` owns an independent app-server. |
| `codexRemoteCollab.localRelayUrl` | `ws://127.0.0.1:8787/v1/connect` | Bundled loopback relay used by the local Web control. |
| `codexRemoteCollab.aetherUrl` | empty | Aether origin remembered by the pairing command. |
| `codexRemoteCollab.cloudRelayUrl` | empty | Aether WebSocket relay URL populated by pairing. |
| `codexRemoteCollab.threadId` | empty | Exact existing conversation ID; empty enables discovery. |
| `codexRemoteCollab.autoDiscoverThread` | `true` | Discover and owner-check a local VS Code session. |
| `codexRemoteCollab.followVscodeSession` | `true` | Legacy compatibility setting; synchronous mode always follows VS Code. |
| `codexRemoteCollab.ipcSocketPath` | empty | Override the local IPC socket path. |
| `codexRemoteCollab.hostId` | `local` | Owner-discovery host identifier. |
| `codexRemoteCollab.ipcStrictVersions` | `true` | Reject unsupported stream protocol versions. |
| `codexRemoteCollab.approvalTimeoutMs` | `300000` | Deny an unanswered request locally after this delay. |
| `codexRemoteCollab.allowHighRiskApprovals` | `false` | Permit remote high-risk approvals when explicitly enabled. |
`codexRemoteCollab.codexCommand`, `codexArgs`, and `defaultCwd` apply only to
asynchronous mode. The deprecated `mode=attach/spawn` values map to
`controlMode=sync/async` when no explicit control mode exists.
## Pair with Aether
The local relay stays enabled after cloud pairing. In Aether, open **Codex remote
control** and generate a one-time code. Then run **Codex Remote: Pair with Aether**
from the VS Code Command Palette, enter the Aether server URL and the code, and
the bridge will connect to both relays. The long-lived device credential is stored
only in VS Code SecretStorage. Revoke a lost or retired device from the Aether page.
## Relay behavior
The bridge sends a `hello` and, when a relay token is configured, a separate
bearer-auth frame over an outbound WebSocket. It publishes normalized events including:
- `connection.opened` / `connection.closed`
- `session.snapshot`
- `output.snapshot` / `output.chunk`
- `task.started` / `task.finished` / `task.cancelled`
- `approval.requested` / `approval.resolved` / `approval.expired`
- `input.requested` / `input.resolved` / `input.expired`
Remote commands are mapped to the existing conversation owner:
- `control/mode/set` atomically switches between `sync` and `async`.
- `session/list`, `session/select`, and `session/new` are available only in
asynchronous mode and map to `thread/list`, `thread/resume`, and `thread/start`.
- `turn/start` starts a turn in the attached thread.
- `turn/steer` adds input to the active turn.
- `turn/interrupt` interrupts the expected active turn.
- `approval.respond`, `input.respond`, and `server.request.respond` preserve the
original request ID and use method-specific follower responses.
- `thread/start` is deliberately rejected in synchronous mode because VS Code
owns conversation navigation there.
The browser never connects directly to the IPC socket. Relay and host both
enforce role/capability checks; high-risk command approval remains disabled
unless the local VS Code setting opts in.
## Supported follower requests
- `item/commandExecution/requestApproval`
- `item/fileChange/requestApproval`
- `item/permissions/requestApproval`
- `item/tool/requestUserInput`
- `mcpServer/elicitation/request`
- legacy `applyPatchApproval` and `execCommandApproval`
Unanswered requests expire with a local deny. JSON-RPC numeric and string IDs
remain distinct, and a response can be submitted only once.
## Legacy mode migration
The old setting remains accepted:
```json
{
"codexRemoteCollab.mode": "spawn",
"codexRemoteCollab.codexCommand": "/absolute/path/to/codex",
"codexRemoteCollab.codexArgs": ["app-server", "--stdio"]
}
```
It maps to `controlMode=async`. Prefer the new setting directly. A
`spawn codex ENOENT` error belongs only to asynchronous mode; it is not a
synchronous-mode prerequisite or a PATH problem that needs fixing for
existing-session control.
The standalone `npm run start:stdio` entry point and `createBridge()` helper
also retain the legacy app-server adapter for compatibility.
## Embedding the attach adapter
The reusable exports are in `src/index.ts`:
```ts
import {
CodexIpcAgentAdapter,
RelayClient,
RelayHost,
} from "codex-remote-collab";
const adapter = new CodexIpcAgentAdapter({
threadId: process.env.CODEX_THREAD_ID,
autoDiscoverThread: true,
});
const relay = new RelayClient({
url: "wss://relay.example.test/v1/connect",
accessToken: process.env.CODEX_REMOTE_HOST_TOKEN,
});
const host = new RelayHost({ adapter, relay });
await host.start();
```
`CodexIpcClient` is exported separately for protocol fixtures and diagnostics.
Use `followConversation()` before follower mutations, and always target the
owner returned by `findThreadOwner()`.
## Troubleshooting
- **No existing session found:** open the target official Codex conversation,
keep that VS Code window running, then retry or set its exact thread ID.
- **Owner not found:** the rollout exists on disk but no live official client
currently owns it. Reopen the conversation in the Codex panel.
- **IPC version mismatch:** update this bridge for the installed official
extension. Disabling strict versions is diagnostic only.
- **Relay stays at waiting for host:** confirm host mode, relay URL, and that no
second host is already connected. If authentication is enabled, also check the
host token.
- **Old `spawn codex ENOENT` message:** install version `0.4.0`, reload VS Code,
and verify `codexRemoteCollab.controlMode` is `sync` unless independent
conversations are intended.
@@ -0,0 +1,56 @@
{
"A non-empty Aether device credential is required.": "A non-empty Aether device credential is required.",
"Aether cloud connection removed. Local control remains enabled.": "Aether cloud connection removed. Local control remains enabled.",
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.",
"Aether cloud relay WebSocket URL": "Aether cloud relay WebSocket URL",
"Aether pairing completed. Local and cloud control are both active.": "Aether pairing completed. Local and cloud control are both active.",
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.",
"Aether returned an invalid pairing response.": "Aether returned an invalid pairing response.",
"Aether server URL": "Aether server URL",
"Attached to the existing Codex conversation. Click to open the web control.": "Attached to the existing Codex conversation. Click to open the web control.",
"Bridge connected. Click to open the web control.": "Bridge connected. Click to open the web control.",
"Bridge paused. Click to open the web control and resume automatically.": "Bridge paused. Click to open the web control and resume automatically.",
"Codex Remote Collaboration": "Codex Remote Collaboration",
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote will attach to {0} after the next bridge start.",
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.",
"Connecting to the local Codex collaboration service": "Connecting to the local Codex collaboration service",
"Device credential from the Aether pairing flow": "Device credential from the Aether pairing flow",
"Enter a valid URL.": "Enter a valid URL.",
"Enter a valid WebSocket URL.": "Enter a valid WebSocket URL.",
"Enter the 8-character pairing code.": "Enter the 8-character pairing code.",
"Enter the Aether server URL.": "Enter the Aether server URL.",
"Existing Codex conversation ID (leave blank for auto-discovery)": "Existing Codex conversation ID (leave blank for auto-discovery)",
"Independent Codex mode is connected. Click to open the web control.": "Independent Codex mode is connected. Click to open the web control.",
"One-time pairing code shown in Aether": "One-time pairing code shown in Aether",
"Relay access token (leave blank for the local relay)": "Relay access token (leave blank for the local relay)",
"Relay token stored in VS Code SecretStorage.": "Relay token stored in VS Code SecretStorage.",
"Remote Aether connections must use wss://.": "Remote Aether connections must use wss://.",
"Remote Aether servers must use https://.": "Remote Aether servers must use https://.",
"Restoring the local collaboration service": "Restoring the local collaboration service",
"Send input to the active Codex turn": "Send input to the active Codex turn",
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "Set codexRemoteCollab.localRelayUrl before starting the bridge.",
"Start the Codex remote bridge first.": "Start the Codex remote bridge first.",
"Starting the independent Codex mode.": "Starting the independent Codex mode.",
"Starting {0}": "Starting {0}",
"The Codex conversation is not connected": "The Codex conversation is not connected",
"The Codex executable is unavailable": "The Codex executable is unavailable",
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "The Codex remote bridge attached to the existing VS Code Codex conversation.",
"The Codex remote bridge is already running.": "The Codex remote bridge is already running.",
"The Codex remote collaboration bridge connected.": "The Codex remote collaboration bridge connected.",
"The independent Codex mode is not connected": "The independent Codex mode is not connected",
"The independent Codex remote mode connected.": "The independent Codex remote mode connected.",
"The bridge is not connected": "The bridge is not connected",
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.",
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.",
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.",
"Unable to pair with Aether: {0}": "Unable to pair with Aether: {0}",
"Unable to restore the local collaboration service": "Unable to restore the local collaboration service",
"Unable to send Codex input: {0}": "Unable to send Codex input: {0}",
"Unable to start the Codex remote bridge: {0}": "Unable to start the Codex remote bridge: {0}",
"Unable to start the local Codex collaboration service: {0}": "Unable to start the local Codex collaboration service: {0}",
"Unable to start the local collaboration service: {0}": "Unable to start the local collaboration service: {0}",
"Use a ws:// or wss:// URL.": "Use a ws:// or wss:// URL.",
"Use the Aether origin without credentials, a query, or a fragment.": "Use the Aether origin without credentials, a query, or a fragment.",
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "Waiting for a Codex conversation to open in VS Code. It will connect automatically.",
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl must be a loopback ws:// address."
}
@@ -0,0 +1,56 @@
{
"A non-empty Aether device credential is required.": "必须填写 Aether 设备凭据。",
"Aether cloud connection removed. Local control remains enabled.": "已移除 Aether 云端连接,本地控制仍然可用。",
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "已保存 Aether 云端连接。重启 Codex Remote 桥接后即可连接,本地控制仍然可用。",
"Aether cloud relay WebSocket URL": "Aether 云端 relay WebSocket 地址",
"Aether pairing completed. Local and cloud control are both active.": "Aether 配对完成,本地与云端控制均已启用。",
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether 配对信息已保存,但当前无法连接云端。本地控制仍然可用,云端连接会继续重试。",
"Aether returned an invalid pairing response.": "Aether 返回了无效的配对响应。",
"Aether server URL": "Aether 服务器地址",
"Attached to the existing Codex conversation. Click to open the web control.": "已附加到现有 Codex 会话,点击打开 Web 控制页。",
"Bridge connected. Click to open the web control.": "桥接已连接,点击打开 Web 控制页。",
"Bridge paused. Click to open the web control and resume automatically.": "桥接已暂停,点击打开 Web 控制页时会自动恢复。",
"Codex Remote Collaboration": "Codex 远程协同",
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote 将在下次启动桥接后附加到 {0}。",
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote 将在下次启动桥接后自动发现最新的 VS Code Codex 会话。",
"Connecting to the local Codex collaboration service": "正在连接本地 Codex 协同服务",
"Device credential from the Aether pairing flow": "Aether 配对流程生成的设备凭据",
"Enter a valid URL.": "请输入有效的 URL。",
"Enter a valid WebSocket URL.": "请输入有效的 WebSocket URL。",
"Enter the 8-character pairing code.": "请输入 8 位配对码。",
"Enter the Aether server URL.": "请输入 Aether 服务器地址。",
"Existing Codex conversation ID (leave blank for auto-discovery)": "现有 Codex 会话 ID(留空则自动发现)",
"Independent Codex mode is connected. Click to open the web control.": "独立 Codex 模式已连接,点击打开 Web 控制页。",
"One-time pairing code shown in Aether": "Aether 中显示的一次性配对码",
"Relay access token (leave blank for the local relay)": "Relay 访问 token(本地 relay 请留空)",
"Relay token stored in VS Code SecretStorage.": "Relay token 已保存到 VS Code SecretStorage。",
"Remote Aether connections must use wss://.": "远程 Aether 连接必须使用 wss://。",
"Remote Aether servers must use https://.": "远程 Aether 服务器必须使用 https://。",
"Restoring the local collaboration service": "正在恢复本地协同服务",
"Send input to the active Codex turn": "向当前 Codex turn 发送输入",
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "请先设置 codexRemoteCollab.localRelayUrl,再启动桥接。",
"Start the Codex remote bridge first.": "请先启动 Codex 远程桥接。",
"Starting the independent Codex mode.": "正在启动独立 Codex 模式。",
"Starting {0}": "正在启动 {0}",
"The Codex conversation is not connected": "Codex 会话尚未连接",
"The Codex executable is unavailable": "Codex 可执行文件不可用",
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "Codex 远程桥接已附加到现有 VS Code Codex 会话。",
"The Codex remote bridge is already running.": "Codex 远程桥接已在运行。",
"The Codex remote collaboration bridge connected.": "Codex 远程协同桥接已连接。",
"The independent Codex mode is not connected": "独立 Codex 模式尚未连接",
"The independent Codex remote mode connected.": "独立 Codex 远程模式已连接。",
"The bridge is not connected": "桥接尚未连接",
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "本地协同地址无效,请检查 codexRemoteCollab.localRelayUrl。",
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "本地协同服务 {0} 暂时无法连接,扩展会继续重试。",
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "未找到官方 Codex 扩展的新会话命令,请确认 VS Code Codex 扩展已启用。",
"Unable to pair with Aether: {0}": "无法与 Aether 配对:{0}",
"Unable to restore the local collaboration service": "无法恢复本地协同服务",
"Unable to send Codex input: {0}": "无法发送 Codex 输入:{0}",
"Unable to start the Codex remote bridge: {0}": "无法启动 Codex 远程桥接:{0}",
"Unable to start the local Codex collaboration service: {0}": "无法启动本地 Codex 协同服务:{0}",
"Unable to start the local collaboration service: {0}": "无法启动本地协同服务:{0}",
"Use a ws:// or wss:// URL.": "请使用 ws:// 或 wss:// URL。",
"Use the Aether origin without credentials, a query, or a fragment.": "请填写不含凭据、查询参数或片段的 Aether 源地址。",
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "正在等待 VS Code 中打开 Codex 会话,检测到后会自动连接。",
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl 必须是回环地址上的 ws:// URL。"
}
+94
View File
@@ -0,0 +1,94 @@
{
"name": "codex-remote-collab",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-remote-collab",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/vscode": "^1.85.0",
"@types/ws": "^8.5.12",
"typescript": "^5.4.5"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.134.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz",
"integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
@@ -0,0 +1,211 @@
{
"name": "codex-remote-collab",
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"version": "0.4.0",
"publisher": "local",
"license": "MIT",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Other"
],
"l10n": "./l10n",
"activationEvents": [
"onStartupFinished",
"onCommand:codexRemoteCollab.openWeb",
"onCommand:codexRemoteCollab.start",
"onCommand:codexRemoteCollab.stop",
"onCommand:codexRemoteCollab.setThreadId",
"onCommand:codexRemoteCollab.sendInput",
"onCommand:codexRemoteCollab.setRelayToken",
"onCommand:codexRemoteCollab.configureCloud",
"onCommand:codexRemoteCollab.pairCloud",
"onCommand:codexRemoteCollab.snapshot"
],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "codexRemoteCollab.openWeb",
"title": "%command.openWeb%"
},
{
"command": "codexRemoteCollab.start",
"title": "%command.start%"
},
{
"command": "codexRemoteCollab.stop",
"title": "%command.stop%"
},
{
"command": "codexRemoteCollab.setThreadId",
"title": "%command.setThreadId%"
},
{
"command": "codexRemoteCollab.sendInput",
"title": "%command.sendInput%"
},
{
"command": "codexRemoteCollab.setRelayToken",
"title": "%command.setRelayToken%"
},
{
"command": "codexRemoteCollab.configureCloud",
"title": "%command.configureCloud%"
},
{
"command": "codexRemoteCollab.pairCloud",
"title": "%command.pairCloud%"
},
{
"command": "codexRemoteCollab.snapshot",
"title": "%command.snapshot%"
}
],
"configuration": {
"title": "%configuration.title%",
"properties": {
"codexRemoteCollab.localRelayUrl": {
"type": "string",
"default": "ws://127.0.0.1:8787/v1/connect",
"description": "%configuration.localRelayUrl%"
},
"codexRemoteCollab.relayUrl": {
"type": "string",
"default": "ws://127.0.0.1:8787/v1/connect",
"description": "%configuration.relayUrl%",
"deprecationMessage": "%configuration.relayUrl.deprecation%"
},
"codexRemoteCollab.cloudRelayUrl": {
"type": "string",
"default": "",
"description": "%configuration.cloudRelayUrl%"
},
"codexRemoteCollab.aetherUrl": {
"type": "string",
"default": "",
"description": "%configuration.aetherUrl%"
},
"codexRemoteCollab.autoStart": {
"type": "boolean",
"default": true,
"description": "%configuration.autoStart%"
},
"codexRemoteCollab.autoStartLocalRelay": {
"type": "boolean",
"default": true,
"description": "%configuration.autoStartLocalRelay%"
},
"codexRemoteCollab.mode": {
"type": "string",
"enum": [
"attach",
"spawn"
],
"default": "attach",
"description": "%configuration.mode%",
"deprecationMessage": "%configuration.mode.deprecation%"
},
"codexRemoteCollab.controlMode": {
"type": "string",
"enum": [
"sync",
"async"
],
"enumDescriptions": [
"%configuration.controlMode.sync%",
"%configuration.controlMode.async%"
],
"default": "sync",
"description": "%configuration.controlMode%"
},
"codexRemoteCollab.threadId": {
"type": "string",
"default": "",
"description": "%configuration.threadId%"
},
"codexRemoteCollab.autoDiscoverThread": {
"type": "boolean",
"default": true,
"description": "%configuration.autoDiscoverThread%"
},
"codexRemoteCollab.followVscodeSession": {
"type": "boolean",
"default": true,
"description": "%configuration.followVscodeSession%"
},
"codexRemoteCollab.ipcSocketPath": {
"type": "string",
"default": "",
"description": "%configuration.ipcSocketPath%"
},
"codexRemoteCollab.hostId": {
"type": "string",
"default": "local",
"description": "%configuration.hostId%"
},
"codexRemoteCollab.ipcStrictVersions": {
"type": "boolean",
"default": true,
"description": "%configuration.ipcStrictVersions%"
},
"codexRemoteCollab.codexCommand": {
"type": "string",
"default": "codex",
"description": "%configuration.codexCommand%"
},
"codexRemoteCollab.codexArgs": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"app-server",
"--stdio"
],
"description": "%configuration.codexArgs%"
},
"codexRemoteCollab.defaultCwd": {
"type": "string",
"default": "",
"description": "%configuration.defaultCwd%"
},
"codexRemoteCollab.approvalTimeoutMs": {
"type": "number",
"default": 300000,
"minimum": 1000,
"description": "%configuration.approvalTimeoutMs%"
},
"codexRemoteCollab.allowHighRiskApprovals": {
"type": "boolean",
"default": false,
"description": "%configuration.allowHighRiskApprovals%"
},
"codexRemoteCollab.relayReconnect": {
"type": "boolean",
"default": true,
"description": "%configuration.relayReconnect%"
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run build:web && npm run build",
"build:web": "npm --prefix ../web run build",
"build": "tsc -p tsconfig.json && node scripts/sync-local-relay.cjs",
"compile": "npm run build",
"check": "tsc --noEmit -p tsconfig.json",
"start:stdio": "node dist/cli.js"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/vscode": "^1.85.0",
"@types/ws": "^8.5.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,38 @@
{
"extension.displayName": "Codex Remote Collaboration",
"extension.description": "Synchronize the current VS Code Codex conversation or manage independent Codex conversations from a local browser and Aether cloud.",
"command.openWeb": "Codex Remote: Open Local Web Console",
"command.start": "Codex Remote: Start Bridge",
"command.stop": "Codex Remote: Stop Bridge",
"command.setThreadId": "Codex Remote: Set Existing Thread ID",
"command.sendInput": "Codex Remote: Send Input",
"command.setRelayToken": "Codex Remote: Set Local Relay Token",
"command.configureCloud": "Codex Remote: Configure Aether Cloud Manually",
"command.pairCloud": "Codex Remote: Pair with Aether",
"command.snapshot": "Codex Remote: Show Snapshot",
"configuration.title": "Codex Remote Collaboration",
"configuration.localRelayUrl": "Loopback relay used by the local browser UI. It remains active when Aether cloud sync is enabled.",
"configuration.relayUrl": "Legacy relay setting retained for compatibility. Use localRelayUrl and cloudRelayUrl for new installations.",
"configuration.relayUrl.deprecation": "Use codexRemoteCollab.localRelayUrl for local access and codexRemoteCollab.cloudRelayUrl for Aether cloud access.",
"configuration.cloudRelayUrl": "Optional Aether cloud relay WebSocket URL. The device credential is stored separately in VS Code SecretStorage.",
"configuration.aetherUrl": "Aether server origin used by the one-time pairing flow.",
"configuration.autoStart": "Start the bridge when the extension activates.",
"configuration.autoStartLocalRelay": "Automatically host the bundled relay for loopback ws:// URLs.",
"configuration.mode": "Attach to the existing official VS Code Codex session, or spawn a separate app-server for legacy use.",
"configuration.mode.deprecation": "Use codexRemoteCollab.controlMode. attach maps to sync and spawn maps to async.",
"configuration.controlMode": "Choose whether the web console follows the current VS Code Codex conversation or manages independent conversations.",
"configuration.controlMode.sync": "Synchronize with the conversation currently shown in the official VS Code Codex panel.",
"configuration.controlMode.async": "Run an independent Codex app-server and manage its conversations from the web console.",
"configuration.threadId": "Existing VS Code Codex conversation ID to follow. Empty uses the most recent locally available session.",
"configuration.autoDiscoverThread": "Discover a recent VS Code Codex conversation when no thread ID is configured.",
"configuration.followVscodeSession": "Follow conversation changes in the attached official VS Code Codex panel.",
"configuration.ipcSocketPath": "Optional official Codex IPC socket path. Empty uses CODEX_HOME/ipc/ipc.sock.",
"configuration.hostId": "Codex host identifier used for existing-session discovery.",
"configuration.ipcStrictVersions": "Reject unknown private IPC stream versions instead of applying them optimistically.",
"configuration.codexCommand": "Asynchronous mode: Codex executable used to launch the independent app-server.",
"configuration.codexArgs": "Asynchronous mode: arguments passed to the Codex executable.",
"configuration.defaultCwd": "Asynchronous mode: working directory used when starting a conversation.",
"configuration.approvalTimeoutMs": "Milliseconds before an unanswered Codex approval or input request is denied locally.",
"configuration.allowHighRiskApprovals": "Allow the remote operator to approve high-risk commands. Keep disabled unless the relay and host are tightly controlled.",
"configuration.relayReconnect": "Reconnect outbound relay WebSockets after a disconnect."
}
@@ -0,0 +1,38 @@
{
"extension.displayName": "Codex 远程协同",
"extension.description": "从本地浏览器或 Aether 云端同步 VS Code 当前 Codex 会话,或独立管理 Codex 会话。",
"command.openWeb": "Codex 远程:打开本地 Web 控制台",
"command.start": "Codex 远程:启动桥接",
"command.stop": "Codex 远程:停止桥接",
"command.setThreadId": "Codex 远程:设置现有会话 ID",
"command.sendInput": "Codex 远程:发送输入",
"command.setRelayToken": "Codex 远程:设置本地中继令牌",
"command.configureCloud": "Codex 远程:手动配置 Aether 云端",
"command.pairCloud": "Codex 远程:与 Aether 配对",
"command.snapshot": "Codex 远程:显示会话快照",
"configuration.title": "Codex 远程协同",
"configuration.localRelayUrl": "本地浏览器控制台使用的回环中继地址。启用 Aether 云同步后仍保持连接。",
"configuration.relayUrl": "为兼容旧版本保留的中继设置。新安装请使用 localRelayUrl 和 cloudRelayUrl。",
"configuration.relayUrl.deprecation": "本地访问请使用 codexRemoteCollab.localRelayUrl,Aether 云端访问请使用 codexRemoteCollab.cloudRelayUrl。",
"configuration.cloudRelayUrl": "可选的 Aether 云端 WebSocket 中继地址。设备凭据单独保存在 VS Code SecretStorage 中。",
"configuration.aetherUrl": "一次性配对流程使用的 Aether 服务地址。",
"configuration.autoStart": "扩展激活时自动启动桥接。",
"configuration.autoStartLocalRelay": "为回环 ws:// 地址自动启动扩展内置的本地中继。",
"configuration.mode": "附加到官方 VS Code Codex 现有会话,或为兼容旧版本启动独立 app-server。",
"configuration.mode.deprecation": "请改用 codexRemoteCollab.controlMode。attach 对应 sync,spawn 对应 async。",
"configuration.controlMode": "选择 Web 控制台是跟随 VS Code 当前 Codex 会话,还是独立管理会话。",
"configuration.controlMode.sync": "同步展示官方 VS Code Codex 面板当前打开的会话。",
"configuration.controlMode.async": "启动独立 Codex app-server,并从 Web 控制台管理其会话。",
"configuration.threadId": "要跟随的现有 VS Code Codex 会话 ID。留空时使用本机最近可附加的会话。",
"configuration.autoDiscoverThread": "未设置会话 ID 时自动发现最近的 VS Code Codex 会话。",
"configuration.followVscodeSession": "自动跟随官方 VS Code Codex 面板中的会话切换。",
"configuration.ipcSocketPath": "可选的官方 Codex IPC socket 路径。留空时使用 CODEX_HOME/ipc/ipc.sock。",
"configuration.hostId": "现有会话发现使用的 Codex 主机标识。",
"configuration.ipcStrictVersions": "拒绝未知的私有 IPC 流版本,不进行乐观兼容。",
"configuration.codexCommand": "异步模式:用于启动独立 app-server 的 Codex 可执行文件。",
"configuration.codexArgs": "异步模式:传给 Codex 可执行文件的参数。",
"configuration.defaultCwd": "异步模式:启动会话时使用的工作目录。",
"configuration.approvalTimeoutMs": "Codex 授权或输入请求无人处理时,在本地拒绝前等待的毫秒数。",
"configuration.allowHighRiskApprovals": "允许远程操作员批准高风险命令。仅在中继和主机均受严格控制时启用。",
"configuration.relayReconnect": "中继 WebSocket 断开后自动重连。"
}
@@ -0,0 +1,19 @@
const fs = require("node:fs");
const path = require("node:path");
const extensionRoot = path.resolve(__dirname, "..");
const projectRoot = path.resolve(extensionRoot, "..");
const outputRoot = path.join(extensionRoot, "dist", "local-relay");
const publicRoot = path.join(extensionRoot, "dist", "public");
const vuePublicRoot = path.join(projectRoot, "web", "dist");
if (!fs.existsSync(path.join(vuePublicRoot, "index.html"))) {
throw new Error("web/dist is missing; run npm run build:web before building the extension");
}
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.rmSync(publicRoot, { recursive: true, force: true });
fs.mkdirSync(outputRoot, { recursive: true });
fs.mkdirSync(publicRoot, { recursive: true });
fs.copyFileSync(path.join(projectRoot, "relay", "server.js"), path.join(outputRoot, "server.js"));
fs.cpSync(vuePublicRoot, publicRoot, { recursive: true });
@@ -0,0 +1,46 @@
import { CodexAgentAdapter, CodexAgentAdapterOptions } from "./codexAgentAdapter";
import { RelayClient, RelayClientOptions } from "./relayClient";
import { RelayHost, RelayHostOptions } from "./relayHost";
import { AgentAdapter, Logger, RelayTransport } from "./protocol";
export interface CodexRemoteBridgeOptions {
/** Use a supplied adapter/transport when embedding or testing. */
adapter?: AgentAdapter;
relay?: RelayTransport;
adapterOptions?: CodexAgentAdapterOptions;
relayOptions?: RelayClientOptions;
sessionId?: string;
capabilities?: Iterable<string>;
logger?: Logger;
}
export interface CodexRemoteBridge {
adapter: AgentAdapter;
relay: RelayTransport;
host: RelayHost;
start(): Promise<void>;
stop(): Promise<void>;
}
/** Construct the default outbound VS Code bridge in one call. */
export function createBridge(options: CodexRemoteBridgeOptions): CodexRemoteBridge {
const adapter = options.adapter ?? new CodexAgentAdapter(options.adapterOptions);
const relay = options.relay ?? (() => {
if (!options.relayOptions) throw new Error("relayOptions are required when no relay transport is supplied");
return new RelayClient(options.relayOptions);
})();
const hostOptions: RelayHostOptions = {
adapter,
relay,
...(options.sessionId ? { sessionId: options.sessionId } : {}),
...(options.capabilities ? { capabilities: options.capabilities } : {}),
...(options.logger ? { logger: options.logger } : {}),
};
const host = new RelayHost(hostOptions);
return {
adapter,
relay,
host,
start: () => host.start(),
stop: () => host.stop(),
};
}
@@ -0,0 +1,30 @@
import { CodexAgentAdapter } from "./codexAgentAdapter";
import { RelayHost } from "./relayHost";
import { StdioRelayTransport } from "./relayClient";
/** Standalone bridge: relay frames in stdin, relay frames out on stdout. */
async function main(): Promise<void> {
const logger = {
debug: (message: string, ...args: unknown[]) => console.error(`[debug] ${message}`, ...args),
info: (message: string, ...args: unknown[]) => console.error(`[info] ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => console.error(`[warn] ${message}`, ...args),
error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
};
const command = process.env.CODEX_COMMAND || "codex";
const args = process.env.CODEX_APP_SERVER_ARGS ? JSON.parse(process.env.CODEX_APP_SERVER_ARGS) as string[] : ["app-server", "--stdio"];
const adapter = new CodexAgentAdapter({ command, args, defaultCwd: process.env.CODEX_WORKSPACE, logger });
const relay = new StdioRelayTransport(process.stdin, process.stdout, logger);
const host = new RelayHost({ adapter, relay, sendHandshake: true, logger });
const shutdown = async (): Promise<void> => {
await host.stop();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
await host.start();
}
void main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exitCode = 1;
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,947 @@
/**
* Minimal client for the private Codex desktop/VS Code coordination socket.
*
* This is intentionally separate from the app-server (JSONL/stdio) adapter.
* It attaches to the already running Codex UI through the local IPC router and
* therefore does not spawn another `codex` process. The wire protocol is
* private and versioned by the official extension; keep this module isolated
* so a protocol change can fail without taking down the relay bridge.
*/
import * as crypto from "node:crypto";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import type { JsonObject, JsonValue } from "./protocol";
export const INITIALIZING_CLIENT_ID = "initializing-client";
export const DEFAULT_IPC_REQUEST_TIMEOUT_MS = 5_000;
export const DEFAULT_MAX_IPC_FRAME_BYTES = 256 * 1024 * 1024;
/** Versions shipped by openai.chatgpt 26.820.71523. */
export const CODEX_IPC_METHOD_VERSIONS = Object.freeze({
"thread-stream-state-changed": 11,
"thread-stream-following-changed": 1,
"thread-stream-following-status-requested": 1,
"ipc-connection-reset": 1,
"thread-read-state-changed": 2,
"thread-archived": 2,
"thread-unarchived": 1,
"thread-owner-discovery": 1,
"thread-follower-start-turn": 2,
"thread-follower-load-complete-history": 1,
"thread-follower-compact-thread": 1,
"thread-follower-steer-turn": 1,
"thread-follower-interrupt-turn": 4,
"thread-follower-update-thread-settings": 1,
"thread-follower-edit-last-user-turn": 2,
"thread-follower-command-approval-decision": 1,
"thread-follower-file-approval-decision": 1,
"thread-follower-permissions-request-approval-response": 1,
"thread-follower-submit-user-input": 1,
"thread-follower-submit-mcp-server-elicitation-response": 1,
"thread-follower-set-queued-follow-ups-state": 1,
"thread-queued-followups-changed": 1,
} as const);
export type IpcMethod = keyof typeof CODEX_IPC_METHOD_VERSIONS;
export type IpcRequestId = string | number;
export type IpcPatchPathPart = string | number;
export interface IpcRequest {
type: "request";
requestId: IpcRequestId;
sourceClientId: string;
targetClientId?: string;
version: number;
method: string;
params?: JsonValue;
timeoutMs?: number;
}
export interface IpcResponse {
type: "response";
requestId: IpcRequestId;
resultType: "success" | "error";
method?: string;
handledByClientId?: string;
result?: JsonValue;
error?: string;
}
export interface IpcBroadcast {
type: "broadcast";
method: string;
sourceClientId?: string;
targetClientIds?: string[];
version: number;
params?: JsonValue;
}
export interface IpcClientDiscoveryRequest {
type: "client-discovery-request";
requestId: IpcRequestId;
request: IpcRequest;
}
export interface IpcClientDiscoveryResponse {
type: "client-discovery-response";
requestId: IpcRequestId;
response: { canHandle: boolean };
}
export type IpcMessage =
| IpcRequest
| IpcResponse
| IpcBroadcast
| IpcClientDiscoveryRequest
| IpcClientDiscoveryResponse;
export interface IpcJsonPatch {
op: "add" | "remove" | "replace";
path: IpcPatchPathPart[];
value?: JsonValue;
}
export interface ThreadStreamSnapshot {
type: "snapshot";
revision: number;
conversationState: JsonObject;
}
export interface ThreadStreamPatches {
type: "patches";
baseRevision: number;
revision: number;
patches: IpcJsonPatch[];
}
export type ThreadStreamChange = ThreadStreamSnapshot | ThreadStreamPatches;
export interface ConversationStreamState {
conversationId: string;
hostId: string;
ownerClientId: string;
revision: number;
conversationState: JsonObject;
}
export type ConversationStreamEvent =
| (ConversationStreamState & { kind: "snapshot"; raw: IpcBroadcast })
| (ConversationStreamState & { kind: "patches"; patches: IpcJsonPatch[]; baseRevision: number; raw: IpcBroadcast })
| {
kind: "desync";
conversationId: string;
hostId: string;
ownerClientId: string;
expectedRevision: number;
receivedBaseRevision: number;
receivedRevision: number;
raw: IpcBroadcast;
};
export interface CodexIpcClientOptions {
/** Explicit socket path; otherwise `$CODEX_HOME/ipc/ipc.sock` or `~/.codex`. */
socketPath?: string;
codexHome?: string;
homeDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
clientType?: string;
requestTimeoutMs?: number;
maxFrameBytes?: number;
strictVersions?: boolean;
/** Reconnect after a socket close and re-send all active following subscriptions. */
autoReconnect?: boolean;
reconnectDelayMs?: number;
/** Optional handler for discovery requests. Default is fail-closed (`false`). */
canHandleRequest?: (request: IpcRequest) => boolean | Promise<boolean>;
}
export interface FollowerTurnStartOptions {
request?: JsonObject;
context?: JsonObject;
clientUserMessageId?: string;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowerSteerOptions {
clientUserMessageId?: string;
serviceTier?: string | null;
attachments?: JsonValue[];
additionalContext?: JsonObject | null;
restoreMessage?: JsonValue | null;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowerInterruptOptions {
mode?: "user-stop" | "system" | "descendant-cleanup" | string;
expectedTurnId?: string | null;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowOptions {
hostId?: string;
targetClientIds?: string[];
}
export interface RequestOptions {
targetClientId?: string;
timeoutMs?: number;
version?: number;
requestId?: IpcRequestId;
}
export interface IpcErrorOptions {
code: string;
response?: IpcResponse;
}
export class CodexIpcError extends Error {
readonly code: string;
readonly response?: IpcResponse;
constructor(message: string, options: IpcErrorOptions) {
super(message);
this.name = "CodexIpcError";
this.code = options.code;
this.response = options.response;
}
}
export function resolveCodexIpcSocketPath(options: {
socketPath?: string;
codexHome?: string;
homeDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
} = {}): string {
if (options.socketPath?.trim()) return options.socketPath.trim();
const platform = options.platform ?? process.platform;
if (platform === "win32") return "\\\\.\\pipe\\codex-ipc";
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const configuredHome = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(homeDir, ".codex");
const codexHome = configuredHome === "~"
? homeDir
: configuredHome.startsWith("~/")
? path.join(homeDir, configuredHome.slice(2))
: configuredHome;
return path.join(codexHome, "ipc", "ipc.sock");
}
/** Encode one private IPC frame: uint32 little-endian byte length + UTF-8 JSON. */
export function encodeIpcFrame(message: IpcMessage, maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES): Buffer {
const json = JSON.stringify(message);
const payload = Buffer.from(json, "utf8");
if (payload.length === 0 || payload.length > maxFrameBytes) {
throw new RangeError(`IPC frame exceeds ${maxFrameBytes} bytes`);
}
const frame = Buffer.allocUnsafe(4 + payload.length);
frame.writeUInt32LE(payload.length, 0);
payload.copy(frame, 4);
return frame;
}
/** Incremental decoder that accepts arbitrary TCP/Unix-socket chunk boundaries. */
export class IpcFrameDecoder {
private buffer = Buffer.alloc(0);
constructor(private readonly maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES) {}
push(chunk: Uint8Array): IpcMessage[] {
if (chunk.length === 0) return [];
this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
const messages: IpcMessage[] = [];
while (this.buffer.length >= 4) {
const payloadLength = this.buffer.readUInt32LE(0);
if (payloadLength === 0 || payloadLength > this.maxFrameBytes) {
throw new CodexIpcError(`Invalid IPC frame length (${payloadLength} bytes)`, { code: "invalid-frame-length" });
}
if (this.buffer.length < payloadLength + 4) break;
const payload = this.buffer.subarray(4, payloadLength + 4).toString("utf8");
this.buffer = this.buffer.subarray(payloadLength + 4);
let decoded: unknown;
try {
decoded = JSON.parse(payload);
} catch (error) {
throw new CodexIpcError(`Invalid IPC JSON: ${error instanceof Error ? error.message : String(error)}`, {
code: "invalid-json",
});
}
if (!isRecord(decoded) || typeof decoded.type !== "string") {
throw new CodexIpcError("IPC frame must be an object with a type", { code: "invalid-message" });
}
messages.push(decoded as unknown as IpcMessage);
}
return messages;
}
reset(): void {
this.buffer = Buffer.alloc(0);
}
}
type Listener<T> = (value: T) => void;
export interface IpcSubscription { dispose(): void; }
function subscribe<T>(set: Set<Listener<T>>, listener: Listener<T>): IpcSubscription {
set.add(listener);
return { dispose: () => set.delete(listener) };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isJsonObject(value: unknown): value is JsonObject {
return isRecord(value);
}
function requestIdKey(id: IpcRequestId): string {
return `${typeof id}:${String(id)}`;
}
function cloneJson<T extends JsonValue>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function versionFor(method: string, params?: JsonValue): number {
// The official client accepts interrupt v3 when expectedTurnId is absent;
// v4 is used when the active-turn precondition is present.
if (method === "thread-follower-interrupt-turn"
&& (!isRecord(params) || params.expectedTurnId === undefined || params.expectedTurnId === null)) return 3;
return CODEX_IPC_METHOD_VERSIONS[method as IpcMethod] ?? 0;
}
function textInput(text: string): JsonObject {
return { type: "text", text, text_elements: [] };
}
function normalizeInput(input: string | JsonValue[]): JsonValue[] {
return typeof input === "string"
? [textInput(input)]
: input.map((entry) => typeof entry === "string" ? textInput(entry) : entry);
}
function hasTarget(frame: IpcBroadcast, clientId: string): boolean {
return frame.targetClientIds == null || frame.targetClientIds.includes(clientId);
}
/** Apply the JSON patch arrays generated by Immer in the official webview. */
export function applyIpcPatches(root: JsonValue, patches: IpcJsonPatch[]): JsonValue {
let result = cloneJson(root);
for (const patch of patches) {
if (!Array.isArray(patch.path)) throw new CodexIpcError("IPC patch path must be an array", { code: "invalid-patch" });
if (patch.path.length === 0) {
if (patch.op === "remove") throw new CodexIpcError("Removing the conversation root is unsupported", { code: "invalid-patch" });
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
result = cloneJson(patch.value);
continue;
}
const parentPath = patch.path.slice(0, -1);
const key = patch.path[patch.path.length - 1];
assertSafePatchPart(key);
const parent = getAtPath(result, parentPath);
if (Array.isArray(parent)) {
const index = key === "-" ? parent.length : toArrayIndex(key);
if (patch.op === "add") {
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
parent.splice(index, 0, cloneJson(patch.value));
} else if (patch.op === "replace") {
if (patch.value === undefined || index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array replace patch", { code: "invalid-patch" });
parent[index] = cloneJson(patch.value);
} else {
if (index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array remove patch", { code: "invalid-patch" });
parent.splice(index, 1);
}
continue;
}
if (!isRecord(parent) || typeof key !== "string") {
throw new CodexIpcError("IPC patch parent is not an object or array", { code: "invalid-patch" });
}
if (patch.op === "remove") {
delete parent[key];
} else {
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
parent[key] = cloneJson(patch.value);
}
}
return result;
}
function getAtPath(root: JsonValue, pathParts: IpcPatchPathPart[]): JsonValue {
let current: JsonValue = root;
for (const part of pathParts) {
if (Array.isArray(current)) {
const index = toArrayIndex(part);
if (index < 0 || index >= current.length) throw new CodexIpcError("IPC patch path is out of bounds", { code: "invalid-patch" });
current = current[index];
} else if (isRecord(current) && typeof part === "string" && Object.prototype.hasOwnProperty.call(current, part)) {
assertSafePatchPart(part);
current = current[part];
} else {
throw new CodexIpcError("IPC patch path does not exist", { code: "invalid-patch" });
}
}
return current;
}
function toArrayIndex(value: IpcPatchPathPart): number {
if (typeof value === "number" && Number.isInteger(value)) return value;
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
throw new CodexIpcError(`Invalid array patch index: ${String(value)}`, { code: "invalid-patch" });
}
function assertSafePatchPart(value: IpcPatchPathPart): void {
if (value === "__proto__" || value === "prototype" || value === "constructor") {
throw new CodexIpcError("Unsafe IPC patch path", { code: "invalid-patch" });
}
}
export class CodexIpcClient {
readonly socketPath: string;
private readonly options: Required<Pick<CodexIpcClientOptions, "clientType" | "requestTimeoutMs" | "maxFrameBytes" | "strictVersions" | "autoReconnect" | "reconnectDelayMs">> & CodexIpcClientOptions;
private socket: net.Socket | undefined;
private decoder: IpcFrameDecoder;
private connectPromise: Promise<string> | undefined;
private reconnectTimer: NodeJS.Timeout | undefined;
private disposed = false;
private clientId = INITIALIZING_CLIENT_ID;
private readonly pending = new Map<string, { method: string; resolve: (response: IpcResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private readonly followed = new Map<string, string>();
private readonly streams = new Map<string, ConversationStreamState>();
private readonly messageListeners = new Set<Listener<IpcMessage>>();
private readonly broadcastListeners = new Set<Listener<IpcBroadcast>>();
private readonly streamListeners = new Set<Listener<ConversationStreamEvent>>();
private readonly errorListeners = new Set<Listener<Error>>();
private readonly closeListeners = new Set<Listener<Error | undefined>>();
private readonly discoveryHandler?: (request: IpcRequest) => boolean | Promise<boolean>;
constructor(options: CodexIpcClientOptions = {}) {
this.options = {
...options,
clientType: options.clientType ?? "codex-remote-collab",
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_IPC_REQUEST_TIMEOUT_MS,
maxFrameBytes: options.maxFrameBytes ?? DEFAULT_MAX_IPC_FRAME_BYTES,
strictVersions: options.strictVersions ?? true,
autoReconnect: options.autoReconnect ?? false,
reconnectDelayMs: options.reconnectDelayMs ?? 1_000,
};
this.socketPath = resolveCodexIpcSocketPath(options);
this.decoder = new IpcFrameDecoder(this.options.maxFrameBytes);
this.discoveryHandler = options.canHandleRequest;
}
getClientId(): string { return this.clientId; }
getConversationState(conversationId: string): ConversationStreamState | undefined {
const state = this.streams.get(conversationId);
return state == null ? undefined : { ...state, conversationState: cloneJson(state.conversationState) };
}
getFollowedConversations(): ReadonlyMap<string, string> { return this.followed; }
onMessage(listener: Listener<IpcMessage>): IpcSubscription { return subscribe(this.messageListeners, listener); }
onBroadcast(listener: Listener<IpcBroadcast>): IpcSubscription { return subscribe(this.broadcastListeners, listener); }
onStreamEvent(listener: Listener<ConversationStreamEvent>): IpcSubscription { return subscribe(this.streamListeners, listener); }
onError(listener: Listener<Error>): IpcSubscription { return subscribe(this.errorListeners, listener); }
onClose(listener: Listener<Error | undefined>): IpcSubscription { return subscribe(this.closeListeners, listener); }
async connect(): Promise<string> {
if (this.disposed) throw new CodexIpcError("IPC client is disposed", { code: "disposed" });
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
if (this.socket?.writable && this.clientId !== INITIALIZING_CLIENT_ID) return this.clientId;
if (this.connectPromise) return this.connectPromise;
this.connectPromise = new Promise<string>((resolve, reject) => {
const socket = net.createConnection(this.socketPath);
this.socket = socket;
this.decoder.reset();
let settled = false;
const finishError = (error: Error): void => {
if (!settled) {
settled = true;
reject(error);
}
this.emitError(error);
};
socket.setNoDelay?.(true);
socket.on("connect", () => {
const requestId = crypto.randomUUID();
const timer = setTimeout(() => {
this.pending.delete(requestIdKey(requestId));
finishError(new CodexIpcError("IPC initialize timed out", { code: "timeout" }));
socket.destroy();
}, this.options.requestTimeoutMs);
this.pending.set(requestIdKey(requestId), {
method: "initialize",
resolve: (response) => {
clearTimeout(timer);
if (response.resultType !== "success" || !isRecord(response.result) || typeof response.result.clientId !== "string") {
finishError(new CodexIpcError("IPC initialize returned an invalid response", { code: "initialize-failed", response }));
socket.destroy();
return;
}
this.clientId = response.result.clientId;
settled = true;
resolve(this.clientId);
this.resubscribeAfterConnect().catch((error) => this.emitError(asError(error)));
},
reject: (error) => {
clearTimeout(timer);
finishError(error);
socket.destroy();
},
timer,
});
this.write({
type: "request",
requestId,
sourceClientId: INITIALIZING_CLIENT_ID,
version: 0,
method: "initialize",
params: { clientType: this.options.clientType },
});
});
socket.on("data", (chunk) => {
try {
for (const message of this.decoder.push(chunk)) this.handleMessage(message);
} catch (error) {
const normalized = asError(error);
finishError(normalized);
socket.destroy(normalized);
}
});
socket.on("error", (error) => {
if (!settled) finishError(error);
else this.emitError(error);
});
socket.on("close", () => {
this.handleClose();
});
}).finally(() => {
this.connectPromise = undefined;
});
return this.connectPromise;
}
async followConversation(conversationId: string, following = true, options: FollowOptions = {}): Promise<void> {
const hostId = options.hostId ?? "local";
await this.connect();
if (following) this.followed.set(conversationId, hostId);
else {
this.followed.delete(conversationId);
this.streams.delete(conversationId);
}
const params: JsonObject = { conversationId, hostId, following };
const frame: IpcBroadcast = {
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: this.clientId,
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
params,
};
if (options.targetClientIds) frame.targetClientIds = options.targetClientIds;
this.write(frame);
}
async findThreadOwner(conversationId: string, hostId = "local", timeoutMs = this.options.requestTimeoutMs): Promise<string | null> {
try {
const response = await this.request("thread-owner-discovery", { conversationId, hostId }, { timeoutMs });
return response.handledByClientId ?? null;
} catch (error) {
if (error instanceof CodexIpcError
&& (error.code === "no-client-found" || error.code.startsWith("no-client-found:"))) return null;
throw error;
}
}
async request(method: string, params?: JsonValue, options: RequestOptions = {}): Promise<IpcResponse> {
await this.connect();
const requestId = options.requestId ?? crypto.randomUUID();
const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs;
const frame: IpcRequest = {
type: "request",
requestId,
sourceClientId: this.clientId,
version: options.version ?? versionFor(method, params),
method,
params,
};
if (options.targetClientId) frame.targetClientId = options.targetClientId;
if (timeoutMs > 0) frame.timeoutMs = timeoutMs;
return new Promise<IpcResponse>((resolve, reject) => {
const key = requestIdKey(requestId);
const timer = setTimeout(() => {
this.pending.delete(key);
reject(new CodexIpcError(`${method} timed out`, { code: "timeout" }));
}, timeoutMs > 0 ? timeoutMs : 2 ** 31 - 1);
this.pending.set(key, { method, resolve, reject, timer });
try {
this.write(frame);
} catch (error) {
clearTimeout(timer);
this.pending.delete(key);
reject(asError(error));
}
}).then((response) => {
if (response.resultType === "error") {
throw new CodexIpcError(response.error ?? `${method} failed`, { code: response.error ?? "ipc-error", response });
}
if (response.method != null && response.method !== method) {
throw new CodexIpcError(`IPC response method mismatch: expected ${method}, got ${response.method}`, {
code: "response-method-mismatch",
response,
});
}
return response;
});
}
async requestFollower(method: string, conversationId: string, params: JsonObject = {}, options: RequestOptions & { ownerClientId?: string } = {}): Promise<IpcResponse> {
const ownerClientId = options.ownerClientId ?? this.streams.get(conversationId)?.ownerClientId;
if (!ownerClientId) throw new CodexIpcError(`No owner is known for conversation ${conversationId}`, { code: "owner-unknown" });
// Do not allow a caller-provided params object to accidentally retarget a
// request after the owner has been selected from the stream snapshot.
const body: JsonObject = { ...params, conversationId };
const { ownerClientId: _owner, ...requestOptions } = options;
return this.request(method, body, { ...requestOptions, targetClientId: ownerClientId });
}
/** Send the exact private `turnStart` envelope expected by the owner. */
async startTurn(conversationId: string, input: string | JsonValue[], options: FollowerTurnStartOptions = {}): Promise<JsonValue | undefined> {
const request: JsonObject = {
...(options.request ?? {}),
threadId: conversationId,
input: options.request?.input ?? normalizeInput(input),
};
const context: JsonObject = { inheritThreadSettings: true, ...(options.context ?? {}) };
if (options.clientUserMessageId) request.clientUserMessageId = options.clientUserMessageId;
const response = await this.requestFollower("thread-follower-start-turn", conversationId, {
turnStart: { request, context },
}, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
async steerTurn(conversationId: string, input: string | JsonValue[], options: FollowerSteerOptions = {}): Promise<JsonValue | undefined> {
const params: JsonObject = {
clientUserMessageId: options.clientUserMessageId ?? crypto.randomUUID(),
input: normalizeInput(input),
attachments: options.attachments ?? [],
};
if (options.serviceTier !== undefined) params.serviceTier = options.serviceTier;
if (options.additionalContext !== undefined) params.additionalContext = options.additionalContext;
if (options.restoreMessage !== undefined) params.restoreMessage = options.restoreMessage;
const response = await this.requestFollower("thread-follower-steer-turn", conversationId, params, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
/**
* Persist settings for the next turn through the official conversation
* owner. The owner-side follower handler expects the settings nested under
* `threadSettings`; `requestFollower` adds the conversation id to the
* outer envelope, yielding:
* `{ conversationId, threadSettings }`.
*/
async updateThreadSettings(
conversationId: string,
threadSettings: JsonObject,
options: RequestOptions & { ownerClientId?: string } = {},
): Promise<JsonValue | undefined> {
if (!isJsonObject(threadSettings)) {
throw new CodexIpcError("thread settings must be a JSON object", { code: "invalid-thread-settings" });
}
const response = await this.requestFollower(
"thread-follower-update-thread-settings",
conversationId,
{ threadSettings: cloneJson(threadSettings) },
options,
);
return response.result;
}
/** Alias matching the official app-server manager method name. */
async updateThreadSettingsForNextTurn(
conversationId: string,
threadSettings: JsonObject,
options: RequestOptions & { ownerClientId?: string } = {},
): Promise<JsonValue | undefined> {
return this.updateThreadSettings(conversationId, threadSettings, options);
}
async interruptTurn(conversationId: string, options: FollowerInterruptOptions = {}): Promise<JsonValue | undefined> {
const params: JsonObject = { mode: options.mode ?? "user-stop" };
if (options.expectedTurnId !== undefined && options.expectedTurnId !== null) params.expectedTurnId = options.expectedTurnId;
const response = await this.requestFollower("thread-follower-interrupt-turn", conversationId, params, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
async loadCompleteHistory(conversationId: string, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
const response = await this.requestFollower("thread-follower-load-complete-history", conversationId, {}, options);
return response.result;
}
async respondCommandApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-command-approval-decision", conversationId, { requestId, decision }, options);
}
async respondFileApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-file-approval-decision", conversationId, { requestId, decision }, options);
}
async respondPermissionsApproval(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-permissions-request-approval-response", conversationId, { requestId, response }, options);
}
async respondUserInput(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-submit-user-input", conversationId, { requestId, response }, options);
}
async respondMcpElicitation(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-submit-mcp-server-elicitation-response", conversationId, { requestId, response }, options);
}
private async respondFollower(method: string, conversationId: string, params: JsonObject, options: RequestOptions & { ownerClientId?: string }): Promise<JsonValue | undefined> {
const response = await this.requestFollower(method, conversationId, params, options);
return response.result;
}
async dispose(): Promise<void> {
this.disposed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new CodexIpcError("IPC client disposed", { code: "disposed" }));
}
this.pending.clear();
this.socket?.destroy();
this.socket = undefined;
this.clientId = INITIALIZING_CLIENT_ID;
}
private write(message: IpcMessage): void {
if (!this.socket?.writable) throw new CodexIpcError("IPC socket is not connected", { code: "not-connected" });
this.socket.write(encodeIpcFrame(message, this.options.maxFrameBytes));
}
private handleMessage(message: IpcMessage): void {
for (const listener of this.messageListeners) safeCall(listener, message, (error) => this.emitError(error));
switch (message.type) {
case "response":
this.handleResponse(message);
return;
case "broadcast":
this.handleBroadcast(message);
return;
case "client-discovery-request":
this.handleDiscoveryRequest(message).catch((error) => this.emitError(asError(error)));
return;
case "request":
this.handleUnexpectedRequest(message);
return;
case "client-discovery-response":
// Discovery responses are consumed by the router, not by clients.
return;
}
}
private handleResponse(response: IpcResponse): void {
const key = requestIdKey(response.requestId);
const pending = this.pending.get(key);
if (!pending) return;
this.pending.delete(key);
clearTimeout(pending.timer);
pending.resolve(response);
}
private handleBroadcast(frame: IpcBroadcast): void {
if (!hasTarget(frame, this.clientId)) return;
for (const listener of this.broadcastListeners) safeCall(listener, frame, (error) => this.emitError(error));
if (frame.method === "thread-stream-state-changed") {
this.handleStreamStateBroadcast(frame);
} else if (frame.method === "thread-stream-following-status-requested") {
this.handleFollowingStatusRequested(frame);
}
}
/** Re-announce active subscriptions when an owner reconnects or hands off. */
private handleFollowingStatusRequested(frame: IpcBroadcast): void {
if (!isRecord(frame.params)) return;
if (this.options.strictVersions
&& frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"]) {
this.emitError(new CodexIpcError(`Unsupported thread following status version ${frame.version}`, { code: "version-mismatch" }));
return;
}
const conversationId = typeof frame.params.conversationId === "string"
? frame.params.conversationId
: undefined;
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
const requester = frame.sourceClientId;
if (!conversationId || !requester || requester === this.clientId) return;
if (this.followed.get(conversationId) !== hostId) return;
void this.followConversation(conversationId, true, {
hostId,
targetClientIds: [requester],
}).catch((error) => this.emitError(asError(error)));
}
private handleStreamStateBroadcast(frame: IpcBroadcast): void {
if (!isRecord(frame.params)) return;
const conversationId = typeof frame.params.conversationId === "string" ? frame.params.conversationId : undefined;
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
const change = frame.params.change;
if (!conversationId || !isRecord(change) || typeof change.type !== "string") return;
if (this.options.strictVersions && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"]) {
this.emitError(new CodexIpcError(`Unsupported thread stream version ${frame.version}`, { code: "version-mismatch" }));
return;
}
const ownerClientId = frame.sourceClientId ?? "";
if (change.type === "snapshot") {
if (typeof change.revision !== "number" || !isJsonObject(change.conversationState)) return;
const state: ConversationStreamState = {
conversationId,
hostId,
ownerClientId,
revision: change.revision,
conversationState: cloneJson(change.conversationState),
};
this.streams.set(conversationId, state);
this.emitStream({ kind: "snapshot", ...state, raw: frame });
return;
}
if (change.type !== "patches" || typeof change.baseRevision !== "number" || typeof change.revision !== "number" || !Array.isArray(change.patches)) return;
const current = this.streams.get(conversationId);
if (!current || current.ownerClientId !== ownerClientId || current.revision !== change.baseRevision) {
const expectedRevision = current?.revision ?? 0;
this.emitStream({
kind: "desync",
conversationId,
hostId,
ownerClientId,
expectedRevision,
receivedBaseRevision: change.baseRevision,
receivedRevision: change.revision,
raw: frame,
});
// Re-sending `following:true` is how the official follower asks the
// owner for a fresh snapshot when a patch base revision is missed.
if (this.followed.has(conversationId)) {
this.followConversation(conversationId, true, { hostId }).catch((error) => this.emitError(asError(error)));
}
return;
}
try {
const patches = change.patches as unknown as IpcJsonPatch[];
const nextConversationState = applyIpcPatches(current.conversationState, patches);
if (!isJsonObject(nextConversationState)) throw new CodexIpcError("Patched conversation state is not an object", { code: "invalid-patch" });
const next: ConversationStreamState = {
...current,
revision: change.revision,
conversationState: nextConversationState,
};
this.streams.set(conversationId, next);
this.emitStream({ kind: "patches", ...next, patches, baseRevision: change.baseRevision, raw: frame });
} catch (error) {
this.emitError(asError(error));
}
}
private async handleDiscoveryRequest(message: IpcClientDiscoveryRequest): Promise<void> {
const request = message.request;
let canHandle = false;
try {
canHandle = this.discoveryHandler ? await this.discoveryHandler(request) : false;
} catch {
canHandle = false;
}
this.write({
type: "client-discovery-response",
requestId: message.requestId,
response: { canHandle },
});
}
private handleUnexpectedRequest(request: IpcRequest): void {
try {
this.write({
type: "response",
requestId: request.requestId,
resultType: "error",
error: "no-handler-for-request",
});
} catch (error) {
this.emitError(asError(error));
}
}
private async resubscribeAfterConnect(): Promise<void> {
const subscriptions = [...this.followed.entries()];
for (const [conversationId, hostId] of subscriptions) {
this.write({
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: this.clientId,
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
params: { conversationId, hostId, following: true },
});
}
}
private handleClose(): void {
const socket = this.socket;
this.socket = undefined;
this.decoder.reset();
const closeError = new CodexIpcError("IPC socket closed", { code: "connection-closed" });
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(closeError);
}
this.pending.clear();
this.clientId = INITIALIZING_CLIENT_ID;
for (const listener of this.closeListeners) safeCall(listener, closeError, (error) => this.emitError(error));
if (!this.disposed && this.options.autoReconnect && socket) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.connect().catch((error) => this.emitError(asError(error)));
}, this.options.reconnectDelayMs);
}
}
private emitStream(event: ConversationStreamEvent): void {
for (const listener of this.streamListeners) safeCall(listener, event, (error) => this.emitError(error));
}
private emitError(error: Error): void {
for (const listener of this.errorListeners) safeCall(listener, error, () => undefined);
}
}
function safeCall<T>(listener: Listener<T>, value: T, onError: (error: Error) => void): void {
try {
listener(value);
} catch (error) {
onError(asError(error));
}
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import { accessSync, constants, Dirent, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join, sep } from "node:path";
export interface CodexPathOptions {
/** Environment used for PATH lookup. Defaults to the extension host environment. */
env?: NodeJS.ProcessEnv;
/** Home directory used when looking for bundled installations. */
homeDir?: string;
/** Platform override for deterministic tests. */
platform?: NodeJS.Platform;
}
/**
* Resolve the executable used by the VS Code bridge.
*
* VS Code launched from Finder/Dock often receives a smaller PATH than a shell.
* The default `codex` command therefore gets a few explicit installation
* fallbacks, while a user-supplied command remains authoritative.
*/
export function resolveCodexCommand(configuredCommand = "codex", options: CodexPathOptions = {}): string {
const command = configuredCommand.trim() || "codex";
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const home = options.homeDir ?? homedir();
if (hasPathComponent(command, platform)) {
const resolved = executablePath(command, platform);
if (resolved) return resolved;
throw missingCodexError(command, platform);
}
const fromPath = findOnPath(command, env.PATH, platform, env.PATHEXT);
if (fromPath) return fromPath;
// Only the default command gets installation-specific fallbacks. A custom
// bare command should fail loudly instead of silently running another binary.
if (!isDefaultCommand(command, platform)) throw missingCodexError(command, platform);
for (const candidate of bundledCandidates(home, platform)) {
const resolved = executablePath(candidate, platform);
if (resolved) return resolved;
}
throw missingCodexError(command, platform);
}
export function missingCodexError(command: string, platform: NodeJS.Platform = process.platform): Error {
const examples = platform === "darwin"
? ' Set "codexRemoteCollab.codexCommand" to the full path, for example "/Applications/ChatGPT.app/Contents/Resources/codex".'
: ' Set "codexRemoteCollab.codexCommand" to the full path of the Codex executable.';
return new Error(`Codex executable "${command}" was not found.${examples}`);
}
function isDefaultCommand(command: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? command.toLowerCase() === "codex" || command.toLowerCase() === "codex.exe" : command === "codex";
}
function hasPathComponent(command: string, platform: NodeJS.Platform): boolean {
return isAbsolute(command) || command.includes(sep) || (platform === "win32" && command.includes("\\"));
}
function executablePath(candidate: string, platform: NodeJS.Platform): string | undefined {
try {
const info = statSync(candidate);
if (!info.isFile()) return undefined;
// X_OK is meaningful on POSIX; Windows still benefits from the file check.
if (platform !== "win32") accessSync(candidate, constants.X_OK);
return candidate;
} catch {
return undefined;
}
}
function findOnPath(command: string, pathValue: string | undefined, platform: NodeJS.Platform, pathextValue?: string): string | undefined {
if (!pathValue) return undefined;
const extensions = platform === "win32" ? windowsExtensions(command, pathextValue) : [""];
for (const directory of pathValue.split(delimiter)) {
if (!directory) continue;
for (const extension of extensions) {
const candidate = join(directory, `${command}${extension}`);
const resolved = executablePath(candidate, platform);
if (resolved) return resolved;
}
}
return undefined;
}
function windowsExtensions(command: string, pathextValue: string | undefined): string[] {
if (/[.][^./\\]+$/.test(command)) return [""];
const extensions = (pathextValue ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((value) => value.trim())
.filter(Boolean);
return ["", ...extensions];
}
function bundledCandidates(home: string, platform: NodeJS.Platform): string[] {
if (platform !== "darwin") return [];
const candidates = [
join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"),
"/Applications/ChatGPT.app/Contents/Resources/codex",
join(home, ".local", "bin", "codex"),
join(home, ".npm-global", "bin", "codex"),
];
for (const extensionsRoot of [
join(home, ".vscode", "extensions"),
join(home, ".vscode-insiders", "extensions"),
]) {
candidates.push(...officialExtensionCandidates(extensionsRoot));
}
return candidates;
}
function officialExtensionCandidates(extensionsRoot: string): string[] {
let entries: Dirent<string>[];
try {
entries = readdirSync(extensionsRoot, { withFileTypes: true, encoding: "utf8" });
} catch {
return [];
}
const matches = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-"))
.map((entry) => {
const directory = join(extensionsRoot, entry.name);
let modified = 0;
try {
modified = statSync(directory).mtimeMs;
} catch {
// Keep an unreadable entry at the end of the deterministic sort.
}
return { directory, modified };
})
.sort((left, right) => right.modified - left.modified || right.directory.localeCompare(left.directory));
const candidates: string[] = [];
for (const match of matches) {
let architectures: Dirent<string>[];
try {
architectures = readdirSync(join(match.directory, "bin"), { withFileTypes: true, encoding: "utf8" });
} catch {
continue;
}
for (const architecture of architectures) {
if (architecture.isDirectory()) candidates.push(join(match.directory, "bin", architecture.name, "codex"));
}
}
return candidates;
}
@@ -0,0 +1,131 @@
import { Disposable, RelayFrame, RelayTransport } from "./protocol";
export interface NamedRelayTransport {
id: string;
transport: RelayTransport;
required?: boolean;
}
/**
* Fans host events out to local and cloud relays while presenting one
* transport lifecycle to RelayHost. A temporary cloud outage must not stop
* the local bridge (and vice versa).
*/
export class CompositeRelayTransport implements RelayTransport {
readonly handlesHandshake = true;
private readonly entries: NamedRelayTransport[];
private readonly subscriptions: Disposable[] = [];
private readonly openEntries = new Set<string>();
private readonly messageListeners = new Set<(frame: RelayFrame) => void>();
private readonly openListeners = new Set<() => void>();
private readonly closeListeners = new Set<(error?: Error) => void>();
private started = false;
private sessionId?: string;
constructor(entries: NamedRelayTransport[]) {
if (entries.length === 0) throw new Error("CompositeRelayTransport requires at least one relay");
const ids = new Set<string>();
for (const entry of entries) {
if (!entry.id || ids.has(entry.id)) throw new Error(`duplicate relay id: ${entry.id || "(empty)"}`);
ids.add(entry.id);
}
this.entries = [...entries];
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
for (const { transport } of this.entries) {
(transport as RelayTransport & { setSessionId?: (value: string) => void }).setSessionId?.(sessionId);
}
}
async connect(): Promise<void> {
if (this.started) return;
this.started = true;
this.bindTransports();
if (this.sessionId) this.setSessionId(this.sessionId);
const results = await Promise.allSettled(this.entries.map(({ transport }) => transport.connect()));
const failures = results
.map((result, index) => ({ result, entry: this.entries[index] }))
.filter((item): item is { result: PromiseRejectedResult; entry: NamedRelayTransport } => item.result.status === "rejected");
const requiredFailure = failures.find(({ entry }) => entry.required);
const connected = results.length - failures.length;
if (requiredFailure || connected === 0) {
this.started = false;
this.disposeSubscriptions();
for (const { transport } of this.entries) transport.close();
const detail = failures.map(({ entry, result }) => `${entry.id}: ${errorMessage(result.reason)}`).join("; ");
throw new Error(`unable to connect relay${failures.length === 1 ? "" : "s"}: ${detail}`);
}
}
send(frame: RelayFrame): void {
const failures: string[] = [];
for (const { id, transport } of this.entries) {
try {
transport.send(frame);
} catch (error) {
failures.push(`${id}: ${errorMessage(error)}`);
}
}
if (failures.length === this.entries.length) {
throw new Error(`all relay sends failed: ${failures.join("; ")}`);
}
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.messageListeners.add(listener);
return { dispose: () => this.messageListeners.delete(listener) };
}
onOpen(listener: () => void): Disposable {
this.openListeners.add(listener);
return { dispose: () => this.openListeners.delete(listener) };
}
onClose(listener: (error?: Error) => void): Disposable {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
isConnected(id: string): boolean {
return this.openEntries.has(id);
}
close(): void {
this.started = false;
this.openEntries.clear();
this.disposeSubscriptions();
for (const { transport } of this.entries) transport.close();
}
private bindTransports(): void {
for (const { id, transport } of this.entries) {
this.subscriptions.push(transport.onMessage((frame) => {
for (const listener of this.messageListeners) listener(frame);
}));
if (transport.onOpen) this.subscriptions.push(transport.onOpen(() => {
this.openEntries.add(id);
// RelayHost publishes an authoritative snapshot after an authenticated
// reconnect. Surface every member reconnect so a recovered cloud relay
// is hydrated even while the local relay remained online.
for (const listener of this.openListeners) listener();
}));
if (transport.onClose) this.subscriptions.push(transport.onClose((error) => {
const wasOpen = this.openEntries.delete(id);
if (wasOpen && this.openEntries.size === 0) {
for (const listener of this.closeListeners) listener(error);
}
}));
}
}
private disposeSubscriptions(): void {
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,574 @@
import { hostname } from "node:os";
import * as vscode from "vscode";
import { CodexAgentAdapter } from "./codexAgentAdapter";
import { resolveCodexCommand } from "./codexPath";
import { CodexIpcAgentAdapter } from "./codexIpcAgentAdapter";
import { CompositeRelayTransport } from "./compositeRelay";
import { LocalRelayController, localRelayTarget } from "./localRelay";
import { AgentAdapter, ControlMode, Disposable, JsonObject, Logger } from "./protocol";
import { RelayClient } from "./relayClient";
import { RelayHost } from "./relayHost";
import { SwitchableAgentAdapter } from "./switchableAgentAdapter";
let activeHost: RelayHost | undefined;
let activeAdapter: AgentAdapter | undefined;
let activeRelay: CompositeRelayTransport | undefined;
let activeAdapterStatusSubscription: Disposable | undefined;
let statusItem: vscode.StatusBarItem | undefined;
let autoStartRetryTimer: NodeJS.Timeout | undefined;
let autoStartRetryMs = 3_000;
let localRelayController: LocalRelayController | undefined;
const t = (message: string, ...args: Array<string | number | boolean>): string => vscode.l10n.t(message, ...args);
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const output = vscode.window.createOutputChannel(t("Codex Remote Collaboration"));
context.subscriptions.push(output);
const logger = {
debug: (message: string, ...args: unknown[]) => output.appendLine(`[debug] ${message} ${formatArgs(args)}`),
info: (message: string, ...args: unknown[]) => output.appendLine(`[info] ${message} ${formatArgs(args)}`),
warn: (message: string, ...args: unknown[]) => output.appendLine(`[warn] ${message} ${formatArgs(args)}`),
error: (message: string, ...args: unknown[]) => output.appendLine(`[error] ${message} ${formatArgs(args)}`),
};
localRelayController = new LocalRelayController({ extensionPath: context.extensionPath, logger });
statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusItem.command = "codexRemoteCollab.openWeb";
statusItem.text = "$(plug) Codex Remote";
statusItem.tooltip = t("Connecting to the local Codex collaboration service");
statusItem.show();
context.subscriptions.push(statusItem);
const start = async (automatic = false): Promise<void> => {
if (!automatic && autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
if (activeHost) {
if (!automatic) vscode.window.showInformationMessage(t("The Codex remote bridge is already running."));
return;
}
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const relayConfiguration = resolveRelayConfiguration(configuration);
const localRelayUrl = relayConfiguration.localUrl;
if (!localRelayUrl) {
vscode.window.showWarningMessage(t("Set codexRemoteCollab.localRelayUrl before starting the bridge."));
return;
}
const localTarget = localRelayTarget(localRelayUrl);
if (!localTarget) {
vscode.window.showErrorMessage(t("codexRemoteCollab.localRelayUrl must be a loopback ws:// address."));
return;
}
if (localTarget && configuration.get<boolean>("autoStartLocalRelay", true)) {
setStatus("$(sync~spin) Codex Remote", t("Starting {0}", localTarget.webUrl), "codexRemoteCollab.openWeb");
try {
await localRelayController?.ensureRunning(localRelayUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus("$(error) Codex Remote", t("Unable to start the local collaboration service: {0}", message), "codexRemoteCollab.openWeb");
if (automatic) {
logger.warn(`Automatic local relay start failed; retrying in ${autoStartRetryMs}ms`, error);
scheduleAutoStartRetry(start);
} else {
vscode.window.showErrorMessage(t("Unable to start the local Codex collaboration service: {0}", message));
}
return;
}
}
const legacyToken = await context.secrets.get("codexRemoteCollab.relayToken");
const localToken = relayConfiguration.legacyRemote ? undefined : legacyToken;
if (!localToken) logger.info("Using the loopback-only unauthenticated local relay");
const initialControlMode = resolveInitialControlMode(configuration);
let currentControlMode: ControlMode = initialControlMode;
const createAdapter = (controlMode: ControlMode): AgentAdapter => {
if (controlMode === "sync") {
const configuredThreadId = configuration.get<string>("threadId", "").trim();
const socketPath = configuration.get<string>("ipcSocketPath", "").trim();
logger.info(`Synchronous mode enabled; following the VS Code Codex panel${configuredThreadId ? ` (initial conversation ${configuredThreadId})` : ""}`);
return new CodexIpcAgentAdapter({
threadId: configuredThreadId || undefined,
socketPath: socketPath || undefined,
hostId: configuration.get<string>("hostId", "local"),
autoDiscoverThread: configuration.get<boolean>("autoDiscoverThread", true),
// Synchronous mode has one navigation owner: the official panel.
followVscodeSession: true,
preferredCwds: workspaceRoots(),
strictVersions: configuration.get<boolean>("ipcStrictVersions", true),
logger,
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
openNewSession: () => openOfficialNewSession(logger),
});
}
const configuredCommand = configuration.get<string>("codexCommand", "codex");
const command = resolveCodexCommand(configuredCommand);
const args = configuration.get<string[]>("codexArgs", ["app-server", "--stdio"]);
const defaultCwd = configuration.get<string>("defaultCwd", "") || firstWorkspaceRoot();
logger.info(`Asynchronous mode enabled; using independent Codex executable: ${command}`);
return new CodexAgentAdapter({
command,
args,
defaultCwd: defaultCwd || undefined,
logger,
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
});
};
const adapter = new SwitchableAgentAdapter({
initialMode: initialControlMode,
createAdapter,
logger,
onModeChanged: async (nextMode) => {
currentControlMode = nextMode;
await configuration.update("controlMode", nextMode, vscode.ConfigurationTarget.Global);
setControlModeStatus(nextMode, nextMode === "async" || Boolean((await adapter.snapshot()).threadId));
},
});
const localRelay = new RelayClient({
url: localRelayUrl,
...(localToken ? { accessToken: localToken } : {}),
reconnect: configuration.get<boolean>("relayReconnect", true),
logger,
});
const relayEntries = [{ id: "local", transport: localRelay, required: true }];
const cloudRelayUrl = relayConfiguration.cloudUrl;
const cloudToken = await context.secrets.get("codexRemoteCollab.cloudRelayToken")
?? (relayConfiguration.legacyRemote ? legacyToken : undefined);
if (cloudRelayUrl && cloudToken) {
relayEntries.push({
id: "aether-cloud",
transport: new RelayClient({
url: cloudRelayUrl,
accessToken: cloudToken,
reconnect: configuration.get<boolean>("relayReconnect", true),
logger,
}),
required: false,
});
logger.info(`Aether cloud relay enabled: ${cloudRelayUrl}`);
} else if (cloudRelayUrl) {
logger.warn("Aether cloud relay URL is configured without a device credential; cloud sync is disabled until pairing is completed");
}
const relay = new CompositeRelayTransport(relayEntries);
const capabilities = ["read_output", "send_task_input", "cancel_task", "approve_low_risk"];
if (configuration.get<boolean>("allowHighRiskApprovals", false)) capabilities.push("approve_high_risk");
const host = new RelayHost({ adapter, relay, logger, capabilities });
let controlReady = initialControlMode === "async";
activeAdapterStatusSubscription?.dispose();
const adapterStatusSubscription = adapter.onEvent((event) => {
if (activeAdapter !== adapter) return;
if (event.type === "control.mode.changed") {
const changedMode = event.payload.controlMode;
if (changedMode === "sync" || changedMode === "async") currentControlMode = changedMode;
}
if (event.type !== "session.snapshot") return;
const metadata = event.payload.metadata;
if (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)) {
const snapshotMode = (metadata as JsonObject).controlMode;
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
}
const waiting = event.payload.state === "waiting_for_host"
|| (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
&& (metadata as JsonObject).waitingForSession === true);
const threadId = event.threadId
?? (typeof event.payload.threadId === "string" ? event.payload.threadId : undefined);
controlReady = currentControlMode === "async" || (Boolean(threadId) && !waiting);
setControlModeStatus(currentControlMode, controlReady);
});
activeAdapterStatusSubscription = adapterStatusSubscription;
activeAdapter = adapter;
activeRelay = relay;
activeHost = host;
if (configuration.get<boolean>("autoStartLocalRelay", true)) {
localRelay.onClose(() => {
if (activeHost !== host) return;
setStatus("$(sync~spin) Codex Remote", t("Restoring the local collaboration service"), "codexRemoteCollab.openWeb");
void localRelayController?.ensureRunning(localRelayUrl).catch((error) => {
logger.warn("Unable to recover bundled local relay", error);
setStatus("$(error) Codex Remote", t("Unable to restore the local collaboration service"), "codexRemoteCollab.openWeb");
});
});
localRelay.onOpen(() => {
if (activeHost === host) {
setControlModeStatus(currentControlMode, controlReady);
}
});
}
try {
await host.start();
autoStartRetryMs = 3_000;
const snapshot = await adapter.snapshot();
const snapshotMode = snapshot.metadata?.controlMode;
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
controlReady = currentControlMode === "async" || Boolean(snapshot.threadId);
setControlModeStatus(currentControlMode, controlReady);
if (!automatic && (currentControlMode === "async" || controlReady)) {
vscode.window.showInformationMessage(currentControlMode === "sync"
? t("The Codex remote bridge attached to the existing VS Code Codex conversation.")
: t("The independent Codex remote mode connected."));
}
} catch (error) {
activeHost = undefined;
activeAdapter = undefined;
activeRelay = undefined;
if (activeAdapterStatusSubscription === adapterStatusSubscription) {
activeAdapterStatusSubscription.dispose();
activeAdapterStatusSubscription = undefined;
}
await host.stop().catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
if (initialControlMode === "sync" && isAttachSessionUnavailable(message)) {
setStatus("$(sync~spin) Codex Remote", t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."), "codexRemoteCollab.openWeb");
logger.info(`No attachable VS Code Codex session is available; retrying in ${autoStartRetryMs}ms`);
scheduleAutoStartRetry(start);
return;
}
setStatus("$(error) Codex Remote", initialControlMode === "sync" ? t("The Codex conversation is not connected") : t("The independent Codex mode is not connected"), "codexRemoteCollab.openWeb");
if (automatic) {
logger.warn(`Automatic bridge start failed; retrying in ${autoStartRetryMs}ms`, error);
scheduleAutoStartRetry(start);
} else {
const detail = localTarget && /ECONNREFUSED|connect refused/i.test(message)
? t("The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.", localTarget.webUrl)
: t("Unable to start the Codex remote bridge: {0}", message);
vscode.window.showErrorMessage(detail);
}
}
};
const stop = async (): Promise<void> => {
if (autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
autoStartRetryMs = 3_000;
const host = activeHost;
activeHost = undefined;
activeAdapter = undefined;
activeRelay = undefined;
activeAdapterStatusSubscription?.dispose();
activeAdapterStatusSubscription = undefined;
if (host) await host.stop();
setStatus("$(plug) Codex Remote", t("Bridge paused. Click to open the web control and resume automatically."), "codexRemoteCollab.openWeb");
};
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.openWeb", async () => {
const localRelayUrl = resolveRelayConfiguration(vscode.workspace.getConfiguration("codexRemoteCollab")).localUrl;
const webUrl = localRelayController?.getWebUrl(localRelayUrl);
if (!webUrl) {
vscode.window.showErrorMessage(t("The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl."));
return;
}
if (!activeHost) await start(false);
if (activeHost) await vscode.env.openExternal(vscode.Uri.parse(webUrl));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.start", start));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.stop", stop));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setThreadId", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const current = configuration.get<string>("threadId", "");
const value = await vscode.window.showInputBox({
prompt: t("Existing Codex conversation ID (leave blank for auto-discovery)"),
value: current,
ignoreFocusOut: true,
});
if (value === undefined) return;
await configuration.update("threadId", value.trim(), vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(value.trim()
? t("Codex Remote will attach to {0} after the next bridge start.", value.trim())
: t("Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setRelayToken", async () => {
const token = await vscode.window.showInputBox({ prompt: t("Relay access token (leave blank for the local relay)"), password: true, ignoreFocusOut: true });
if (token === undefined) return;
await context.secrets.store("codexRemoteCollab.relayToken", token);
vscode.window.showInformationMessage(t("Relay token stored in VS Code SecretStorage."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.configureCloud", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const currentUrl = configuration.get<string>("cloudRelayUrl", "");
const url = await vscode.window.showInputBox({
prompt: t("Aether cloud relay WebSocket URL"),
value: currentUrl,
placeHolder: "wss://aether.example.com/api/vscodex/ws",
ignoreFocusOut: true,
validateInput: validateCloudRelayUrl,
});
if (url === undefined) return;
if (!url.trim()) {
await configuration.update("cloudRelayUrl", "", vscode.ConfigurationTarget.Global);
await context.secrets.delete("codexRemoteCollab.cloudRelayToken");
vscode.window.showInformationMessage(t("Aether cloud connection removed. Local control remains enabled."));
return;
}
const token = await vscode.window.showInputBox({
prompt: t("Device credential from the Aether pairing flow"),
password: true,
ignoreFocusOut: true,
});
if (token === undefined) return;
if (!token.trim()) {
vscode.window.showWarningMessage(t("A non-empty Aether device credential is required."));
return;
}
await configuration.update("cloudRelayUrl", url.trim(), vscode.ConfigurationTarget.Global);
await context.secrets.store("codexRemoteCollab.cloudRelayToken", token.trim());
vscode.window.showInformationMessage(t("Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.pairCloud", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const currentBaseUrl = configuration.get<string>("aetherUrl", "");
const baseUrl = await vscode.window.showInputBox({
prompt: t("Aether server URL"),
value: currentBaseUrl,
placeHolder: "https://aether.example.com",
ignoreFocusOut: true,
validateInput: validateAetherBaseUrl,
});
if (baseUrl === undefined || !baseUrl.trim()) return;
const code = await vscode.window.showInputBox({
prompt: t("One-time pairing code shown in Aether"),
placeHolder: "ABCD-EFGH",
ignoreFocusOut: true,
validateInput: (value) => normalizePairingCode(value).length === 8 ? undefined : t("Enter the 8-character pairing code."),
});
if (code === undefined || !code.trim()) return;
try {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const response = await fetch(`${normalizedBaseUrl}/api/vscodex/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: normalizePairingCode(code), name: hostname() || "VS Code" }),
});
const raw = await response.text();
let result: unknown;
try {
result = JSON.parse(raw);
} catch {
result = null;
}
if (!response.ok) {
const detail = isJsonRecord(result) && typeof result.error === "string" ? result.error : `HTTP ${response.status}`;
throw new Error(detail);
}
if (!isJsonRecord(result) || typeof result.device_token !== "string" || typeof result.ws_url !== "string") {
throw new Error(t("Aether returned an invalid pairing response."));
}
const wsError = validateCloudRelayUrl(result.ws_url);
if (wsError) throw new Error(wsError);
await configuration.update("aetherUrl", normalizedBaseUrl, vscode.ConfigurationTarget.Global);
await configuration.update("cloudRelayUrl", result.ws_url, vscode.ConfigurationTarget.Global);
await context.secrets.store("codexRemoteCollab.cloudRelayToken", result.device_token);
if (activeHost) await stop();
await start(false);
if (!activeHost) return;
if (activeRelay?.isConnected("aether-cloud")) {
vscode.window.showInformationMessage(t("Aether pairing completed. Local and cloud control are both active."));
} else {
vscode.window.showWarningMessage(t("Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry."));
}
} catch (error) {
vscode.window.showErrorMessage(t("Unable to pair with Aether: {0}", error instanceof Error ? error.message : String(error)));
}
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.sendInput", async () => {
if (!activeAdapter) {
vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
return;
}
const text = await vscode.window.showInputBox({ prompt: t("Send input to the active Codex turn"), ignoreFocusOut: true });
if (text === undefined || !text.trim()) return;
try {
await activeAdapter.sendInput(text);
} catch (error) {
vscode.window.showErrorMessage(t("Unable to send Codex input: {0}", error instanceof Error ? error.message : String(error)));
}
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.snapshot", async () => {
if (!activeAdapter) return vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
const snapshot = await activeAdapter.snapshot();
output.appendLine(JSON.stringify(snapshot));
output.show(true);
}));
if (vscode.workspace.getConfiguration("codexRemoteCollab").get<boolean>("autoStart", true)) await start(true);
}
export async function deactivate(): Promise<void> {
if (autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
const host = activeHost;
activeHost = undefined;
activeAdapter = undefined;
activeAdapterStatusSubscription?.dispose();
activeAdapterStatusSubscription = undefined;
if (host) await host.stop();
const localRelay = localRelayController;
localRelayController = undefined;
if (localRelay) await localRelay.stop();
}
function firstWorkspaceRoot(): string | undefined {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
function workspaceRoots(): string[] {
return (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath);
}
function setStatus(text: string, tooltip: string, command?: string): void {
if (!statusItem) return;
statusItem.text = text;
statusItem.tooltip = tooltip;
statusItem.command = command;
}
function setAttachStatus(ready: boolean): void {
setStatus(
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
ready ? t("Attached to the existing Codex conversation. Click to open the web control.") : t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."),
"codexRemoteCollab.openWeb",
);
}
function setControlModeStatus(mode: ControlMode, ready: boolean): void {
if (mode === "sync") {
setAttachStatus(ready);
return;
}
setStatus(
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
ready
? t("Independent Codex mode is connected. Click to open the web control.")
: t("Starting the independent Codex mode."),
"codexRemoteCollab.openWeb",
);
}
function scheduleAutoStartRetry(start: (automatic?: boolean) => Promise<void>): void {
if (autoStartRetryTimer) return;
const delay = autoStartRetryMs;
autoStartRetryMs = Math.min(autoStartRetryMs * 2, 30_000);
autoStartRetryTimer = setTimeout(() => {
autoStartRetryTimer = undefined;
void start(true);
}, delay);
}
function formatArgs(args: unknown[]): string {
return args.length ? args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") : "";
}
function isAttachSessionUnavailable(message: string): boolean {
return message.includes("没有找到已打开的 VS Code Codex 会话")
|| /找不到会话\s+.+\s+的 VS Code Codex owner/.test(message);
}
function validateCloudRelayUrl(value: string): string | undefined {
if (!value.trim()) return undefined;
try {
const url = new URL(value.trim());
if (url.protocol !== "wss:" && url.protocol !== "ws:") return t("Use a ws:// or wss:// URL.");
if (url.protocol === "ws:" && !isLoopbackHostname(url.hostname)) {
return t("Remote Aether connections must use wss://.");
}
return undefined;
} catch {
return t("Enter a valid WebSocket URL.");
}
}
function resolveRelayConfiguration(configuration: vscode.WorkspaceConfiguration): {
localUrl: string;
cloudUrl: string;
legacyRemote: boolean;
} {
const defaultLocalUrl = "ws://127.0.0.1:8787/v1/connect";
const explicitLocal = inspectedValue<string>(configuration.inspect<string>("localRelayUrl"));
const explicitCloud = inspectedValue<string>(configuration.inspect<string>("cloudRelayUrl"));
const explicitLegacy = inspectedValue<string>(configuration.inspect<string>("relayUrl"));
const legacyUrl = explicitLegacy?.trim() || "";
const legacyRemote = Boolean(legacyUrl && !localRelayTarget(legacyUrl));
const localUrl = (explicitLocal?.trim()
|| (!legacyRemote ? legacyUrl : "")
|| configuration.get<string>("localRelayUrl", defaultLocalUrl).trim()
|| defaultLocalUrl);
const cloudUrl = explicitCloud?.trim()
|| (legacyRemote ? legacyUrl : "")
|| configuration.get<string>("cloudRelayUrl", "").trim();
return { localUrl, cloudUrl, legacyRemote };
}
function inspectedValue<T>(inspection: ReturnType<vscode.WorkspaceConfiguration["inspect"]> | undefined): T | undefined {
if (!inspection) return undefined;
const values = inspection as {
globalLanguageValue?: T;
workspaceFolderLanguageValue?: T;
workspaceLanguageValue?: T;
workspaceFolderValue?: T;
workspaceValue?: T;
globalValue?: T;
};
return values.workspaceFolderLanguageValue
?? values.workspaceLanguageValue
?? values.globalLanguageValue
?? values.workspaceFolderValue
?? values.workspaceValue
?? values.globalValue;
}
function resolveInitialControlMode(configuration: vscode.WorkspaceConfiguration): ControlMode {
const configured = inspectedValue<ControlMode>(configuration.inspect<ControlMode>("controlMode"));
if (configured === "sync" || configured === "async") return configured;
const legacyMode = inspectedValue<"attach" | "spawn">(configuration.inspect<"attach" | "spawn">("mode"));
return legacyMode === "spawn" ? "async" : "sync";
}
function validateAetherBaseUrl(value: string): string | undefined {
if (!value.trim()) return t("Enter the Aether server URL.");
try {
const url = new URL(value.trim());
if (url.username || url.password || url.search || url.hash) return t("Use the Aether origin without credentials, a query, or a fragment.");
if (url.protocol === "https:") return undefined;
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return undefined;
return t("Remote Aether servers must use https://.");
} catch {
return t("Enter a valid URL.");
}
}
function normalizePairingCode(value: string): string {
return value.toUpperCase().replace(/[^A-Z2-9]/g, "");
}
function isLoopbackHostname(value: string): boolean {
const hostname = value.replace(/^\[|\]$/g, "").toLowerCase();
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
}
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Reuse the official extension's command registry for the header's new-chat
* action. This keeps the remote UI attached to the same VS Code Codex
* installation and avoids launching a second app-server process.
*/
async function openOfficialNewSession(logger: Logger): Promise<JsonObject> {
const commands = await vscode.commands.getCommands(true);
const command = commands.includes("chatgpt.newCodexPanel")
? "chatgpt.newCodexPanel"
: commands.includes("chatgpt.newChat")
? "chatgpt.newChat"
: undefined;
if (!command) {
throw new Error(t("The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled."));
}
await vscode.commands.executeCommand(command);
logger.info?.("Opened a new official Codex conversation with " + command);
return { opened: true, command };
}
@@ -0,0 +1,11 @@
export * from "./protocol";
export * from "./jsonlRpc";
export * from "./codexAgentAdapter";
export * from "./relayClient";
export * from "./compositeRelay";
export * from "./relayHost";
export * from "./bridge";
export * from "./codexPath";
export * from "./codexIpc";
export * from "./codexIpcAgentAdapter";
export * from "./switchableAgentAdapter";
@@ -0,0 +1,268 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { createInterface, Interface as ReadLineInterface } from "node:readline";
import {
asJsonValue,
Disposable,
isRecord,
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
JsonValue,
Logger,
jsonRpcIdKey,
} from "./protocol";
export interface JsonlRpcClientOptions {
command?: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
logger?: Logger;
/** Optional request timeout. Zero disables it, which is useful for long turns. */
requestTimeoutMs?: number;
}
interface PendingRequest {
method: string;
resolve: (value: JsonValue) => void;
reject: (reason: Error) => void;
timer?: NodeJS.Timeout;
}
export class JsonRpcRemoteError extends Error {
constructor(
message: string,
readonly code: number,
readonly data?: JsonValue,
) {
super(message);
this.name = "JsonRpcRemoteError";
}
}
/** Minimal newline-delimited JSON-RPC client used by `codex app-server --stdio`. */
export class JsonlRpcClient {
private readonly options: Required<Pick<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">> &
Omit<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">;
private child?: ChildProcessWithoutNullStreams;
private stdoutLines?: ReadLineInterface;
private nextId = 1;
private readonly pending = new Map<string, PendingRequest>();
private readonly notificationListeners = new Set<(message: JsonRpcNotification) => void>();
private readonly requestListeners = new Set<(message: JsonRpcRequest) => void>();
private readonly exitListeners = new Set<(error?: Error) => void>();
constructor(options: JsonlRpcClientOptions = {}) {
this.options = {
command: options.command ?? "codex",
args: options.args ?? ["app-server", "--stdio"],
requestTimeoutMs: options.requestTimeoutMs ?? 0,
cwd: options.cwd,
env: options.env,
logger: options.logger,
};
}
get running(): boolean {
return Boolean(this.child && this.child.exitCode === null && !this.child.killed);
}
async start(): Promise<void> {
if (this.running) return;
const child = spawn(this.options.command, this.options.args, {
cwd: this.options.cwd,
env: { ...process.env, ...(this.options.env ?? {}) },
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.child = child;
this.stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity });
this.stdoutLines.on("line", (line) => this.handleLine(line));
child.stderr.on("data", (chunk: Buffer) => {
const text = redactDiagnostic(chunk.toString("utf8").trim());
if (text) this.options.logger?.debug?.(`[app-server stderr] ${text}`);
});
child.once("exit", (code, signal) => {
const expected = child.killed;
const error = expected
? undefined
: new Error(`codex app-server exited (code=${String(code)}, signal=${String(signal)})`);
this.handleExit(error);
});
await new Promise<void>((resolve, reject) => {
const onSpawn = (): void => {
child.off("error", onError);
resolve();
};
const onError = (error: Error): void => {
child.off("spawn", onSpawn);
const spawnError = error as NodeJS.ErrnoException;
if (spawnError.code === "ENOENT") {
reject(new Error(`Codex executable "${this.options.command}" was not found. Set codexRemoteCollab.codexCommand to its full path.`));
return;
}
reject(error);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
}
request(method: string, params?: JsonValue): Promise<JsonValue> {
if (!this.running) return Promise.reject(new Error("app-server is not running"));
const id = this.nextId++;
return new Promise<JsonValue>((resolve, reject) => {
const pending: PendingRequest = { method, resolve, reject };
if (this.options.requestTimeoutMs > 0) {
pending.timer = setTimeout(() => {
this.pending.delete(jsonRpcIdKey(id));
reject(new Error(`app-server request timed out: ${method}`));
}, this.options.requestTimeoutMs);
}
this.pending.set(jsonRpcIdKey(id), pending);
try {
this.write({ id, method, ...(params === undefined ? {} : { params }) });
} catch (error) {
this.pending.delete(jsonRpcIdKey(id));
if (pending.timer) clearTimeout(pending.timer);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
notify(method: string, params?: JsonValue): void {
this.write({ method, ...(params === undefined ? {} : { params }) });
}
respond(id: JsonRpcId, result: JsonValue): void {
this.write({ id, result });
}
respondError(id: JsonRpcId, code: number, message: string, data?: JsonValue): void {
this.write({ id, error: { code, message, ...(data === undefined ? {} : { data }) } });
}
onNotification(listener: (message: JsonRpcNotification) => void): Disposable {
this.notificationListeners.add(listener);
return { dispose: () => this.notificationListeners.delete(listener) };
}
onServerRequest(listener: (message: JsonRpcRequest) => void): Disposable {
this.requestListeners.add(listener);
return { dispose: () => this.requestListeners.delete(listener) };
}
onExit(listener: (error?: Error) => void): Disposable {
this.exitListeners.add(listener);
return { dispose: () => this.exitListeners.delete(listener) };
}
close(): void {
const child = this.child;
this.child = undefined;
this.stdoutLines?.close();
this.stdoutLines = undefined;
if (child && child.exitCode === null && !child.killed) child.kill();
this.rejectAll(new Error("app-server client closed"));
}
private write(message: unknown): void {
const child = this.child;
if (!child || child.exitCode !== null || child.killed || !child.stdin.writable) {
throw new Error("app-server is not running");
}
child.stdin.write(`${JSON.stringify(message)}\n`, "utf8");
}
private handleLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) return;
let message: unknown;
try {
message = JSON.parse(trimmed);
} catch (error) {
this.options.logger?.warn?.("Ignoring malformed app-server JSON", error, trimmed.slice(0, 500));
return;
}
if (!isRecord(message)) return;
const hasId = typeof message.id === "string" || typeof message.id === "number";
const hasMethod = typeof message.method === "string";
if (hasId && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error")) && !hasMethod) {
this.handleResponse(message as Record<string, unknown> & { id: JsonRpcId });
return;
}
if (hasMethod && hasId) {
const request: JsonRpcRequest = {
id: message.id as JsonRpcId,
method: message.method as string,
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
};
for (const listener of this.requestListeners) listener(request);
return;
}
if (hasMethod) {
const notification: JsonRpcNotification = {
method: message.method as string,
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
};
for (const listener of this.notificationListeners) listener(notification);
return;
}
this.options.logger?.warn?.("Ignoring unknown app-server message", message);
}
private handleResponse(message: Record<string, unknown> & { id: JsonRpcId }): void {
const pending = this.pending.get(jsonRpcIdKey(message.id));
if (!pending) {
this.options.logger?.warn?.(`Received response for unknown app-server request ${String(message.id)}`);
return;
}
this.pending.delete(jsonRpcIdKey(message.id));
if (pending.timer) clearTimeout(pending.timer);
if (isRecord(message.error)) {
pending.reject(
new JsonRpcRemoteError(
typeof message.error.message === "string" ? message.error.message : `Request failed: ${pending.method}`,
typeof message.error.code === "number" ? message.error.code : -32000,
message.error.data === undefined ? undefined : asJsonValue(message.error.data),
),
);
return;
}
pending.resolve(message.result === undefined ? null : asJsonValue(message.result));
}
private handleExit(error?: Error): void {
this.child = undefined;
this.stdoutLines?.close();
this.stdoutLines = undefined;
this.rejectAll(error ?? new Error("app-server exited"));
for (const listener of this.exitListeners) listener(error);
}
private rejectAll(error: Error): void {
for (const request of this.pending.values()) {
if (request.timer) clearTimeout(request.timer);
request.reject(error);
}
this.pending.clear();
}
}
function redactDiagnostic(text: string): string {
return text
.replace(/Bearer\s+[A-Za-z0-9._~+\-/]+=*/gi, "Bearer [REDACTED]")
.replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})\b/g, "[REDACTED]")
.replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
}
@@ -0,0 +1,193 @@
import * as http from "node:http";
import * as path from "node:path";
import { Logger } from "./protocol";
interface BundledRelay {
start(): Promise<{ host: string; port: number }>;
stop(): Promise<void>;
}
interface BundledRelayModule {
CodexRelay: new (options: Record<string, unknown>) => BundledRelay;
}
export interface LocalRelayTarget {
host: string;
port: number;
healthUrl: string;
webUrl: string;
}
export interface LocalRelayControllerOptions {
extensionPath: string;
logger?: Logger;
probeTimeoutMs?: number;
relayModulePath?: string;
loadRelayModule?: (modulePath: string) => BundledRelayModule;
probeRelayHealth?: (url: string, timeoutMs?: number) => Promise<boolean>;
}
/**
* Owns the loopback relay bundled with the companion extension. Remote and
* TLS relay URLs deliberately stay outside this controller.
*/
export class LocalRelayController {
private readonly options: LocalRelayControllerOptions;
private relay?: BundledRelay;
private target?: LocalRelayTarget;
private starting?: Promise<boolean>;
private generation = 0;
constructor(options: LocalRelayControllerOptions) {
this.options = options;
}
async ensureRunning(relayUrl: string): Promise<boolean> {
const target = localRelayTarget(relayUrl);
if (!target) return false;
if ((this.relay || this.starting) && this.target?.healthUrl !== target.healthUrl) await this.stop();
const generation = this.generation;
this.target = target;
const available = await this.probeHealth(target.healthUrl);
// `stop()` may run while the health request is in flight. Do not let that
// completed probe resurrect a relay owned by a deactivated extension.
if (generation !== this.generation) return false;
if (available) return false;
if (this.relay) {
await this.relay.stop().catch(() => undefined);
this.relay = undefined;
}
if (this.starting) return this.starting;
this.starting = this.startBundledRelay(target).finally(() => {
this.starting = undefined;
});
return this.starting;
}
getWebUrl(relayUrl: string): string | undefined {
return localRelayTarget(relayUrl)?.webUrl;
}
async stop(): Promise<void> {
this.generation += 1;
const starting = this.starting;
if (starting) await starting.catch(() => undefined);
const relay = this.relay;
this.relay = undefined;
this.target = undefined;
if (relay) await relay.stop();
}
private async startBundledRelay(target: LocalRelayTarget): Promise<boolean> {
const modulePath = this.options.relayModulePath
?? path.join(this.options.extensionPath, "dist", "local-relay", "server.js");
let relay: BundledRelay;
try {
const load = this.options.loadRelayModule ?? ((value: string) => require(value) as BundledRelayModule);
const module = load(modulePath);
if (typeof module?.CodexRelay !== "function") throw new Error("bundled relay module is invalid");
relay = new module.CodexRelay({
host: target.host,
port: target.port,
mode: "host",
spawnCodex: false,
authRequired: false,
});
await relay.start();
} catch (error) {
// Another VS Code window can win the listen race after our health
// probe. Treat that as success only when the expected relay responds.
if (await this.probeHealth(target.healthUrl)) {
this.options.logger?.info?.(`Using existing local relay at ${target.webUrl}`);
return false;
}
throw error;
}
this.relay = relay;
this.options.logger?.info?.(`Started bundled local relay at ${target.webUrl}`);
return true;
}
private probeHealth(url: string): Promise<boolean> {
const probe = this.options.probeRelayHealth ?? relayHealthAvailable;
return probe(url, this.options.probeTimeoutMs);
}
}
export function localRelayTarget(relayUrl: string): LocalRelayTarget | undefined {
let url: URL;
try {
url = new URL(relayUrl);
} catch {
return undefined;
}
if (url.protocol !== "ws:" || !isLoopbackHostname(url.hostname)) return undefined;
const port = Number(url.port || 80);
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
const hostname = normalizeLoopbackHostname(url.hostname);
const authorityHost = hostname.includes(":") ? `[${hostname}]` : hostname;
return {
host: hostname,
port,
healthUrl: `http://${authorityHost}:${port}/api/health`,
webUrl: `http://${authorityHost}:${port}/`,
};
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function normalizeLoopbackHostname(hostname: string): string {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" ? "127.0.0.1" : normalized;
}
export function relayHealthAvailable(url: string, timeoutMs = 700): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
let timer: NodeJS.Timeout | undefined;
const finish = (available: boolean): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(available);
};
const request = http.get(url, (response) => {
if (response.statusCode !== 200) {
response.resume();
finish(false);
return;
}
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
if (body.length <= 16_384) body += chunk;
});
response.on("end", () => {
try {
const payload = JSON.parse(body) as { ok?: unknown };
finish(payload.ok === true);
} catch {
finish(false);
}
});
response.on("aborted", () => finish(false));
response.on("error", () => finish(false));
response.on("close", () => {
if (!response.complete) finish(false);
});
});
request.setTimeout(timeoutMs, () => {
request.destroy();
finish(false);
});
request.on("error", () => finish(false));
timer = setTimeout(() => {
request.destroy();
finish(false);
}, timeoutMs);
});
}
@@ -0,0 +1,496 @@
/**
* Wire types shared by the relay host and the Codex app-server adapter.
*
* The relay intentionally treats `payload` as JSON. Keeping this boundary
* unopinionated lets the bridge continue working when app-server adds a new
* notification or request before this extension is updated.
*/
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export type JsonRpcId = string | number;
/** Preserve the JSON-RPC id type when using it as a map key. */
export function jsonRpcIdKey(id: JsonRpcId): string {
return `${typeof id}:${String(id)}`;
}
export function isJsonRpcId(value: unknown): value is JsonRpcId {
return typeof value === "string" || typeof value === "number";
}
export type ApprovalDecisionKind = "allow" | "deny" | "cancel";
const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]);
const V2_APPROVAL_METHODS = new Set([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
]);
/**
* Classify both current and legacy app-server approval decisions without
* rewriting the wire value. Unknown tagged objects intentionally return
* `undefined` so callers can fail closed instead of accidentally approving a
* newly introduced response shape.
*/
export function approvalDecisionKind(value: unknown): ApprovalDecisionKind | undefined {
if (typeof value === "string") {
if (new Set([
"allow",
"accept",
"acceptForSession",
"approved",
"approved_for_session",
"approved_mcp_policy_amendment",
]).has(value)) return "allow";
if (new Set(["deny", "decline", "denied", "timed_out"]).has(value)) return "deny";
if (new Set(["cancel", "abort"]).has(value)) return "cancel";
return undefined;
}
if (!isRecord(value)) return undefined;
const keys = Object.keys(value);
if (keys.length !== 1) return undefined;
const key = keys[0];
const nested = value[key];
if (isExecpolicyAmendmentTag(key, nested) || isNetworkPolicyAmendmentTag(key, nested)) return "allow";
if (key === "denied" && isRecord(nested) && typeof nested.rejection === "string") return "deny";
return undefined;
}
/**
* Classify a decision against the response schema for one app-server method.
* The generic classifier above is intentionally useful for relay envelopes;
* this method-aware variant prevents a v2 tagged object from being sent to a
* legacy callback (or vice versa), while retaining compatibility aliases that
* the relay may use in its outer `decision` field.
*/
export function approvalDecisionKindForMethod(
value: unknown,
method?: string,
): ApprovalDecisionKind | undefined {
const generic = approvalDecisionKind(value);
if (!generic || !method) return generic;
if (LEGACY_APPROVAL_METHODS.has(method)) {
if (typeof value === "string") {
return new Set([
"approved",
"approved_for_session",
"approved_mcp_policy_amendment",
"timed_out",
"abort",
]).has(value) ? generic : undefined;
}
if (!isRecord(value)) return undefined;
const key = Object.keys(value)[0];
return key === "approved_execpolicy_amendment"
|| key === "network_policy_amendment"
|| key === "denied" ? generic : undefined;
}
if (V2_APPROVAL_METHODS.has(method)) {
if (typeof value === "string") {
return new Set(["accept", "acceptForSession", "decline", "cancel"]).has(value)
? generic
: undefined;
}
if (!isRecord(value)) return undefined;
const key = Object.keys(value)[0];
if (method === "item/fileChange/requestApproval") return undefined;
return key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"
? generic
: undefined;
}
if (method === "mcpServer/elicitation/request") {
return typeof value === "string" && new Set(["accept", "decline", "cancel"]).has(value)
? generic
: undefined;
}
return generic;
}
function isExecpolicyAmendmentTag(key: string, nested: unknown): boolean {
if (!isRecord(nested)) return false;
if (key === "acceptWithExecpolicyAmendment") {
return isStringArray(nested.execpolicy_amendment);
}
if (key === "approved_execpolicy_amendment") {
return isStringArray(nested.proposed_execpolicy_amendment);
}
return false;
}
function isNetworkPolicyAmendmentTag(key: string, nested: unknown): boolean {
if (!isRecord(nested)) return false;
if (key === "applyNetworkPolicyAmendment") {
return isNetworkPolicyAmendment(nested.network_policy_amendment);
}
if (key === "network_policy_amendment") {
return isNetworkPolicyAmendment(nested.network_policy_amendment);
}
return false;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isNetworkPolicyAmendment(value: unknown): boolean {
return isRecord(value)
&& typeof value.host === "string"
&& (value.action === "allow" || value.action === "deny");
}
/** Whether a response explicitly carries a decision/action field. */
export function hasApprovalDecisionField(value: unknown): value is Record<string, unknown> {
return isRecord(value) && (Object.prototype.hasOwnProperty.call(value, "decision")
|| Object.prototype.hasOwnProperty.call(value, "action"));
}
export interface Disposable {
dispose(): void;
}
export interface JsonRpcRequest {
id: JsonRpcId;
method: string;
params?: JsonValue;
}
export interface JsonRpcNotification {
method: string;
params?: JsonValue;
}
export interface JsonRpcResponse {
id: JsonRpcId;
result?: JsonValue;
error?: {
code: number;
message: string;
data?: JsonValue;
};
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
export type RelayRole = "owner" | "operator" | "approver" | "viewer" | string;
export interface RelayActor {
id?: string;
role?: RelayRole;
}
/** A versioned relay event frame. `seq` is normally assigned by the relay. */
export interface RelayEventFrame {
v: 1;
kind: "event";
type: string;
id: string;
sessionId: string;
seq?: number;
ts: string;
actor?: RelayActor;
payload: JsonObject;
/** Optional typed execution projection attached by a VS Code host. */
status?: AgentStatusSnapshot;
}
export interface RelayCommandFrame {
v?: 1;
kind?: "command";
type: string;
/** Compact relay compatibility form: `{ type: "command", method, params }`. */
method?: string;
params?: JsonObject;
commandId?: string;
id?: string;
sessionId?: string;
actor?: RelayActor;
payload?: JsonObject;
/** Some clients put the command body under `command`. */
command?: {
type?: string;
commandId?: string;
payload?: JsonObject;
[key: string]: JsonValue | undefined;
};
}
export interface RelayHelloFrame {
v: 1;
kind: "hello";
clientType: "host" | "web" | string;
protocol?: number;
accessToken?: string;
token?: string;
lastSeq?: number;
sessionId?: string;
payload?: JsonObject;
}
export interface RelayAckFrame {
v: 1;
kind: "ack";
sessionId: string;
seq: number;
}
export interface RelayErrorFrame {
v: 1;
kind: "error";
code: string;
message: string;
retryable?: boolean;
commandId?: string;
}
export type RelayFrame =
| RelayEventFrame
| RelayCommandFrame
| RelayHelloFrame
| RelayAckFrame
| RelayErrorFrame
| (JsonObject & { kind?: string; v?: number });
/**
* Live execution information projected from the official Codex conversation
* state. The private IPC protocol can add new turn statuses/flags, so the
* string fields intentionally remain open-ended for forward compatibility.
*/
export interface AgentStatusSnapshot {
/** Coarse UI activity, for example `thinking`, `editing`, or `running`. */
activity: string;
/** Raw/normalized turn status (`inProgress`, `completed`, ...). */
turnStatus: string;
/** Runtime flags such as `waitingOnApproval` or `waitingOnUserInput`. */
activeFlags: string[];
startedAtMs?: number | null;
durationMs?: number | null;
/**
* Time spent doing work in the official UI. This deliberately differs
* from `durationMs`: Codex starts the worked-for clock at the first work
* item and stops it when the final assistant response starts.
*/
workedDurationMs?: number | null;
/** Elapsed wall-clock time for an active turn. */
elapsedMs?: number | null;
firstTurnWorkItemStartedAtMs?: number | null;
finalAssistantStartedAtMs?: number | null;
error?: JsonValue;
}
/** Official background-agent lifecycle values emitted by Codex v2 items. */
export type CollabAgentStatus =
| "pendingInit"
| "running"
| "interrupted"
| "completed"
| "errored"
| "shutdown"
| "notFound"
| string;
export type CollabAgentTool =
| "spawnAgent"
| "sendInput"
| "resumeAgent"
| "wait"
| "closeAgent"
| string;
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | string;
export type SubAgentActivityKind = "started" | "interacted" | "interrupted" | "completed" | string;
/** Last known state for one receiver in a collabAgentToolCall item. */
export interface CollabAgentStateSnapshot {
status: CollabAgentStatus;
message?: string | null;
}
/**
* Browser-safe projection of a background Codex subagent. The official
* webview currently uses the four coarse statuses below; the string union is
* deliberately open so a newer app-server status does not break the relay.
*/
export interface SubagentSnapshot {
threadId: string;
displayName: string | null;
prompt: string | null;
/** Alias used by the subagent side panel for the same prompt text. */
objective?: string | null;
status: "waiting" | "working" | "done" | "failed" | string;
statusMessage: string | null;
startedAtMs?: number | null;
completedAtMs?: number | null;
canInteract?: boolean;
model?: string | null;
agentPath?: string | null;
parentThreadId?: string | null;
}
export interface AgentEvent {
/** Normalized relay event name, for example `output.chunk`. */
type: string;
threadId?: string;
turnId?: string;
requestId?: JsonRpcId;
payload: JsonObject;
/** Original app-server notification/request, when available. */
raw?: JsonValue;
/** Optional typed projection of live Codex turn/runtime status. */
status?: AgentStatusSnapshot;
}
export interface PendingApproval {
requestId: JsonRpcId;
method: string;
threadId?: string;
turnId?: string;
itemId?: string;
action: string;
risk: "low" | "medium" | "high" | "unknown";
summary: string;
/** SHA-256 of canonicalized, unredacted app-server request params. */
commandHash?: string;
createdAt: number;
expiresAt?: number;
payload: JsonObject;
}
export interface SessionSnapshot {
threadId: string | null;
turnId: string | null;
state: string;
pendingApprovals: PendingApproval[];
pendingRequests?: Array<{
requestId: JsonRpcId;
method: string;
params?: JsonValue;
commandHash?: string;
risk?: string;
summary?: string;
createdAt?: number;
expiresAt?: number;
}>;
outputTail: string;
/** Optional role-aware projection used by the browser renderer. */
messages?: JsonValue[];
/** Background/inline subagents reconstructed from official collab items. */
subagents?: SubagentSnapshot[];
/** Live execution projection; retained alongside the legacy `state` field. */
status?: AgentStatusSnapshot;
/** Convenience aliases for clients that do not consume `status` yet. */
activity?: string;
turnStatus?: string;
activeFlags?: string[];
startedAtMs?: number | null;
durationMs?: number | null;
workedDurationMs?: number | null;
elapsedMs?: number | null;
metadata?: JsonObject;
}
/** A live VS Code Codex conversation that the attach bridge has verified. */
export interface SessionListEntry {
threadId: string;
title: string;
updatedAtMs: number | null;
cwd?: string | null;
active: boolean;
/** True for attach-mode results; retained for wire compatibility. */
available: boolean;
}
export interface SessionListResult {
sessions: SessionListEntry[];
activeThreadId: string | null;
}
/** Which owner controls conversation navigation for the remote surface. */
export type ControlMode = "sync" | "async";
export interface AgentAdapter {
start(): Promise<void>;
/** Switch between following VS Code and independently owned conversations. */
setControlMode?(params: JsonObject): Promise<JsonValue>;
/** Return the currently committed control mode without taking a snapshot. */
getControlMode?(): ControlMode;
/** Start a new app-server thread. */
startThread?(params?: JsonObject): Promise<JsonValue>;
/** Ask the official VS Code Codex extension to open a fresh conversation. */
newSession?(params?: JsonObject): Promise<JsonValue>;
/** Start a turn; `threadId` may be supplied in params or use the active thread. */
startTurn?(params: JsonObject): Promise<JsonValue>;
/** Steer the active turn. */
steerTurn?(params: JsonObject): Promise<JsonValue>;
/** Persist model/effort and other owner-managed settings on the thread. */
updateThreadSettings?(params: JsonObject): Promise<JsonValue>;
/** List verified, attachable local conversations without starting another Codex process. */
listSessions?(params?: JsonObject): Promise<JsonValue>;
/** Attach the follower to another already-open conversation. */
selectSession?(params: JsonObject): Promise<JsonValue>;
/** Interrupt a turn. */
interruptTurn?(params: JsonObject): Promise<JsonValue>;
/** Convenience MVP aliases. */
sendInput(text: string, params?: JsonObject): Promise<JsonValue>;
cancel(taskId?: string, params?: JsonObject): Promise<JsonValue>;
respondApproval(
requestId: JsonRpcId,
decision: "allow" | "deny" | "cancel",
reason?: string,
response?: JsonValue,
): Promise<JsonValue>;
/** Resolve all pending approvals/inputs with a deny response. */
denyPending?(reason?: string): Promise<void>;
snapshot(): Promise<SessionSnapshot>;
onEvent(listener: (event: AgentEvent) => void): Disposable;
dispose(): Promise<void>;
}
export interface RelayTransport {
connect(): Promise<void>;
send(frame: RelayFrame): void;
onMessage(listener: (frame: RelayFrame) => void): Disposable;
onOpen?(listener: () => void): Disposable;
onClose?(listener: (error?: Error) => void): Disposable;
close(): void;
}
export interface Logger {
debug?(message: string, ...args: unknown[]): void;
info?(message: string, ...args: unknown[]): void;
warn?(message: string, ...args: unknown[]): void;
error?(message: string, ...args: unknown[]): void;
}
export const noopDisposable = (): Disposable => ({ dispose: () => undefined });
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asJsonObject(value: unknown): JsonObject {
return isRecord(value) ? (value as JsonObject) : {};
}
export function asJsonValue(value: unknown): JsonValue {
if (value === undefined) return null;
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (Array.isArray(value)) {
return value.map(asJsonValue);
}
if (isRecord(value)) {
const output: JsonObject = {};
for (const [key, item] of Object.entries(value)) {
if (item !== undefined) output[key] = asJsonValue(item);
}
return output;
}
return String(value);
}
@@ -0,0 +1,413 @@
import { createInterface, Interface as ReadLineInterface } from "node:readline";
import WebSocket from "ws";
import {
Disposable,
isRecord,
JsonObject,
Logger,
RelayFrame,
RelayHelloFrame,
RelayTransport,
} from "./protocol";
export interface RelayClientOptions {
url: string;
accessToken?: string;
sessionId?: string;
lastSeq?: number;
reconnect?: boolean;
reconnectInitialMs?: number;
reconnectMaxMs?: number;
maxFrameBytes?: number;
maxQueuedBytes?: number;
logger?: Logger;
/** Injectable constructor for tests or a browser-compatible WebSocket. */
webSocket?: new (url: string) => unknown;
}
type SocketLike = {
readyState?: number;
send(data: string): void;
close(): void;
on?(event: string, listener: (...args: any[]) => void): void;
addEventListener?(event: string, listener: (...args: any[]) => void): void;
};
const OPEN = 1;
// A structured Codex history snapshot is routinely larger than 256 KiB even
// though its plain-text tail is capped. Keep a bounded limit, but leave enough
// room for the message/tool projection of a long attached conversation.
export const DEFAULT_MAX_RELAY_FRAME_BYTES = 16 * 1024 * 1024;
export const DEFAULT_MAX_RELAY_QUEUE_BYTES = DEFAULT_MAX_RELAY_FRAME_BYTES + 2 * 1024 * 1024;
interface QueuedFrame {
serialized: string;
bytes: number;
projectionKey?: string;
}
const QUEUED_PROJECTION_TYPES = new Set(["session.snapshot", "output.snapshot", "output.chunk"]);
function queuedProjectionKey(frame: RelayFrame): string | undefined {
if (!isRecord(frame) || frame.kind !== "event" || typeof frame.type !== "string"
|| !QUEUED_PROJECTION_TYPES.has(frame.type)) return undefined;
const sessionId = typeof frame.sessionId === "string" ? frame.sessionId : "default";
return `${sessionId}:transcript`;
}
/** WebSocket relay transport with bounded reconnect and frame validation. */
export class RelayClient implements RelayTransport {
readonly handlesHandshake = true;
private readonly options: Required<
Pick<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">
> &
Omit<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">;
private socket?: SocketLike;
// A WebSocket can report OPEN while its relay authentication handshake is
// still in flight. Keep this separate from `socket` so events emitted by
// the adapter during reconnect are queued until the relay sends auth.ok.
private authenticatedSocket?: SocketLike;
private connecting?: Promise<void>;
// Incremented whenever a connection attempt is replaced or explicitly
// closed. Late events from an older WebSocket must not mutate newer state.
private connectionGeneration = 0;
private reconnectTimer?: NodeJS.Timeout;
private stopped = false;
private retryMs: number;
private readonly queue: QueuedFrame[] = [];
private queueBytes = 0;
private readonly listeners = new Set<(frame: RelayFrame) => void>();
private readonly openListeners = new Set<() => void>();
private readonly closeListeners = new Set<(error?: Error) => void>();
constructor(options: RelayClientOptions) {
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_RELAY_FRAME_BYTES;
const defaultMaxQueuedBytes = Math.max(
maxFrameBytes,
Math.min(DEFAULT_MAX_RELAY_QUEUE_BYTES, maxFrameBytes * 2),
);
this.options = {
...options,
reconnect: options.reconnect ?? true,
reconnectInitialMs: options.reconnectInitialMs ?? 500,
reconnectMaxMs: options.reconnectMaxMs ?? 10_000,
maxFrameBytes,
maxQueuedBytes: Math.max(1, Math.floor(options.maxQueuedBytes ?? defaultMaxQueuedBytes)),
};
this.retryMs = this.options.reconnectInitialMs;
}
/** Let RelayHost assign its stable session id before the first hello. */
setSessionId(sessionId: string): void {
this.options.sessionId = sessionId;
}
async connect(): Promise<void> {
this.stopped = false;
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) return;
if (this.connecting) return this.connecting;
const generation = ++this.connectionGeneration;
let connectionPromise: Promise<void>;
connectionPromise = new Promise<void>((resolve, reject) => {
let settled = false;
let authenticated = false;
const SocketCtor = this.options.webSocket ?? WebSocket;
let socket: SocketLike;
try {
socket = new SocketCtor(this.options.url) as SocketLike;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
this.socket = socket;
this.authenticatedSocket = undefined;
const isCurrent = (): boolean => this.connectionGeneration === generation && this.socket === socket;
const onOpen = (): void => {
if (!isCurrent() || settled || authenticated) return;
try {
// The TCP/WebSocket open event is only a transport milestone. Do
// not release queued commands until the relay has authenticated us.
this.sendHello(socket);
} catch (error) {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
}
};
const onMessage = (raw: unknown): void => {
if (!isCurrent()) return;
const data = extractMessageData(raw);
if (Buffer.byteLength(data, "utf8") > this.options.maxFrameBytes) {
this.options.logger?.warn?.("Ignoring oversized relay frame");
return;
}
let frame: unknown;
try {
frame = JSON.parse(data);
} catch {
this.options.logger?.warn?.("Ignoring malformed relay JSON");
return;
}
if (!isRecord(frame)) return;
if (frame.type === "auth.ok" && !authenticated && !settled) {
authenticated = true;
settled = true;
this.authenticatedSocket = socket;
this.retryMs = this.options.reconnectInitialMs;
try {
this.flush(socket);
} catch (error) {
this.options.logger?.warn?.("Unable to flush relay queue after authentication", error);
}
for (const listener of this.openListeners) listener();
resolve();
} else if (frame.type === "error" && !authenticated && !settled) {
settled = true;
reject(new Error(typeof frame.message === "string" ? frame.message : "relay authentication failed"));
}
if (frame.kind === "event" && typeof frame.seq === "number") {
this.options.lastSeq = Math.max(this.options.lastSeq ?? 0, frame.seq);
}
for (const listener of this.listeners) listener(frame as RelayFrame);
};
const onError = (raw: unknown): void => {
if (!isCurrent()) return;
const error = raw instanceof Error ? raw : new Error("relay websocket error");
this.options.logger?.warn?.(error.message);
if (!settled) {
settled = true;
reject(error);
}
};
const onClose = (): void => {
const current = isCurrent();
if (current) {
this.socket = undefined;
if (this.authenticatedSocket === socket) this.authenticatedSocket = undefined;
}
const error = new Error("relay websocket closed");
// A stale socket may still need to settle the promise returned to its
// caller, but it must never notify the active host or schedule a
// second reconnect loop.
if (!current) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
for (const listener of this.closeListeners) listener(error);
if (!settled) {
settled = true;
reject(error);
}
if (!this.stopped && this.options.reconnect) this.scheduleReconnect();
};
bindSocket(socket, onOpen, onMessage, onError, onClose);
// A small number of test/browser WebSocket implementations can already
// be OPEN by the time listeners are attached.
if (socket.readyState === OPEN) queueMicrotask(onOpen);
}).finally(() => {
if (this.connectionGeneration === generation && this.connecting === connectionPromise) {
this.connecting = undefined;
}
});
this.connecting = connectionPromise;
return connectionPromise;
}
send(frame: RelayFrame): void {
const serialized = JSON.stringify(frame);
const bytes = Buffer.byteLength(serialized, "utf8");
if (bytes > this.options.maxFrameBytes) {
throw new Error(`relay frame exceeds ${this.options.maxFrameBytes} bytes`);
}
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) {
this.socket.send(serialized);
return;
}
this.enqueue({ serialized, bytes, projectionKey: queuedProjectionKey(frame) });
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
onOpen(listener: () => void): Disposable {
this.openListeners.add(listener);
return { dispose: () => this.openListeners.delete(listener) };
}
onClose(listener: (error?: Error) => void): Disposable {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
close(): void {
this.stopped = true;
this.connectionGeneration += 1;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
this.connecting = undefined;
const socket = this.socket;
this.socket = undefined;
this.authenticatedSocket = undefined;
if (socket && socket.readyState !== 3) socket.close();
this.queue.length = 0;
this.queueBytes = 0;
}
private sendHello(socket: SocketLike): void {
const hello: RelayHelloFrame = {
v: 1,
kind: "hello",
clientType: "host",
protocol: 1,
...(this.options.sessionId ? { sessionId: this.options.sessionId } : {}),
...(this.options.lastSeq !== undefined ? { lastSeq: this.options.lastSeq } : {}),
};
socket.send(JSON.stringify(hello));
if (this.options.accessToken) {
// Keep authentication separate from hello so a relay can challenge the
// host before accepting a bearer token (and so hello remains cacheable).
socket.send(JSON.stringify({ v: 1, kind: "auth", accessToken: this.options.accessToken }));
}
}
private flush(socket: SocketLike): void {
if (socket.readyState !== OPEN || this.authenticatedSocket !== socket || this.socket !== socket) return;
while (this.queue.length > 0) {
const entry = this.queue.shift() as QueuedFrame;
this.queueBytes = Math.max(0, this.queueBytes - entry.bytes);
socket.send(entry.serialized);
}
}
private enqueue(entry: QueuedFrame): void {
// Transcript events are reconstructible: RelayHost publishes a fresh full
// session snapshot after every authenticated reconnect. Keep only the
// newest projection per session while preserving approval/command events.
if (entry.projectionKey) {
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
if (this.queue[index].projectionKey === entry.projectionKey) this.removeQueuedFrame(index);
}
}
if (entry.bytes > this.options.maxQueuedBytes) {
this.options.logger?.warn?.("Dropping relay frame that exceeds the reconnect queue byte limit");
return;
}
while (this.queue.length >= 100 || this.queueBytes + entry.bytes > this.options.maxQueuedBytes) {
const projectionIndex = this.queue.findIndex((queued) => Boolean(queued.projectionKey));
if (projectionIndex >= 0) {
this.removeQueuedFrame(projectionIndex);
continue;
}
// Never evict an approval/command solely to retain a transcript delta;
// the authoritative snapshot emitted after auth restores that state.
if (entry.projectionKey) {
this.options.logger?.debug?.("Dropping supersedable relay projection while reconnect queue is full");
return;
}
this.removeQueuedFrame(0);
}
this.queue.push(entry);
this.queueBytes += entry.bytes;
}
private removeQueuedFrame(index: number): void {
const [removed] = this.queue.splice(index, 1);
if (removed) this.queueBytes = Math.max(0, this.queueBytes - removed.bytes);
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.stopped) return;
const delay = this.retryMs;
this.retryMs = Math.min(this.options.reconnectMaxMs, Math.max(this.retryMs * 2, this.options.reconnectInitialMs));
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
void this.connect().catch((error) => this.options.logger?.debug?.("relay reconnect failed", error));
}, delay);
}
}
/**
* Line-oriented transport for local development and CI. Pipe it to a relay
* process with `node dist/cli.js`; each line is one JSON relay frame.
*/
export class StdioRelayTransport implements RelayTransport {
private readonly listeners = new Set<(frame: RelayFrame) => void>();
private readonly lineReader: ReadLineInterface;
private closed = false;
constructor(
private readonly input: NodeJS.ReadableStream = process.stdin,
private readonly output: NodeJS.WritableStream = process.stdout,
private readonly logger?: Logger,
) {
this.lineReader = createInterface({ input, crlfDelay: Infinity });
this.lineReader.on("line", (line) => {
if (!line.trim()) return;
try {
const frame = JSON.parse(line);
if (isRecord(frame)) for (const listener of this.listeners) listener(frame as RelayFrame);
} catch (error) {
this.logger?.warn?.("Ignoring malformed relay stdin frame", error);
}
});
}
async connect(): Promise<void> {
this.closed = false;
}
send(frame: RelayFrame): void {
if (this.closed) throw new Error("stdio relay transport is closed");
this.output.write(`${JSON.stringify(frame)}\n`);
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
close(): void {
this.closed = true;
this.lineReader.close();
}
}
function bindSocket(
socket: SocketLike,
onOpen: () => void,
onMessage: (data: unknown) => void,
onError: (error: unknown) => void,
onClose: () => void,
): void {
if (typeof socket.on === "function") {
socket.on("open", onOpen);
socket.on("message", onMessage);
socket.on("error", onError);
socket.on("close", onClose);
} else if (typeof socket.addEventListener === "function") {
socket.addEventListener("open", onOpen);
socket.addEventListener("message", onMessage);
socket.addEventListener("error", onError);
socket.addEventListener("close", onClose);
} else {
onError(new Error("WebSocket implementation has no event API"));
}
}
function extractMessageData(raw: unknown): string {
if (typeof raw === "string") return raw;
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
if (isRecord(raw) && "data" in raw) return extractMessageData(raw.data);
return String(raw ?? "");
}
@@ -0,0 +1,650 @@
import { randomUUID } from "node:crypto";
import {
AgentAdapter,
AgentEvent,
approvalDecisionKind,
approvalDecisionKindForMethod,
asJsonObject,
asJsonValue,
Disposable,
hasApprovalDecisionField,
isRecord,
JsonObject,
JsonRpcId,
Logger,
JsonValue,
RelayActor,
RelayCommandFrame,
RelayEventFrame,
RelayFrame,
RelayTransport,
} from "./protocol";
export interface RelayHostOptions {
adapter: AgentAdapter;
relay: RelayTransport;
sessionId?: string;
actor?: RelayActor;
/** Capabilities enforced locally even when relay authorization is bypassed. */
capabilities?: Iterable<string>;
logger?: Logger;
/** Emit a handshake on transports that do not implement one themselves. */
sendHandshake?: boolean;
}
/**
* Maps relay commands to the app-server AgentAdapter and publishes normalized
* adapter events. This is the policy boundary for the VS Code host.
*/
export class RelayHost {
private readonly adapter: AgentAdapter;
private readonly relay: RelayTransport;
private readonly options: RelayHostOptions;
private readonly subscriptions: Disposable[] = [];
private readonly commandResults = new Map<string, RelayEventFrame>();
private readonly inFlightCommands = new Set<string>();
private readonly capabilities: Set<string>;
private eventSeq = 0;
private sessionId: string;
private started = false;
private adapterReady = false;
constructor(options: RelayHostOptions);
constructor(adapter: AgentAdapter, relay: RelayTransport, options?: Omit<RelayHostOptions, "adapter" | "relay">);
constructor(
optionsOrAdapter: RelayHostOptions | AgentAdapter,
relayArg?: RelayTransport,
legacyOptions: Omit<RelayHostOptions, "adapter" | "relay"> = {},
) {
if (isAgentAdapter(optionsOrAdapter)) {
this.adapter = optionsOrAdapter;
if (!relayArg) throw new Error("RelayHost requires a relay transport");
this.relay = relayArg;
this.options = { ...legacyOptions, adapter: this.adapter, relay: this.relay };
} else {
this.options = optionsOrAdapter;
this.adapter = optionsOrAdapter.adapter;
this.relay = optionsOrAdapter.relay;
}
this.capabilities = new Set(this.options.capabilities ?? [
"read_output",
"send_task_input",
"cancel_task",
"approve_low_risk",
]);
this.sessionId = this.options.sessionId ?? `sess_${randomUUID()}`;
}
get id(): string {
return this.sessionId;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
this.subscriptions.push(this.adapter.onEvent((event) => {
if (event.type === "connection.opened") this.adapterReady = true;
if (event.type === "connection.closed") this.adapterReady = false;
this.publishAgentEvent(event);
}));
this.subscriptions.push(this.relay.onMessage((frame) => {
void this.handleFrame(frame).catch((error) => {
this.options.logger?.warn?.("Invalid relay frame", error);
if (isRecord(frame) && typeof frame.commandId === "string") {
this.sendCommandResult(frame.commandId, false, undefined, error instanceof Error ? error.message : String(error), typeof frame.method === "string" ? frame.method : typeof frame.type === "string" ? frame.type : undefined);
}
});
}));
if (this.relay.onClose) this.subscriptions.push(this.relay.onClose((error) => {
// A relay disconnect must not leave an app-server request waiting for a
// browser that can no longer answer. The adapter's local deny path is
// deliberately fail-closed. Do not publish `connection.closed` here:
// that event describes the app-server process, while this callback only
// describes the outbound transport and is followed by connection.opened
// on a successful reconnect.
void this.adapter.denyPending?.("relay disconnected");
this.options.logger?.debug?.("Relay transport closed", error?.message ?? "");
}));
if (this.relay.onOpen) this.subscriptions.push(this.relay.onOpen(() => {
// RelayClient fires onOpen only after auth.ok. On reconnect the adapter
// is already initialized, so the synthetic event restores relay state;
// during initial startup the adapter event below is authoritative.
if (this.adapterReady) {
this.publishConnectionEvent("connection.opened");
void this.publishSnapshot();
}
}));
const configurableRelay = this.relay as RelayTransport & { setSessionId?: (sessionId: string) => void };
configurableRelay.setSessionId?.(this.sessionId);
try {
await this.relay.connect();
if (this.options.sendHandshake !== false && !transportHandlesHandshake(this.relay)) {
this.safeSend({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: this.sessionId });
}
// Start app-server only after the relay handshake is queued/sent. This
// keeps standalone stdout frames protocol-ordered and prevents an early
// notification from racing the host hello.
await this.adapter.start();
if (!this.adapterReady) {
this.adapterReady = true;
this.publishConnectionEvent("connection.opened");
}
await this.publishSnapshot();
} catch (error) {
this.started = false;
this.adapterReady = false;
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose().catch(() => undefined);
throw error;
}
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
this.adapterReady = false;
this.inFlightCommands.clear();
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose();
}
/** Public for unit tests and local stdin bridges. */
async handleFrame(frame: RelayFrame): Promise<void> {
if (!isRecord(frame)) return;
if (frame.kind === "command" || isCommandLike(frame)) {
await this.handleCommand(frame as unknown as RelayCommandFrame);
return;
}
if (frame.kind === "event" && typeof frame.seq === "number") {
this.safeSend({ v: 1, kind: "ack", sessionId: frame.sessionId, seq: frame.seq });
}
}
private async handleCommand(frame: RelayCommandFrame): Promise<void> {
const command = normalizeCommand(frame);
const commandId = command.commandId;
if (commandId) {
const previous = this.commandResults.get(commandId);
if (previous) {
this.safeSend(previous);
return;
}
if (this.inFlightCommands.has(commandId)) {
this.safeSend({
v: 1,
kind: "event",
// Do not call this `command.accepted`: the relay treats that event
// as the terminal result for its pending command. A retry while the
// original operation is running is only an informational event.
type: "command.pending",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: { commandId, duplicate: true, pending: true },
});
return;
}
this.inFlightCommands.add(commandId);
}
const role = frame.actor?.role ?? "operator";
const denied = authorize(command.type, role, this.capabilities);
if (denied) {
// A viewer may not force a pending approval to deny (that would turn a
// read-only role into a denial-of-service primitive). Authorized roles
// can still be rejected by local capability/policy checks, in which
// case denying the app-server request is the safe terminal action.
if (role === "owner" || role === "operator" || role === "approver" || role === "host") {
await this.denyApprovalIfNeeded(command.type, command.payload, denied);
}
this.sendCommandResult(commandId, false, undefined, denied, command.type);
if (commandId) this.inFlightCommands.delete(commandId);
return;
}
try {
const result = await this.executeCommand(command.type, command.payload);
this.sendCommandResult(commandId, true, result, undefined, command.type);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.logger?.warn?.(`Relay command ${command.type} failed`, error);
await this.denyApprovalIfNeeded(command.type, command.payload, message);
this.sendCommandResult(commandId, false, undefined, message, command.type);
} finally {
if (commandId) this.inFlightCommands.delete(commandId);
}
}
private async denyApprovalIfNeeded(type: string, payload: JsonObject, reason: string): Promise<void> {
const command = canonicalCommandType(type);
if (command !== "approval.respond" && command !== "input.respond" && command !== "server.request.respond") return;
const requestId = payload.requestId;
if (requestId === undefined || (typeof requestId !== "string" && typeof requestId !== "number")) return;
try {
await this.adapter.respondApproval(requestId, "deny", reason);
} catch {
// The request may already have expired or been resolved. Keep the
// original command rejection as the observable result.
}
}
private async executeCommand(type: string, payload: JsonObject): Promise<unknown> {
switch (canonicalCommandType(type)) {
case "control.mode.get": {
const mode = this.adapter.getControlMode?.();
if (mode) return { mode };
const snapshot = await this.adapter.snapshot();
const controlMode = snapshot.metadata?.controlMode;
if (controlMode !== "sync" && controlMode !== "async") {
throw new Error("adapter does not expose a control mode");
}
return { mode: controlMode };
}
case "control.mode.set":
if (!this.adapter.setControlMode) throw new Error("adapter does not support control mode switching");
return this.adapter.setControlMode(payload);
case "thread.start":
if (!this.adapter.startThread) throw new Error("adapter does not support thread/start");
return this.adapter.startThread(payload);
case "session.new":
if (!this.adapter.newSession) throw new Error("adapter does not support session/new");
return this.adapter.newSession(payload);
case "thread.settings.update":
if (!this.adapter.updateThreadSettings) throw new Error("adapter does not support thread/settings/update");
return this.adapter.updateThreadSettings(payload);
case "session.list":
if (!this.adapter.listSessions) throw new Error("adapter does not support session/list");
return this.adapter.listSessions(payload);
case "session.select":
if (!this.adapter.selectSession) throw new Error("adapter does not support session/select");
return this.adapter.selectSession(payload);
case "turn.start":
if (this.adapter.startTurn) return this.adapter.startTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.steer":
if (this.adapter.steerTurn) return this.adapter.steerTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.interrupt":
if (this.adapter.interruptTurn) return this.adapter.interruptTurn(payload);
return this.adapter.cancel(typeof payload.turnId === "string" ? payload.turnId : undefined, payload);
case "task.input": {
const text = typeof payload.text === "string" ? payload.text : typeof payload.message === "string" ? payload.message : undefined;
if (!text) throw new Error("task.input requires payload.text");
return this.adapter.sendInput(text, payload);
}
case "task.cancel": {
const taskId = typeof payload.taskId === "string" ? payload.taskId : typeof payload.turnId === "string" ? payload.turnId : undefined;
return this.adapter.cancel(taskId, payload);
}
case "approval.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error("approval.respond requires requestId");
const requestedValue = payload.decision;
const decision = approvalDecisionKind(requestedValue);
if (!decision) throw new Error("decision must be a recognized allow, deny, or cancel value");
const snapshot = await this.adapter.snapshot();
// JSON-RPC distinguishes numeric and string ids. Keep the lookup
// type-safe so id `1` cannot accidentally authorize response `"1"`.
const approval = snapshot.pendingApprovals.find((item) => item.requestId === requestId);
const method = approval?.method ?? (typeof payload.method === "string" ? payload.method : undefined);
const response = payload.response ?? implicitApprovalResponse(requestedValue, decision, method, payload.scope);
if (decision === "allow") {
if (approval && typeof payload.commandHash === "string" && payload.commandHash !== approval.commandHash) {
throw new Error("approval commandHash does not match the pending request");
}
if (approval?.risk === "high" && !this.capabilities.has("approve_high_risk") && !this.capabilities.has("*")) {
throw new Error("host policy requires approve_high_risk for this approval");
}
}
validateApprovalResponse(decision, response, method);
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
response,
);
}
case "input.respond":
case "server.request.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error(`${type} requires requestId`);
const response = payload.response ?? (payload.answers !== undefined ? payload.answers : undefined);
// Tool-input requests do not carry an allow/deny field in their wire
// response, while MCP elicitation uses `action`. Prefer an explicit
// response action when present; otherwise honor the relay decision and
// fail closed when a denial has no custom response.
if (response !== undefined && !isRecord(response)) {
throw new Error("input response must be a JSON object");
}
const responseDecision = isRecord(response)
? explicitResponseDecision(response)
: undefined;
const requestedDecision = payload.decision === undefined
? undefined
: approvalDecisionKind(payload.decision);
if (payload.decision !== undefined && !requestedDecision) {
throw new Error("decision must be allow, deny, or cancel");
}
if (responseDecision && requestedDecision
&& responseDecision !== requestedDecision
// RelayHost uses `decision: "allow"` as a generic envelope for
// MCP/input responses; the nested action remains authoritative in
// that one compatibility case.
&& requestedDecision !== "allow") {
throw new Error(`input response implies ${responseDecision}, but decision is ${requestedDecision}`);
}
// For MCP, `response.action` is the actual app-server decision and is
// authoritative even if a relay uses `decision: "allow"` as a generic
// input-response envelope. With no custom response, an explicit relay
// decision (or the fail-closed deny default) controls the result.
const decision = responseDecision ?? requestedDecision ?? (response === undefined ? "deny" : "allow");
const responseForAdapter = requestedDecision && requestedDecision !== "allow" && !responseDecision
? undefined
: response;
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
responseForAdapter,
);
}
case "session.snapshot":
case "snapshot":
return this.adapter.snapshot();
case "ping":
return { pong: true, ts: new Date().toISOString() };
default:
throw new Error(`unsupported relay command: ${type}`);
}
}
private sendCommandResult(commandId: string | undefined, ok: boolean, result?: unknown, error?: string, method?: string): void {
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: ok ? "command.accepted" : "command.rejected",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: {
...(commandId ? { commandId } : {}),
...(method ? { method } : {}),
ok,
...(ok ? { result: asJsonValue(result) } : { error: error ?? "command rejected" }),
},
};
if (commandId) {
this.commandResults.set(commandId, frame);
if (this.commandResults.size > 1000) this.commandResults.delete(this.commandResults.keys().next().value as string);
}
this.safeSend(frame);
}
private publishAgentEvent(event: AgentEvent): void {
if (event.threadId && this.sessionId.startsWith("sess_")) {
// Keep a stable relay session id while exposing the app-server thread id
// in the payload; a relay session may contain more than one thread.
}
const payload: JsonObject = {
...event.payload,
...(event.status ? { executionStatus: asJsonValue(event.status) } : {}),
...(event.threadId ? { threadId: event.threadId } : {}),
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.requestId !== undefined ? { requestId: asJsonValue(event.requestId) } : {}),
...(event.raw !== undefined ? { raw: event.raw } : {}),
};
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: event.type,
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload,
...(event.status ? { status: { ...event.status, activeFlags: [...event.status.activeFlags] } } : {}),
};
try {
this.safeSend(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to publish relay event", error);
}
}
private safeSend(frame: RelayFrame): void {
try {
this.relay.send(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to send relay frame", error);
}
}
private publishConnectionEvent(type: string, error?: Error): void {
if (!this.started && type === "connection.closed") return;
this.publishAgentEvent({ type, payload: error ? { message: error.message } : {} });
}
private async publishSnapshot(): Promise<void> {
try {
const snapshot = await this.adapter.snapshot();
this.publishAgentEvent({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
} catch (error) {
this.options.logger?.warn?.("Unable to publish adapter snapshot", error);
}
}
}
interface NormalizedCommand {
type: string;
commandId?: string;
payload: JsonObject;
}
function normalizeCommand(frame: RelayCommandFrame): NormalizedCommand {
const nested = isRecord(frame.command) ? frame.command : undefined;
const type = typeof nested?.type === "string"
? nested.type
: typeof frame.method === "string"
? frame.method
: frame.type === "command"
? ""
: frame.type;
const commandId = typeof frame.commandId === "string"
? frame.commandId
: typeof nested?.commandId === "string"
? nested.commandId
: typeof frame.id === "string"
? frame.id
: undefined;
if (!type) throw new Error("relay command has no type");
if (isRecord(nested?.payload)) return { type, commandId, payload: asJsonObject(nested.payload) };
if (isRecord(frame.payload)) return { type, commandId, payload: asJsonObject(frame.payload) };
if (isRecord(frame.params)) return { type, commandId, payload: asJsonObject(frame.params) };
const payload: JsonObject = {};
for (const [key, value] of Object.entries(frame)) {
if (["v", "kind", "type", "method", "params", "commandId", "id", "sessionId", "actor", "command"].includes(key)) continue;
if (value !== undefined) payload[key] = asJsonValue(value);
}
return { type, commandId, payload };
}
function canonicalCommandType(type: string): string {
const normalized = type.trim().replace(/\//g, ".").replace(/\s+/g, ".").toLowerCase();
if (normalized === "control.mode.get" || normalized === "controlmode.get" || normalized === "controlmodeget" || normalized === "mode.get" || normalized === "modeget") return "control.mode.get";
if (normalized === "control.mode.set" || normalized === "controlmode.set" || normalized === "controlmodeset" || normalized === "mode.set" || normalized === "modeset") return "control.mode.set";
if (normalized === "thread.start" || normalized === "threadstart") return "thread.start";
if (normalized === "session.new" || normalized === "sessionnew" || normalized === "thread.new" || normalized === "threadnew") return "session.new";
if (normalized === "thread.settings.update" || normalized === "threadsettings.update" || normalized === "threadsettingsupdate") return "thread.settings.update";
if (normalized === "session.list" || normalized === "thread.list" || normalized === "sessionlist" || normalized === "threadlist") return "session.list";
if (normalized === "session.select" || normalized === "session.switch" || normalized === "thread.select" || normalized === "thread.attach" || normalized === "sessionswitch" || normalized === "threadselect") return "session.select";
if (normalized === "turn.start" || normalized === "turnstart") return "turn.start";
if (normalized === "turn.steer" || normalized === "turnsteer") return "turn.steer";
if (normalized === "turn.interrupt" || normalized === "turninterrupt") return "turn.interrupt";
if (normalized === "approval.respond" || normalized === "approvalrespond") return "approval.respond";
if (normalized === "task.input" || normalized === "taskinput") return "task.input";
if (normalized === "task.cancel" || normalized === "taskcancel") return "task.cancel";
if (normalized === "input.respond" || normalized === "inputrespond") return "input.respond";
if (normalized === "server.request.respond" || normalized === "serverrequest.respond") return "server.request.respond";
if (normalized === "session.snapshot" || normalized === "snapshot") return "session.snapshot";
return normalized;
}
function authorize(type: string, role: string, capabilities: Set<string>): string | undefined {
const command = canonicalCommandType(type);
const readOnly = command === "session.snapshot" || command === "snapshot" || command === "session.list" || command === "control.mode.get" || command === "ping";
if (readOnly) return undefined;
if (role === "viewer") return "viewer role cannot issue control commands";
if (role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") return `role ${role} is not authorized`;
if ((command === "approval.respond" || command === "input.respond" || command === "server.request.respond") && role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") {
return "role is not authorized to resolve approvals";
}
const required = command === "approval.respond" ? "approve_low_risk" : command === "task.cancel" || command === "turn.interrupt" ? "cancel_task" : command === "task.input" || command.startsWith("turn.") || command === "thread.start" || command === "thread.settings.update" || command === "session.select" || command === "session.new" || command === "control.mode.set" ? "send_task_input" : undefined;
if (required && !capabilities.has(required) && !capabilities.has("*") && role !== "owner" && role !== "host") return `missing capability: ${required}`;
return undefined;
}
function isCommandLike(frame: Record<string, unknown>): boolean {
if (frame.kind === "command") return true;
if (frame.type === "command" && typeof frame.method === "string") return true;
if (frame.kind !== undefined) return false;
if (typeof frame.method === "string") return true;
if (typeof frame.commandId !== "string") return false;
return KNOWN_COMMAND_TYPES.has(String(frame.type).trim().replace(/\//g, ".").toLowerCase());
}
const KNOWN_COMMAND_TYPES = new Set([
"control.mode.get",
"control.mode.set",
"thread.start",
"session.new",
"thread.settings.update",
"session.list",
"session.select",
"turn.start",
"turn.steer",
"turn.interrupt",
"approval.respond",
"task.input",
"task.cancel",
"input.respond",
"server.request.respond",
"session.snapshot",
"snapshot",
"ping",
]);
function isAgentAdapter(value: unknown): value is AgentAdapter {
return isRecord(value) && typeof value.start === "function" && typeof value.onEvent === "function" && typeof value.sendInput === "function" && typeof value.cancel === "function" && typeof value.respondApproval === "function" && typeof value.snapshot === "function";
}
function extractCommandText(payload: JsonObject): string {
if (typeof payload.text === "string") return payload.text;
if (typeof payload.message === "string") return payload.message;
if (typeof payload.prompt === "string") return payload.prompt;
if (Array.isArray(payload.input)) {
const first = payload.input[0];
if (isRecord(first) && typeof first.text === "string") return first.text;
}
throw new Error("turn command requires text or input");
}
function validateApprovalResponse(
decision: "allow" | "deny" | "cancel",
response: JsonValue | undefined,
method?: string,
): void {
if (response === undefined) return;
if (!isRecord(response)) throw new Error("approval response must be a JSON object");
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
if (hasDecision || hasAction) {
const decisionKind = hasDecision ? approvalDecisionKindForMethod(response.decision, method) : undefined;
const actionKind = hasAction ? approvalDecisionKindForMethod(response.action, method) : undefined;
if (hasDecision && !decisionKind) throw new Error("unsupported approval response decision");
if (hasAction && !actionKind) throw new Error("unsupported approval response action");
if (decisionKind && actionKind && decisionKind !== actionKind) {
throw new Error("approval response decision and action conflict");
}
const implied = decisionKind ?? actionKind;
if (implied && implied !== decision) {
throw new Error(`approval response implies ${implied}, but decision is ${decision}`);
}
return;
}
// Permissions approvals intentionally carry a profile rather than a
// decision field. Keep the profile shape narrow; malformed/unknown objects
// must not be interpreted as an approval.
if (method === "item/permissions/requestApproval"
&& isRecord(response.permissions)
&& (response.scope === "turn" || response.scope === "session")
&& (response.strictAutoReview === undefined || typeof response.strictAutoReview === "boolean")
&& Object.keys(response).every((key) => key === "permissions" || key === "scope" || key === "strictAutoReview")) {
return;
}
throw new Error("approval response has no recognized decision or permission profile");
}
/**
* Convert a relay's compact outer decision into a wire response only when it
* carries a non-canonical app-server value. Canonical `allow`/`deny`/`cancel`
* remain undefined so the adapter can choose the method-specific default.
*/
function implicitApprovalResponse(
requestedValue: JsonValue,
decision: "allow" | "deny" | "cancel",
method?: string,
scope?: JsonValue,
): JsonValue | undefined {
// Permission approvals have a profile response, not a decision wrapper.
// Let the adapter construct the requested turn-scoped profile by default;
// callers that need session scope must provide the full profile explicitly.
if (method === "item/permissions/requestApproval") return undefined;
if (requestedValue === "allow" || requestedValue === "deny" || requestedValue === "cancel") {
if (requestedValue === "allow" && scope === "session") {
if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved_for_session" };
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "acceptForSession" };
}
return undefined;
}
// The generic classifier has already rejected unknown/conflicting values.
// Preserve recognized legacy/v2 tags exactly under the app-server wrapper.
if (decision === "allow" || decision === "deny" || decision === "cancel") {
return { decision: requestedValue };
}
return undefined;
}
function explicitResponseDecision(response: Record<string, unknown>): "allow" | "deny" | "cancel" | undefined {
if (!hasApprovalDecisionField(response)) return undefined;
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
const decision = hasDecision ? approvalDecisionKind(response.decision) : undefined;
const action = hasAction ? approvalDecisionKind(response.action) : undefined;
if (hasDecision && !decision) throw new Error("unsupported input response decision");
if (hasAction && !action) throw new Error("unsupported input response action");
if (decision && action && decision !== action) throw new Error("input response decision and action conflict");
return decision ?? action;
}
function transportHandlesHandshake(transport: RelayTransport): boolean {
return Boolean((transport as RelayTransport & { handlesHandshake?: boolean }).handlesHandshake);
}
@@ -0,0 +1,425 @@
import {
AgentAdapter,
AgentEvent,
asJsonObject,
ControlMode,
Disposable,
JsonObject,
JsonRpcId,
JsonValue,
Logger,
SessionSnapshot,
} from "./protocol";
export type AgentAdapterFactory = (mode: ControlMode) => AgentAdapter | Promise<AgentAdapter>;
export interface SwitchableAgentAdapterOptions {
initialMode: ControlMode;
createAdapter: AgentAdapterFactory;
/** Persist the committed mode. Persistence errors do not roll back a live adapter. */
onModeChanged?: (mode: ControlMode, previousMode: ControlMode) => void | Promise<void>;
logger?: Logger;
}
interface AdapterBinding {
adapter: AgentAdapter;
mode: ControlMode;
generation: number;
committed: boolean;
bufferedEvents: AgentEvent[];
subscription: Disposable;
}
interface ModeCapabilities extends JsonObject {
followsVscodeRoute: boolean;
sessionList: boolean;
sessionSelect: boolean;
sessionCreate: boolean;
threadSettings: boolean;
}
/**
* Keeps RelayHost bound to one stable AgentAdapter while atomically replacing
* the implementation behind it when the control owner changes.
*/
export class SwitchableAgentAdapter implements AgentAdapter {
private readonly options: SwitchableAgentAdapterOptions;
private readonly listeners = new Set<(event: AgentEvent) => void>();
private binding: AdapterBinding | null = null;
private controlMode: ControlMode;
private modeEpoch = 0;
private startPromise: Promise<void> | null = null;
private switchPromise: Promise<JsonValue> | null = null;
private started = false;
private disposed = false;
constructor(options: SwitchableAgentAdapterOptions) {
this.options = options;
this.controlMode = validateControlMode(options.initialMode);
}
async start(): Promise<void> {
if (this.started) return;
if (this.disposed) throw new Error("switchable adapter has been disposed");
if (this.startPromise) return this.startPromise;
const operation = this.startInitialAdapter();
this.startPromise = operation;
try {
await operation;
} finally {
if (this.startPromise === operation) this.startPromise = null;
}
}
getControlMode(): ControlMode {
return this.controlMode;
}
async setControlMode(params: JsonObject): Promise<JsonValue> {
const nextMode = controlModeFromParams(params);
this.ensureStarted();
if (this.switchPromise) throw new Error("a control mode switch is already in progress");
if (nextMode === this.controlMode) {
return {
changed: false,
controlMode: this.controlMode,
previousControlMode: this.controlMode,
modeEpoch: this.modeEpoch,
};
}
const operation = this.performModeSwitch(nextMode);
this.switchPromise = operation;
try {
return await operation;
} finally {
if (this.switchPromise === operation) this.switchPromise = null;
}
}
async startThread(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("thread/start");
const adapter = this.activeAdapterForMutation();
if (!adapter.startThread) throw unsupported("thread/start", this.controlMode);
return adapter.startThread(params);
}
async newSession(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("session/new");
const adapter = this.activeAdapterForMutation();
if (adapter.newSession) return adapter.newSession(params);
if (adapter.startThread) return adapter.startThread(params);
throw unsupported("session/new", this.controlMode);
}
async startTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.startTurn) throw unsupported("turn/start", this.controlMode);
return adapter.startTurn(params);
}
async steerTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.steerTurn) throw unsupported("turn/steer", this.controlMode);
return adapter.steerTurn(params);
}
async updateThreadSettings(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.updateThreadSettings) throw unsupported("thread/settings/update", this.controlMode);
return adapter.updateThreadSettings(params);
}
async listSessions(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("session/list");
const adapter = this.activeAdapter();
if (!adapter.listSessions) throw unsupported("session/list", this.controlMode);
return adapter.listSessions(params);
}
async selectSession(params: JsonObject): Promise<JsonValue> {
this.assertIndependentNavigation("session/select");
const adapter = this.activeAdapterForMutation();
if (!adapter.selectSession) throw unsupported("session/select", this.controlMode);
return adapter.selectSession(params);
}
async interruptTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapter();
if (!adapter.interruptTurn) throw unsupported("turn/interrupt", this.controlMode);
return adapter.interruptTurn(params);
}
async sendInput(text: string, params: JsonObject = {}): Promise<JsonValue> {
return this.activeAdapterForMutation().sendInput(text, params);
}
async cancel(taskId?: string, params: JsonObject = {}): Promise<JsonValue> {
return this.activeAdapter().cancel(taskId, params);
}
async respondApproval(
requestId: JsonRpcId,
decision: "allow" | "deny" | "cancel",
reason?: string,
response?: JsonValue,
): Promise<JsonValue> {
return this.activeAdapter().respondApproval(requestId, decision, reason, response);
}
async denyPending(reason?: string): Promise<void> {
await this.activeAdapter().denyPending?.(reason);
}
async snapshot(): Promise<SessionSnapshot> {
const binding = this.activeBinding();
const snapshot = await binding.adapter.snapshot();
return this.decorateSnapshot(snapshot, binding);
}
onEvent(listener: (event: AgentEvent) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
const starting = this.startPromise;
const switching = this.switchPromise;
await starting?.catch(() => undefined);
await switching?.catch(() => undefined);
const binding = this.binding;
this.binding = null;
this.started = false;
if (!binding) return;
binding.committed = false;
binding.subscription.dispose();
await binding.adapter.dispose();
}
private async startInitialAdapter(): Promise<void> {
const binding = await this.createBinding(this.controlMode, this.modeEpoch);
try {
await binding.adapter.start();
if (this.disposed) throw new Error("switchable adapter was disposed while starting");
binding.committed = true;
this.binding = binding;
this.started = true;
this.flushBufferedEvents(binding);
} catch (error) {
await this.releaseBinding(binding);
throw error;
}
}
private async performModeSwitch(nextMode: ControlMode): Promise<JsonValue> {
const previousBinding = this.activeBinding();
const previousMode = this.controlMode;
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
const nextEpoch = this.modeEpoch + 1;
const candidate = await this.createBinding(nextMode, nextEpoch);
if (candidate.adapter === previousBinding.adapter) {
candidate.subscription.dispose();
throw new Error("adapter factory must return a distinct adapter when switching control modes");
}
try {
await candidate.adapter.start();
if (this.disposed) throw new Error("switchable adapter was disposed while switching modes");
// VS Code can start a turn independently while the candidate boots.
// Recheck immediately before the synchronous commit point.
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
const candidateSnapshot = await candidate.adapter.snapshot();
// The candidate snapshot is an await point, so make the old adapter's
// liveness check the final operation before committing synchronously.
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
candidate.committed = true;
this.binding = candidate;
this.controlMode = nextMode;
this.modeEpoch = nextEpoch;
previousBinding.committed = false;
const result: JsonObject = {
changed: true,
controlMode: nextMode,
previousControlMode: previousMode,
modeEpoch: nextEpoch,
};
this.emit({ type: "control.mode.changed", payload: result });
this.flushBufferedEvents(candidate);
const snapshot = this.decorateSnapshot(candidateSnapshot, candidate);
this.emit({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
previousBinding.subscription.dispose();
await previousBinding.adapter.dispose().catch((error) => {
this.options.logger?.warn?.("Unable to dispose the previous control mode adapter", error);
});
await Promise.resolve(this.options.onModeChanged?.(nextMode, previousMode)).catch((error) => {
this.options.logger?.warn?.("Unable to persist the committed control mode", error);
});
return result;
} catch (error) {
if (this.binding !== candidate) await this.releaseBinding(candidate);
throw error;
}
}
private async createBinding(mode: ControlMode, generation: number): Promise<AdapterBinding> {
const adapter = await this.options.createAdapter(mode);
if (!adapter) throw new Error(`adapter factory returned no adapter for ${mode} mode`);
const binding: AdapterBinding = {
adapter,
mode,
generation,
committed: false,
bufferedEvents: [],
subscription: { dispose: () => undefined },
};
binding.subscription = adapter.onEvent((event) => this.receiveAdapterEvent(binding, event));
return binding;
}
private receiveAdapterEvent(binding: AdapterBinding, event: AgentEvent): void {
if (!binding.committed) {
binding.bufferedEvents.push(event);
return;
}
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
this.emit(this.decorateEvent(event, binding));
}
private flushBufferedEvents(binding: AdapterBinding): void {
const events = binding.bufferedEvents.splice(0);
for (const event of events) {
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
this.emit(this.decorateEvent(event, binding));
}
}
private emit(event: AgentEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch (error) {
this.options.logger?.warn?.("Switchable adapter event listener failed", error);
}
}
}
private activeBinding(): AdapterBinding {
this.ensureStarted();
if (!this.binding) throw new Error("switchable adapter has no active adapter");
return this.binding;
}
private activeAdapter(): AgentAdapter {
return this.activeBinding().adapter;
}
private activeAdapterForMutation(): AgentAdapter {
if (this.switchPromise) throw new Error("control mode is switching; retry after it completes");
return this.activeAdapter();
}
private ensureStarted(): void {
if (this.disposed) throw new Error("switchable adapter has been disposed");
if (!this.started || !this.binding) throw new Error("switchable adapter is not started");
}
private assertIndependentNavigation(operation: string): void {
if (this.controlMode === "sync") {
throw new Error(`${operation} is unavailable in sync mode; conversation navigation follows VS Code`);
}
}
private assertSnapshotIdle(snapshot: SessionSnapshot): void {
const pendingApprovalCount = snapshot.pendingApprovals.length;
const pendingRequestCount = snapshot.pendingRequests?.length ?? 0;
const state = normalizeStatus(snapshot.state);
const turnStatus = normalizeStatus(snapshot.status?.turnStatus ?? snapshot.turnStatus ?? "");
const activeFlags = snapshot.status?.activeFlags ?? snapshot.activeFlags ?? [];
const hasActiveState = ACTIVE_STATUSES.has(state) || ACTIVE_STATUSES.has(turnStatus) || activeFlags.length > 0;
if (snapshot.turnId || hasActiveState || pendingApprovalCount > 0 || pendingRequestCount > 0) {
throw new Error("cannot switch control mode while a turn or request is active");
}
}
private decorateSnapshot(snapshot: SessionSnapshot, binding: AdapterBinding): SessionSnapshot {
return {
...snapshot,
metadata: {
...(snapshot.metadata ?? {}),
mode: binding.mode,
controlMode: binding.mode,
modeEpoch: binding.generation,
capabilities: this.capabilities(binding),
},
};
}
private decorateEvent(event: AgentEvent, binding: AdapterBinding): AgentEvent {
if (event.type !== "session.snapshot") return event;
return {
...event,
payload: {
...event.payload,
metadata: {
...asJsonObject(event.payload.metadata),
mode: binding.mode,
controlMode: binding.mode,
modeEpoch: binding.generation,
capabilities: this.capabilities(binding),
},
},
};
}
private capabilities(binding: AdapterBinding): ModeCapabilities {
const independent = binding.mode === "async";
return {
followsVscodeRoute: !independent,
sessionList: independent && typeof binding.adapter.listSessions === "function",
sessionSelect: independent && typeof binding.adapter.selectSession === "function",
sessionCreate: independent && (typeof binding.adapter.newSession === "function"
|| typeof binding.adapter.startThread === "function"),
threadSettings: typeof binding.adapter.updateThreadSettings === "function",
};
}
private async releaseBinding(binding: AdapterBinding): Promise<void> {
binding.committed = false;
binding.subscription.dispose();
await binding.adapter.dispose().catch(() => undefined);
}
}
function controlModeFromParams(params: JsonObject): ControlMode {
return validateControlMode(params.mode ?? params.controlMode);
}
function validateControlMode(value: unknown): ControlMode {
if (value === "sync" || value === "async") return value;
throw new Error("control mode must be sync or async");
}
function unsupported(operation: string, mode: ControlMode): Error {
return new Error(`${operation} is not supported by the ${mode} adapter`);
}
const ACTIVE_STATUSES = new Set(["active", "inprogress", "running", "starting", "thinking", "editing", "working"]);
function normalizeStatus(value: string): string {
return value.trim().replace(/[\s_-]+/g, "").toLowerCase();
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": [
"ES2022"
],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"sourceMap": true,
"skipLibCheck": true,
"types": [
"node",
"vscode"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}