diff --git a/.dockerignore b/.dockerignore index 93a95eeb5..28cd2806a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,10 @@ target/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +aether-vscodex/web/node_modules/ +aether-vscodex/web/dist/ +aether-vscodex/vscode-extension/node_modules/ +aether-vscodex/vscode-extension/dist/ # Development .git/ diff --git a/.env.example b/.env.example index a529dd673..4a9a0e5db 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,12 @@ ADMIN_USERNAME=admin123456 # Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。 # AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12 +# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要) +# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32 +# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret +# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws +# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com + # docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true) # AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index b57dfeb1b..d9a615cf4 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -53,7 +53,15 @@ jobs: with: node-version: '22' cache: 'npm' - cache-dependency-path: frontend/package-lock.json + cache-dependency-path: | + frontend/package-lock.json + aether-vscodex/web/package-lock.json + + - name: Build aether-vscodex web + working-directory: aether-vscodex/web + run: | + npm ci + npm run build - name: Install dependencies working-directory: frontend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33256e04d..8114ff833 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,15 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: frontend/package-lock.json + cache-dependency-path: | + frontend/package-lock.json + aether-vscodex/web/package-lock.json + + - name: Build aether-vscodex web + working-directory: aether-vscodex/web + run: | + npm ci + npm run build - name: Install & build working-directory: frontend @@ -93,6 +101,71 @@ jobs: if-no-files-found: error retention-days: 1 + vscodex: + name: Build VS Code Codex extension + needs: preflight + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + aether-vscodex/package-lock.json + aether-vscodex/web/package-lock.json + aether-vscodex/vscode-extension/package-lock.json + + - name: Install module test dependencies + working-directory: aether-vscodex + run: npm ci + + - name: Build the embedded Web UI + working-directory: aether-vscodex/web + run: | + npm ci + npm run build + + - name: Install extension dependencies + working-directory: aether-vscodex/vscode-extension + run: npm ci + + - name: Check and compile the extension + working-directory: aether-vscodex/vscode-extension + run: | + npm run check + npm run build + + - name: Run module tests + working-directory: aether-vscodex + run: npm test + + - name: Run Web UI tests + working-directory: aether-vscodex/web + run: npm test + + - name: Package VSIX + working-directory: aether-vscodex/vscode-extension + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository + source_vsix="codex-remote-collab-${version}.vsix" + test -f "${source_vsix}" + mv "${source_vsix}" "aether-vscodex-${version}.vsix" + unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v5 + with: + name: aether-vscodex-vsix + path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix + if-no-files-found: error + retention-days: 7 + build: name: Build ${{ matrix.name }} needs: preflight @@ -299,7 +372,7 @@ jobs: github-release: name: GitHub Release assets - needs: [preflight, docker, package] + needs: [preflight, docker, package, vscodex] if: needs.preflight.outputs.publish == 'true' runs-on: ubuntu-latest steps: @@ -309,6 +382,12 @@ jobs: name: release-assets path: release-assets + - name: Download VSIX artifact + uses: actions/download-artifact@v5 + with: + name: aether-vscodex-vsix + path: release-assets + - name: Delete stale draft releases for tag env: GH_TOKEN: ${{ github.token }} @@ -340,3 +419,4 @@ jobs: release-assets/*.tar.gz release-assets/SHA256SUMS release-assets/install.sh + release-assets/*.vsix diff --git a/.gitignore b/.gitignore index fab43389a..35ed543b2 100644 --- a/.gitignore +++ b/.gitignore @@ -248,3 +248,5 @@ src/_version.py analysis/ new-api/ apps/aether-tunnel/aether-tunnel.toml +# Generated by frontend/scripts/sync-vscodex.mjs. +frontend/public/aether-vscodex/ diff --git a/Cargo.lock b/Cargo.lock index 32ff749e6..0a1ac6888 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,7 @@ dependencies = [ "tikv-jemalloc-sys", "tikv-jemallocator", "tokio", + "tokio-tungstenite 0.28.0", "tokio-util", "tower", "tower-http", diff --git a/Dockerfile.app.local b/Dockerfile.app.local index 6c0971e6d..3067d37e4 100644 --- a/Dockerfile.app.local +++ b/Dockerfile.app.local @@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder ARG AETHER_BUILD_VERSION ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \ AETHER_VERSION=${AETHER_BUILD_VERSION} +WORKDIR /app/aether-vscodex/web +COPY aether-vscodex/web/package*.json ./ +RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \ + npm config set registry https://registry.npmmirror.com && \ + npm ci --no-audit --no-fund +COPY aether-vscodex/public /app/aether-vscodex/public +COPY aether-vscodex/web/ ./ +RUN npm run build + WORKDIR /app/frontend COPY frontend/package*.json ./ RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \ diff --git a/Dockerfile.app.release-local b/Dockerfile.app.release-local index 8eddbd57a..169d79184 100644 --- a/Dockerfile.app.release-local +++ b/Dockerfile.app.release-local @@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder ARG AETHER_BUILD_VERSION ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \ AETHER_VERSION=${AETHER_BUILD_VERSION} +WORKDIR /app/aether-vscodex/web +COPY aether-vscodex/web/package*.json ./ +RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \ + npm config set registry https://registry.npmmirror.com && \ + npm ci --no-audit --no-fund +COPY aether-vscodex/public /app/aether-vscodex/public +COPY aether-vscodex/web/ ./ +RUN npm run build + WORKDIR /app/frontend COPY frontend/package*.json ./ RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \ diff --git a/README.md b/README.md index 73e9100ab..fe1130380 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,12 @@ make dev `make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。 Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。 +## Codex 远程协同 + +`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。 + +安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。 + ## Aether Tunnel (可选) Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。 diff --git a/aether-vscodex/.dockerignore b/aether-vscodex/.dockerignore new file mode 100644 index 000000000..9739dbad4 --- /dev/null +++ b/aether-vscodex/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +node_modules +test +fixtures +vscode-extension +*.vsix +coverage +data +.DS_Store diff --git a/aether-vscodex/.gitignore b/aether-vscodex/.gitignore new file mode 100644 index 000000000..890f9f2b7 --- /dev/null +++ b/aether-vscodex/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +vscode-extension/node_modules/ +vscode-extension/dist/ +data/ +coverage/ +*.vsix +.DS_Store +*.log diff --git a/aether-vscodex/Dockerfile b/aether-vscodex/Dockerfile new file mode 100644 index 000000000..5e8b74fc5 --- /dev/null +++ b/aether-vscodex/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-alpine + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8788 \ + AETHER_VSCODEX_DATA_DIR=/var/lib/aether-vscodex + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY cloud ./cloud +COPY relay ./relay +COPY public ./public + +RUN mkdir -p /var/lib/aether-vscodex && chown -R node:node /var/lib/aether-vscodex /app + +USER node + +EXPOSE 8788 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8788/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +CMD ["node", "cloud/server.js"] diff --git a/aether-vscodex/README.md b/aether-vscodex/README.md new file mode 100644 index 000000000..035e4309a --- /dev/null +++ b/aether-vscodex/README.md @@ -0,0 +1,283 @@ +# aether-vscodex + +这个项目让浏览器从本机 URL 或 Aether 云端查看、输入并处理 Codex 会话,提供两种 +可随时切换的控制模式。默认的**同步模式**通过官方扩展使用的本机 IPC socket,严格 +跟随 VS Code Codex 面板当前会话,不启动另一个 `codex` 进程;**异步模式**由伴随扩展 +启动独立 app-server,网页可以自行列出、恢复、新建和切换会话。 + +同一个伴随扩展可同时连接两个互不替代的通道:本机 loopback 控制台和部署在 +Aether 中的云端控制台。本机通道默认免密码且只能从本机访问;云端通道使用 +Aether 登录鉴权、一次性浏览器票据和独立设备凭据;父页面不会通过协议把 Aether JWT +传给 iframe 或 Node sidecar。iframe 是随 Aether 一起发布的同源受信代码,不应被视为 +隔离不受信内容的安全边界。 + +`vscode-extension/codex-remote-collab-0.4.0.vsix` 安装到 VS Code 后,会作为官方 +`openai.chatgpt` Codex 扩展的伴随扩展,并自动托管只监听本机的 relay。开发时仍可 +单独运行 `relay/server.js`。不要卸载或替换官方 Codex 扩展。 + +## 工作方式 + +```text +官方 VS Code Codex 会话 + │ 本机私有 IPC(只在 VS Code 所在机器上) + ▼ + ┌── 本机 relay ── http://127.0.0.1:8787 +VS Code aether-vscodex 扩展 ────┤ + └── Aether gateway ── 用户/设备隔离的云端 relay +``` + +控制模式与传输通道是两个独立维度:切换同步/异步不会重连本地或云端 relay。本机和 +Aether 控制页连接到同一台 VS Code 主机时,会看到同一个当前模式。 + +| 控制模式 | 会话所有者 | 网页会话导航 | +| --- | --- | --- | +| 同步 | 官方 VS Code Codex 面板 | 禁止网页自行切换;自动跟随 VS Code | +| 异步 | 扩展启动的独立 app-server | 可列出、恢复、新建和切换会话 | + +浏览器的 `operator` 可以发送任务、继续/中断当前 turn,并处理 Codex 的审批、 +用户输入和 MCP elicitation;`viewer` 只能查看事件和输出。远程浏览器不接触 +VS Code 的 SecretStorage,也不直接连接 IPC socket。 + +## 前端结构 + +Aether 页面使用仓库既有的 Vue 3、TypeScript、Vite 和 i18n。独立控制台也提供 +Vue/Vite 源码入口,但当前高保真的会话渲染与协议状态机作为兼容运行时保留,构建到 +`public/` 后同时供本机 URL 和 Aether 同源 iframe 使用。这样不需要一次性重写并丢失 +命令展开、滚动锚点、思考状态、Markdown、子代理、模型和权限菜单等已有行为。 + +界面支持 `zh-CN` 与 `en-US`。Aether 的语言和深浅色主题会通过经过来源校验的 +`postMessage` 同步给 iframe;VS Code 命令与设置说明使用 `package.nls` 本地化。 + +## Aether 云端部署 + +云端模式由 Aether gateway 和独立 Node sidecar 组成。sidecar 只在 Compose 内网暴露 +8788,公网的 HTTP、配对交换和 WebSocket 都经 Aether gateway: + +```text +GET /api/users/me/vscodex/devices +POST /api/users/me/vscodex/pairings +DELETE /api/users/me/vscodex/devices/:device_id +POST /api/users/me/vscodex/ws-tickets +POST /api/vscodex/pair +WS /api/vscodex/ws +``` + +生成至少 32 字节的内部令牌,并按 Aether 的公开 HTTPS 地址设置变量: + +```sh +export AETHER_VSCODEX_INTERNAL_TOKEN="$(openssl rand -base64 32)" +export AETHER_VSCODEX_PUBLIC_WS_URL="wss://aether.example.com/api/vscodex/ws" +export AETHER_VSCODEX_ALLOWED_ORIGINS="https://aether.example.com" + +docker compose \ + -f docker-compose.yml \ + -f docker-compose.local.yml \ + -f aether-vscodex/docker-compose.aether.yml \ + up -d --build +``` + +源码部署必须包含 `docker-compose.local.yml`,以保证 gateway、前端和 sidecar 来自同一份 +checkout。使用发布镜像时可以去掉该文件,但 `APP_IMAGE` 必须固定为包含相同 +`aether-vscodex` 协议版本的 Aether 镜像,不能把当前 sidecar 与旧的 `latest` gateway 混用。 + +首次使用源码 Compose 前先构建控制台;正式 Aether 发布流程与 Dockerfile 已自动执行 +同一步骤: + +```sh +npm --prefix aether-vscodex/web ci +npm --prefix aether-vscodex/web run build +``` + +第一阶段 sidecar 是有状态单副本:设备凭据的 scrypt 哈希保存在 +`vscodex_data`,短期配对码、60 秒一次性浏览器票据和在线房间保存在内存。不要在未引入 +共享连接目录前横向扩容 sidecar。 + +登录 Aether 后打开“Codex 远程控制”,生成一次性配对码。然后在 VS Code 命令面板执行 +**Codex Remote: Pair with Aether**,填写 Aether 地址和配对码。插件会把设备凭据写入 +VS Code SecretStorage,并同时保持本机控制台连接。 + +## 快速开始 + +前提:Node.js 20+;官方 `openai.chatgpt` VS Code 扩展已安装并登录;目标会话 +已经在 VS Code 的 Codex 面板中打开。VS Code 和 relay 必须以同一个操作系统用户 +运行,因为 IPC socket 是本机文件。 + +1. 安装依赖并构建伴随扩展: + + ```sh + npm --prefix vscode-extension install + npm --prefix vscode-extension run build + ``` + + 本机 `ws://` 地址会由扩展自动启动 relay;loopback 模式默认不需要 token,且 + `host` 模式不会启动 `codex app-server`。 + +2. 安装 `vscode-extension/codex-remote-collab-0.4.0.vsix`(或在扩展目录先 + `npm run build` 再用 `npx --yes @vscode/vsce package` 打包),然后在 VS Code + 执行 **Developer: Reload Window**。 + +3. 在 VS Code 设置中填写: + + ```json + { + "codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect", + "codexRemoteCollab.controlMode": "sync", + "codexRemoteCollab.autoDiscoverThread": true, + "codexRemoteCollab.autoStart": true + } + ``` + +4. 执行一次 **Developer: Reload Window** 后,扩展会自动找到最近的、仍由官方 + VS Code Codex owner 持有的会话,并把已有输出同步到 relay;如果没有自动启动, + 无需手动启动或断开。右下角状态项只用于显示状态并打开 Web。需要精确指定会话时,执行 + **Codex Remote: Set Existing Thread ID**;留空则恢复自动发现。 + 官方 Codex 面板切换会话时,Web 默认会在新会话快照就绪后自动跟随;正在执行或等待 + 授权的旧会话会先保持附着,结束后再安全切换。 + +5. 浏览器打开 `http://127.0.0.1:8787`,页面会自动以本机 operator 身份连接, + 不需要输入密码。 + +如果页面显示“等待 VS Code 主机连接”,先确认 relay 地址与扩展设置的端口完全一致, +然后在 VS Code 执行一次 **Developer: Reload Window**。同步模式必须在官方 Codex +面板已经打开至少一个会话后才能发现 owner;通常不需要手工填写 +`codexRemoteCollab.threadId`,留空会自动选择最近的可用会话。若之前填写过已经关闭的 +thread ID,清空该设置后再重载窗口。 + +### 发布与下载插件 + +正式发布时不需要用户在本地编译。仓库的 `.github/workflows/release.yml` 在推送 +`vX.Y.Z`、`vX.Y.Z-beta.N` 或 `vX.Y.Z-rc.N` 标签时,会在 GitHub Actions 中完成 Web +前端构建、扩展编译和 VSIX 打包,并把 +`aether-vscodex-.vsix` 附加到对应的 GitHub Release。用户从 Release +页面下载该 VSIX,在 VS Code 的扩展视图中选择“从 VSIX 安装...”即可;安装后执行一次 +**Developer: Reload Window**。 + +手动运行该 workflow 时,VSIX 会作为 `aether-vscodex-vsix` Actions artifact 提供下载, +但不会创建 GitHub Release。源码目录中的 VSIX 只用于本地开发验证,不是用户发布渠道。 + +如果命令面板提示 `command 'codexRemoteCollab.start' not found`,通常是旧版 +VSIX 激活失败(旧包可能没有包含 `ws` 运行依赖)。请安装当前的 +`codex-remote-collab-0.4.0.vsix` 并使用 `--force` 覆盖旧版本,然后执行一次 +**Developer: Reload Window**: + +```sh +code --install-extension vscode-extension/codex-remote-collab-0.4.0.vsix --force +``` + +也可以在 **Output → Codex Remote Collaboration** 中确认没有 +`Cannot find module 'ws'`;出现该错误时,说明扩展尚未成功激活。 + +网页现在按官方 Codex Webview 的会话模型展示:历史和实时输出在中间消息流,用户、 +助手、reasoning、命令输出分别投影为对应的消息项;助手内容支持安全的 Markdown、 +代码块和复制操作,reasoning/命令活动可折叠。底部 composer 使用可编辑富文本区域, +回车发送、Shift+Enter 换行;审批和用户输入会以内嵌 card 出现在会话流中,支持风险 +标记、输入控件、授权范围和明确的允许/拒绝动作。附着适配器会额外发送可选的 +`messages` 角色投影,旧版 host 没有该字段时网页仍回退到纯文本快照。 + +页面打开后自动连接并在断线后重连,不再需要手动点击“连接”或“断开”。同步模式下 +会话列表、返回历史和新建入口会被禁用,所有输入都发送到 VS Code 当前会话。这里复刻的是从本机已安装 +官方 bundle 审计出的布局、状态和交互;官方 bundle 依赖 VS Code 私有 Webview API, +不能安全地直接作为 iframe 嵌入浏览器。 + +底部的“同步 / 异步”分段控件发送 `control/mode/set`。当前 turn 正在执行或存在待处理 +授权、用户输入时,主机拒绝切换;候选适配器启动失败时保留原模式和原会话。切入异步 +模式后,页面顶部会恢复会话历史、新建和选择入口;`session/list` 映射到 +`thread/list`,选择会话使用 `thread/resume` 并水合完整历史,新建会话使用 +`thread/start`。切回同步模式会关闭独立 app-server,并重新以 VS Code 面板为唯一 +会话导航来源。 + +### 认证(可选) + +如果以后需要保护 relay,可显式开启认证;本机流程默认不需要这些变量: + +```sh +CODEX_REMOTE_AUTH=required \ +CODEX_REMOTE_HOST_TOKEN='host-only-secret' \ +CODEX_REMOTE_TOKEN='browser-operator-secret' \ +CODEX_REMOTE_VIEW_TOKEN='browser-viewer-secret' \ +CODEX_REMOTE_MODE=host npm start +``` + +认证开启后,Host token 填在 VS Code 扩展中,Operator/Viewer token 填在浏览器中。 + +## `spawn codex ENOENT` 是什么 + +这个错误只表示某处正在尝试启动**独立**的 `codex app-server`,但 VS Code 图形 +进程的 `PATH` 找不到可执行文件。对于本项目默认的同步模式,不会调用 +`spawn codex`,因此不需要通过设置 `codexCommand` 来修复它。 + +只有切换到异步模式(或仍使用旧版兼容设置)才需要独立可执行文件: + +```json +"codexRemoteCollab.controlMode": "async" +``` + +扩展会优先解析 `codexRemoteCollab.codexCommand`,并可回退到官方 Codex 扩展内置的 +可执行文件;`codexRemoteCollab.codexArgs` 默认是 `["app-server", "--stdio"]`。 +旧 `mode=attach/spawn` 会分别迁移为 `sync/async`。 + +## Relay 模式 + +### `host`(推荐) + +relay 只负责认证、事件缓存和转发;VS Code 扩展通过私有 IPC 附着官方 Codex +会话。必须先打开目标会话;本机 loopback 默认不需要 host token,只有显式开启认证时 +才把 host token 提供给扩展。 + +### `embedded`(旧的独立进程模式) + +只有显式设置 `CODEX_REMOTE_MODE=embedded` 时,relay 才会启动自己的 +`codex app-server --stdio`,适合测试页面和公开 app-server 协议;它与 VS Code +当前会话无关: + +```sh +CODEX_REMOTE_MODE=embedded CODEX_CWD="$PWD" npm start +``` + +`CODEX_BIN` 可指定独立进程的可执行文件;`CODEX_ARGS_JSON` 可覆盖其参数。不要 +把这些设置误认为 attach 模式的必要配置。 + +## HTTP API + +认证开启时,除 `/api/health` 外的 `/api/*` 都需要 +`Authorization: Bearer ` 或 `X-Codex-Token`;本机免认证 +模式下 loopback 请求直接作为 operator 处理。 + +```text +GET /api/health +GET /api/state +GET /api/events?fromSeq=0 +POST /api/command {"commandId":"...","method":"turn/start","params":{...}} +POST /api/respond {"requestId":"...","result":{...}} +``` + +host 模式下,同步控制会拒绝 `thread/start` 和网页会话导航;异步控制会把它们转给 +独立 app-server。浏览器使用 `threadId` 发送 `turn/start`、`turn/steer` 或 +`turn/interrupt`。认证开启时写操作和 +响应请求必须使用 operator token;本机免认证模式下 loopback operator 可直接操作。 + +## 私有协议和限制 + +- IPC follower 协议是官方 VS Code 扩展的私有、带版本号实现,不是公开 API;官方 + 扩展升级后可能需要同步适配。启用 `codexRemoteCollab.ipcStrictVersions` + 时,未知 stream 版本会让连接报错而不是猜测执行。 +- 自动发现只把本地 rollout 元数据当作候选,最终仍通过 IPC owner discovery + 验证;生产或多会话场景建议设置明确的 `threadId`。 +- relay 默认只监听 loopback,且 loopback 默认免认证;这意味着同一台机器上能访问 + loopback 的本地进程都可能控制会话,不要把它反向代理或暴露到外部。如果开启 token + 认证,token 是 bearer secret。高风险授权默认被 host policy 拒绝,只有显式设置 + `codexRemoteCollab.allowHighRiskApprovals=true` 才允许。 +- 输出会做常见 token/密码脱敏,但不能识别所有秘密;不要把凭据发送给 Codex。 +- 当前 UI 控制一个 host 会话,不提供多人同时编辑或文件同步。 + +## 测试 + +根目录测试使用假的 stdio app-server,不会向真实 Codex 发送任务: + +```sh +npm test +cd vscode-extension && npm run check && npm run build +``` + +要验证真实附着,只读地打开官方 VS Code 会话后启动 bridge;不要在验证脚本中 +调用 `turn/start`,除非你确实要向该会话发送任务。 diff --git a/aether-vscodex/cloud/server.js b/aether-vscodex/cloud/server.js new file mode 100644 index 000000000..08724945e --- /dev/null +++ b/aether-vscodex/cloud/server.js @@ -0,0 +1,727 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const path = require("node:path"); +const { URL } = require("node:url"); +const { WebSocket, WebSocketServer } = require("ws"); + +const { CodexRelay } = require("../relay/server.js"); + +const MAX_JSON_BYTES = 64 * 1024; +const MAX_WS_BYTES = 16 * 1024 * 1024; +const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000; +const DEFAULT_TICKET_TTL_MS = 60 * 1000; +const DEFAULT_ROOM_IDLE_MS = 30 * 60 * 1000; + +class DeviceStore { + constructor(filePath) { + this.filePath = filePath; + this.data = { version: 1, devices: [] }; + this.load(); + } + + load() { + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")); + if (parsed?.version !== 1 || !Array.isArray(parsed.devices)) throw new Error("unsupported device store format"); + this.data = parsed; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 }); + this.persist(); + } + } + + list(userId, connectedDeviceIds = new Set()) { + return this.data.devices + .filter((device) => device.user_id === userId && !device.revoked_at) + .map((device) => publicDevice(device, connectedDeviceIds.has(device.id))); + } + + create(userId, name) { + const id = crypto.randomUUID(); + const secret = crypto.randomBytes(32).toString("base64url"); + const salt = crypto.randomBytes(16).toString("base64url"); + const now = new Date().toISOString(); + const device = { + id, + user_id: userId, + name: normalizeName(name), + secret_salt: salt, + secret_hash: deriveSecret(secret, salt), + created_at: now, + last_seen_at: null, + revoked_at: null, + }; + this.data.devices.push(device); + this.persist(); + return { device: publicDevice(device, false), token: `avx1.${id}.${secret}` }; + } + + authenticate(token) { + const parsed = parseDeviceToken(token); + if (!parsed) return null; + const device = this.data.devices.find((candidate) => candidate.id === parsed.id && !candidate.revoked_at); + if (!device) return null; + const actual = Buffer.from(deriveSecret(parsed.secret, device.secret_salt), "base64url"); + const expected = Buffer.from(device.secret_hash, "base64url"); + if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null; + return device; + } + + get(userId, deviceId) { + return this.data.devices.find((device) => device.user_id === userId && device.id === deviceId && !device.revoked_at) || null; + } + + touch(deviceId) { + const device = this.data.devices.find((candidate) => candidate.id === deviceId && !candidate.revoked_at); + if (!device) return; + device.last_seen_at = new Date().toISOString(); + this.persist(); + } + + revoke(userId, deviceId) { + const device = this.get(userId, deviceId); + if (!device) return false; + device.revoked_at = new Date().toISOString(); + this.persist(); + return true; + } + + persist() { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 }); + const temporary = `${this.filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; + fs.writeFileSync(temporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporary, this.filePath); + } +} + +class EphemeralCredentials { + constructor(options = {}) { + this.pairingTtlMs = options.pairingTtlMs || DEFAULT_PAIRING_TTL_MS; + this.ticketTtlMs = options.ticketTtlMs || DEFAULT_TICKET_TTL_MS; + this.pairings = new Map(); + this.tickets = new Map(); + } + + createPairing(userId, requestedName) { + const code = pairingCode(); + const record = { + id: crypto.randomUUID(), + code, + user_id: userId, + requested_name: normalizeName(requestedName), + expires_at_ms: Date.now() + this.pairingTtlMs, + }; + this.pairings.set(normalizePairingCode(code), record); + return record; + } + + consumePairing(code) { + const key = normalizePairingCode(code); + const record = this.pairings.get(key); + this.pairings.delete(key); + if (!record || record.expires_at_ms <= Date.now()) return null; + return record; + } + + createTicket(userId, deviceId) { + const ticket = `avt1.${crypto.randomBytes(32).toString("base64url")}`; + this.tickets.set(ticket, { + user_id: userId, + device_id: deviceId, + expires_at_ms: Date.now() + this.ticketTtlMs, + }); + return ticket; + } + + consumeTicket(ticket) { + const record = this.tickets.get(ticket); + this.tickets.delete(ticket); + if (!record || record.expires_at_ms <= Date.now()) return null; + return record; + } + + cleanup() { + const now = Date.now(); + for (const [key, record] of this.pairings) if (record.expires_at_ms <= now) this.pairings.delete(key); + for (const [key, record] of this.tickets) if (record.expires_at_ms <= now) this.tickets.delete(key); + } +} + +class RoomManager { + constructor(options = {}) { + this.rooms = new Map(); + this.pendingRooms = new Map(); + this.revokedRoomKeys = new Set(); + this.idleMs = options.idleMs || DEFAULT_ROOM_IDLE_MS; + } + + key(userId, deviceId) { + return `${encodeURIComponent(userId)}:${deviceId}`; + } + + async get(userId, deviceId) { + const key = this.key(userId, deviceId); + if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked"); + let room = this.rooms.get(key); + if (!room && this.pendingRooms.has(key)) room = await this.pendingRooms.get(key); + if (!room) { + const creating = this.createRoom(key, userId, deviceId); + this.pendingRooms.set(key, creating); + try { + room = await creating; + } finally { + this.pendingRooms.delete(key); + } + } + if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked"); + room.lastActiveMs = Date.now(); + return room; + } + + async createRoom(key, userId, deviceId) { + const hostToken = randomToken(); + const operatorToken = randomToken(); + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + authRequired: true, + hostToken, + operatorToken, + viewerToken: randomToken(), + }); + await relay.start(); + if (this.revokedRoomKeys.has(key)) { + await relay.stop().catch(() => undefined); + throw httpError(401, "device revoked"); + } + const address = relay.address(); + const room = { + key, + userId, + deviceId, + relay, + hostToken, + operatorToken, + baseUrl: `ws://127.0.0.1:${address.port}`, + connections: 0, + lastActiveMs: Date.now(), + }; + this.rooms.set(key, room); + return room; + } + + connectedDeviceIds(userId) { + return new Set([...this.rooms.values()] + .filter((room) => room.userId === userId && room.relay.state.hostConnected) + .map((room) => room.deviceId)); + } + + retain(room) { + room.connections += 1; + room.lastActiveMs = Date.now(); + } + + release(room) { + room.connections = Math.max(0, room.connections - 1); + room.lastActiveMs = Date.now(); + } + + async cleanup() { + const now = Date.now(); + for (const [key, room] of this.rooms) { + if (room.connections > 0 || now - room.lastActiveMs < this.idleMs) continue; + this.rooms.delete(key); + await room.relay.stop(); + } + } + + async revoke(userId, deviceId) { + const key = this.key(userId, deviceId); + this.revokedRoomKeys.add(key); + const pending = this.pendingRooms.get(key); + if (pending) await pending.catch(() => undefined); + const room = this.rooms.get(key); + if (!room) return; + this.rooms.delete(key); + await room.relay.stop(); + } + + async stop() { + await Promise.allSettled([...this.pendingRooms.values()]); + this.pendingRooms.clear(); + const rooms = [...this.rooms.values()]; + this.rooms.clear(); + this.revokedRoomKeys.clear(); + await Promise.allSettled(rooms.map((room) => room.relay.stop())); + } +} + +class AetherVscodexCloudServer { + constructor(options = {}) { + this.host = options.host || process.env.HOST || "127.0.0.1"; + this.port = parsePort(options.port ?? process.env.PORT, 8788); + this.internalToken = options.internalToken || process.env.AETHER_VSCODEX_INTERNAL_TOKEN || ""; + this.publicWsUrl = options.publicWsUrl || process.env.AETHER_VSCODEX_PUBLIC_WS_URL || ""; + this.allowedOrigins = normalizeOrigins(options.allowedOrigins ?? process.env.AETHER_VSCODEX_ALLOWED_ORIGINS); + const dataDir = options.dataDir || process.env.AETHER_VSCODEX_DATA_DIR || path.join(process.cwd(), "data"); + this.store = options.store || new DeviceStore(path.join(dataDir, "devices.json")); + this.credentials = options.credentials || new EphemeralCredentials(options); + this.rooms = options.rooms || new RoomManager(options); + this.exchangeAttempts = new Map(); + this.httpServer = null; + this.wsServer = null; + this.cleanupTimer = null; + } + + async start() { + if (!this.internalToken) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN is required"); + if (Buffer.byteLength(this.internalToken, "utf8") < 24) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN must contain at least 24 bytes"); + if (!this.publicWsUrl) throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL is required"); + validatePublicWsUrl(this.publicWsUrl); + if (!isLoopbackHost(this.host) && this.allowedOrigins.size === 0) { + throw new Error("AETHER_VSCODEX_ALLOWED_ORIGINS is required when binding outside loopback"); + } + this.httpServer = http.createServer((request, response) => { + void this.handleHttp(request, response).catch((error) => { + jsonResponse(response, error.statusCode || 500, { error: error.expose ? error.message : "internal server error" }); + }); + }); + this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_BYTES }); + this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head)); + this.cleanupTimer = setInterval(() => { + this.credentials.cleanup(); + this.cleanupExchangeAttempts(); + void this.rooms.cleanup(); + }, 30_000); + this.cleanupTimer.unref(); + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + this.httpServer.once("error", onError); + this.httpServer.listen(this.port, this.host, () => { + this.httpServer.off("error", onError); + resolve(); + }); + }); + return this.address(); + } + + address() { + const address = this.httpServer.address(); + if (!address || typeof address === "string") return { host: this.host, port: this.port }; + return { host: address.address, port: address.port }; + } + + async stop() { + if (this.cleanupTimer) clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + if (this.wsServer) { + for (const client of this.wsServer.clients) client.close(1001, "server shutting down"); + await new Promise((resolve) => this.wsServer.close(() => resolve())); + } + if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve())); + this.wsServer = null; + this.httpServer = null; + await this.rooms.stop(); + } + + async handleHttp(request, response) { + const requestUrl = new URL(request.url || "/", "http://sidecar.local"); + if (request.method === "GET" && requestUrl.pathname === "/healthz") { + jsonResponse(response, 200, { ok: true, service: "aether-vscodex", mode: "single-replica" }); + return; + } + if (request.method === "POST" && requestUrl.pathname === "/v1/pairings/exchange") { + this.enforceExchangeRate(request); + const body = await readJson(request); + const pairing = this.credentials.consumePairing(body.code); + if (!pairing) throw httpError(400, "invalid or expired pairing code"); + const created = this.store.create(pairing.user_id, body.name || pairing.requested_name); + jsonResponse(response, 201, { + device_id: created.device.id, + device_name: created.device.name, + device_token: created.token, + ws_url: this.publicWsUrl, + }); + return; + } + + const match = requestUrl.pathname.match(/^\/internal\/v1\/users\/([^/]+)\/(devices|pairings|ws-tickets)(?:\/([^/]+))?$/); + if (!match) { + jsonResponse(response, 404, { error: "not found" }); + return; + } + this.requireInternalAuth(request); + const userId = decodeURIComponent(match[1]); + const resource = match[2]; + const resourceId = match[3] ? decodeURIComponent(match[3]) : null; + if (!userId || userId.length > 256) throw httpError(400, "invalid user id"); + + if (request.method === "GET" && resource === "devices" && !resourceId) { + jsonResponse(response, 200, { devices: this.store.list(userId, this.rooms.connectedDeviceIds(userId)) }); + return; + } + if (request.method === "POST" && resource === "pairings" && !resourceId) { + const body = await readJson(request); + const pairing = this.credentials.createPairing(userId, body.name); + jsonResponse(response, 201, { + pairing_id: pairing.id, + code: pairing.code, + expires_at: new Date(pairing.expires_at_ms).toISOString(), + }); + return; + } + if (request.method === "DELETE" && resource === "devices" && resourceId) { + if (!this.store.revoke(userId, resourceId)) throw httpError(404, "device not found"); + await this.rooms.revoke(userId, resourceId); + response.writeHead(204, { "Cache-Control": "no-store" }); + response.end(); + return; + } + if (request.method === "POST" && resource === "ws-tickets" && !resourceId) { + const body = await readJson(request); + const deviceId = typeof body.device_id === "string" ? body.device_id : ""; + if (!deviceId || !this.store.get(userId, deviceId)) throw httpError(404, "device not found"); + jsonResponse(response, 201, { + ticket: this.credentials.createTicket(userId, deviceId), + ws_url: "/api/vscodex/ws", + expires_in: Math.floor(this.credentials.ticketTtlMs / 1000), + }); + return; + } + jsonResponse(response, 405, { error: "method not allowed" }, { Allow: allowedMethod(resource, resourceId) }); + } + + requireInternalAuth(request) { + if (!this.hasInternalAuth(request)) throw httpError(401, "unauthorized"); + } + + hasInternalAuth(request) { + const authorization = String(request.headers.authorization || ""); + const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + return secureEqual(token, this.internalToken); + } + + enforceExchangeRate(request) { + const address = this.exchangeRateAddress(request); + const now = Date.now(); + const attempts = (this.exchangeAttempts.get(address) || []).filter((time) => now - time < 60_000); + if (attempts.length >= 10) throw httpError(429, "too many pairing attempts"); + attempts.push(now); + this.exchangeAttempts.set(address, attempts); + } + + exchangeRateAddress(request) { + if (this.hasInternalAuth(request)) { + const forwardedAddress = singleHeaderValue(request, "x-aether-client-ip")?.trim(); + if (forwardedAddress && net.isIP(forwardedAddress)) return forwardedAddress; + } + return request.socket.remoteAddress || "unknown"; + } + + cleanupExchangeAttempts() { + const now = Date.now(); + for (const [address, attempts] of this.exchangeAttempts) { + const active = attempts.filter((time) => now - time < 60_000); + if (active.length) this.exchangeAttempts.set(address, active); + else this.exchangeAttempts.delete(address); + } + } + + handleUpgrade(request, socket, head) { + const requestUrl = new URL(request.url || "/", "http://sidecar.local"); + if (requestUrl.pathname !== "/api/vscodex/ws" && requestUrl.pathname !== "/v1/connect") { + rejectUpgrade(socket, 404, "Not Found"); + return; + } + const origin = request.headers.origin; + if (origin && this.allowedOrigins.size > 0 && !this.allowedOrigins.has(normalizeOrigin(origin))) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + this.wsServer.handleUpgrade(request, socket, head, (webSocket) => { + this.wsServer.emit("connection", webSocket, request); + this.handleWebSocket(webSocket, request); + }); + } + + handleWebSocket(socket, request) { + let hello = null; + let token = ""; + let upstream = null; + let authenticating = false; + let room = null; + const queued = []; + const authTimer = setTimeout(() => socket.close(1008, "authentication required"), 10_000); + authTimer.unref(); + + const connectUpstream = async () => { + if (authenticating || upstream || !hello || !token) return; + authenticating = true; + let identity; + let upstreamToken; + if (hello.clientType === "host") { + const device = this.store.authenticate(token); + if (!device) throw httpError(401, "invalid device credential"); + identity = { userId: device.user_id, deviceId: device.id }; + room = await this.rooms.get(identity.userId, identity.deviceId); + upstreamToken = room.hostToken; + this.store.touch(device.id); + } else { + const ticket = this.credentials.consumeTicket(token); + if (!ticket || !this.store.get(ticket.user_id, ticket.device_id)) throw httpError(401, "invalid or expired browser ticket"); + identity = { userId: ticket.user_id, deviceId: ticket.device_id }; + room = await this.rooms.get(identity.userId, identity.deviceId); + upstreamToken = room.operatorToken; + } + this.rooms.retain(room); + upstream = new WebSocket(`${room.baseUrl}${hello.clientType === "host" ? "/v1/connect" : "/ws"}`, { + maxPayload: MAX_WS_BYTES, + }); + upstream.once("open", () => { + if (socket.readyState !== WebSocket.OPEN) { + upstream.close(); + return; + } + upstream.send(JSON.stringify(hello)); + upstream.send(JSON.stringify(hello.clientType === "host" + ? { v: 1, kind: "auth", accessToken: upstreamToken } + : { type: "auth", token: upstreamToken })); + for (const frame of queued.splice(0)) upstream.send(frame); + }); + upstream.on("message", (data, isBinary) => { + if (socket.readyState === WebSocket.OPEN) socket.send(data, { binary: isBinary }); + }); + upstream.on("close", (code, reason) => { + if (socket.readyState === WebSocket.OPEN) socket.close(validCloseCode(code) ? code : 1011, reason.toString().slice(0, 120) || "relay closed"); + }); + upstream.on("error", () => { + if (socket.readyState === WebSocket.OPEN) socket.close(1011, "relay unavailable"); + }); + clearTimeout(authTimer); + }; + + socket.on("message", (data, isBinary) => { + if (isBinary) { + socket.close(1003, "JSON text frames only"); + return; + } + if (upstream) { + const text = data.toString("utf8"); + if (upstream.readyState === WebSocket.OPEN) upstream.send(text); + else queued.push(text); + return; + } + let message; + try { + message = JSON.parse(data.toString("utf8")); + } catch { + socket.close(1007, "invalid JSON"); + return; + } + if (message?.kind === "hello") { + if (Number(message.protocol || 1) !== 1) { + socket.close(1002, "unsupported protocol"); + return; + } + hello = { + v: 1, + kind: "hello", + clientType: message.clientType === "host" ? "host" : "web", + protocol: 1, + ...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}), + ...(Number.isFinite(Number(message.lastSeq)) ? { lastSeq: Number(message.lastSeq) } : {}), + }; + } else if (message?.kind === "auth" || message?.type === "auth") { + token = typeof message.accessToken === "string" ? message.accessToken : typeof message.token === "string" ? message.token : ""; + } else { + socket.close(1002, "hello and auth required"); + return; + } + void connectUpstream().catch(() => socket.close(1008, "authentication failed")); + }); + socket.on("close", () => { + clearTimeout(authTimer); + if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close(); + if (room) this.rooms.release(room); + }); + socket.on("error", () => {}); + } +} + +function parseDeviceToken(token) { + const match = /^avx1\.([0-9a-f-]{36})\.([A-Za-z0-9_-]{32,})$/.exec(String(token || "")); + return match ? { id: match[1], secret: match[2] } : null; +} + +function deriveSecret(secret, salt) { + return crypto.scryptSync(secret, Buffer.from(salt, "base64url"), 32).toString("base64url"); +} + +function publicDevice(device, connected) { + return { + id: device.id, + name: device.name, + connected, + created_at: device.created_at, + last_seen_at: device.last_seen_at, + }; +} + +function normalizeName(value) { + const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, 80) : ""; + return name || "VS Code"; +} + +function pairingCode() { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const bytes = crypto.randomBytes(8); + let result = ""; + for (let index = 0; index < 8; index += 1) result += alphabet[bytes[index] % alphabet.length]; + return `${result.slice(0, 4)}-${result.slice(4)}`; +} + +function normalizePairingCode(value) { + return String(value || "").toUpperCase().replace(/[^A-Z2-9]/g, ""); +} + +function randomToken() { + return crypto.randomBytes(32).toString("base64url"); +} + +function secureEqual(left, right) { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function singleHeaderValue(request, name) { + const distinctValues = request.headersDistinct?.[name]; + if (Array.isArray(distinctValues)) return distinctValues.length === 1 ? distinctValues[0] : null; + const value = request.headers[name]; + return typeof value === "string" ? value : null; +} + +function parsePort(value, fallback) { + const parsed = Number(value ?? fallback); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("invalid port"); + return parsed; +} + +function normalizeOrigins(value) { + const values = Array.isArray(value) ? value : String(value || "").split(","); + return new Set(values.map(normalizeOrigin).filter(Boolean)); +} + +function normalizeOrigin(value) { + try { + return new URL(String(value).trim()).origin.toLowerCase(); + } catch { + return ""; + } +} + +function validatePublicWsUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must be an absolute WebSocket URL"); + } + if (url.protocol !== "wss:" && !(url.protocol === "ws:" && isLoopbackHost(url.hostname))) { + throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must use wss:// outside loopback"); + } +} + +function isLoopbackHost(value) { + const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase(); + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +function validCloseCode(code) { + return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + request.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_JSON_BYTES) { + reject(httpError(413, "request body too large")); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + try { + const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + resolve(value); + } catch { + reject(httpError(400, "invalid JSON body")); + } + }); + request.on("error", reject); + }); +} + +function jsonResponse(response, statusCode, body, extraHeaders = {}) { + if (response.headersSent) return; + const payload = Buffer.from(JSON.stringify(body)); + response.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payload.length, + "Cache-Control": "no-store", + ...extraHeaders, + }); + response.end(payload); +} + +function rejectUpgrade(socket, status, reason) { + socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +} + +function httpError(statusCode, message) { + return Object.assign(new Error(message), { statusCode, expose: statusCode < 500 }); +} + +function allowedMethod(resource, resourceId) { + if (resource === "devices" && resourceId) return "DELETE"; + if (resource === "devices") return "GET"; + return "POST"; +} + +async function main() { + const server = new AetherVscodexCloudServer(); + const address = await server.start(); + process.stdout.write(`Aether VS Codex sidecar listening on ${address.host}:${address.port}\n`); + const shutdown = async () => { + await server.stop(); + process.exit(0); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error.stack || error}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + AetherVscodexCloudServer, + DeviceStore, + EphemeralCredentials, + RoomManager, +}; diff --git a/aether-vscodex/docker-compose.aether.yml b/aether-vscodex/docker-compose.aether.yml new file mode 100644 index 000000000..2ebffc44b --- /dev/null +++ b/aether-vscodex/docker-compose.aether.yml @@ -0,0 +1,37 @@ +services: + app: + environment: + AETHER_VSCODEX_ENABLED: "true" + AETHER_VSCODEX_INTERNAL_URL: http://vscodex:8788 + AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN} + AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL} + depends_on: + vscodex: + condition: service_healthy + volumes: + - ./aether-vscodex/web/dist:/opt/aether/releases/image/frontend/aether-vscodex:ro + + vscodex: + build: + context: ./aether-vscodex + image: ${AETHER_VSCODEX_IMAGE:-aether-vscodex:local} + environment: + HOST: 0.0.0.0 + PORT: 8788 + AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN} + AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL} + AETHER_VSCODEX_ALLOWED_ORIGINS: ${AETHER_VSCODEX_ALLOWED_ORIGINS:?set AETHER_VSCODEX_ALLOWED_ORIGINS} + AETHER_VSCODEX_DATA_DIR: /var/lib/aether-vscodex + expose: + - "8788" + volumes: + - vscodex_data:/var/lib/aether-vscodex + logging: + driver: local + options: + max-size: "50m" + max-file: "3" + restart: unless-stopped + +volumes: + vscodex_data: diff --git a/aether-vscodex/docs/cloud-security.md b/aether-vscodex/docs/cloud-security.md new file mode 100644 index 000000000..3608afe89 --- /dev/null +++ b/aether-vscodex/docs/cloud-security.md @@ -0,0 +1,20 @@ +# Cloud security model + +## Trust boundaries + +- Aether authenticates browser HTTP requests and resolves the user ID. The client never supplies a trusted user ID. +- The Node sidecar never receives an Aether access token or JWT signing key. +- A VS Code installation receives one revocable device credential. Only its scrypt hash is persisted. +- An iframe receives a random, one-time WebSocket ticket with a 60-second lifetime. Tickets are sent in an auth frame, never in a URL. +- The embedded UI is trusted, same-origin Aether code. `allow-same-origin` is required by the current integration, so the iframe is not a sandbox boundary for untrusted content even though the parent does not post its JWT into the frame. +- Relay state is isolated by `(user_id, device_id)`. A browser ticket and host credential must resolve to the same room. + +## Network boundary + +Run the sidecar on the private Compose network. Do not publish port 8788. Aether gateway is the only public HTTP and WebSocket entry point and authenticates internal API calls with `AETHER_VSCODEX_INTERNAL_TOKEN`. + +`AETHER_VSCODEX_ALLOWED_ORIGINS` must contain the exact public Aether origin when the sidecar binds outside loopback. Public deployments must use HTTPS/WSS. + +## Current scaling limit + +The first release intentionally runs one sidecar replica. Pairing codes, browser tickets, and the live connection directory are process-local. Before adding replicas, move those records to a shared atomic store and add sticky or distributed WebSocket room routing. diff --git a/aether-vscodex/fixtures/fake-app-server.cjs b/aether-vscodex/fixtures/fake-app-server.cjs new file mode 100644 index 000000000..2001db255 --- /dev/null +++ b/aether-vscodex/fixtures/fake-app-server.cjs @@ -0,0 +1,59 @@ +"use strict"; + +const readline = require("node:readline"); + +let threadNumber = 0; +let turnNumber = 0; +let activeThread = null; +let activeTurn = null; + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +const input = readline.createInterface({ input: process.stdin }); +input.on("line", (line) => { + let request; + try { request = JSON.parse(line); } catch { return; } + if (request.method === "initialize") { + send({ id: request.id, result: { userAgent: "fake", codexHome: "/tmp/codex" } }); + send({ method: "remoteControl/status/changed", params: { status: "disabled" } }); + return; + } + if (request.method === "thread/start") { + activeThread = `thread-${++threadNumber}`; + send({ id: request.id, result: { thread: { id: activeThread }, cwd: request.params?.cwd || "/tmp" } }); + send({ method: "thread/started", params: { thread: { id: activeThread } } }); + return; + } + if (request.method === "turn/start") { + activeTurn = `turn-${++turnNumber}`; + send({ id: request.id, result: { turn: { id: activeTurn } } }); + send({ method: "turn/started", params: { threadId: request.params.threadId, turn: { id: activeTurn } } }); + const text = request.params.input?.[0]?.text || ""; + send({ method: "item/agentMessage/delta", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-1", delta: `echo: ${text}` } }); + if (text.includes("approve")) { + send({ id: 9001, method: "item/commandExecution/requestApproval", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-2", command: "echo approval" } }); + } else { + send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: activeTurn } } }); + activeTurn = null; + } + return; + } + if (request.method === "turn/steer") { + send({ id: request.id, result: { turn: { id: activeTurn } } }); + send({ method: "item/agentMessage/delta", params: { delta: `steered: ${request.params.input?.[0]?.text || ""}` } }); + return; + } + if (request.method === "turn/interrupt") { + send({ id: request.id, result: {} }); + send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: request.params.turnId } } }); + activeTurn = null; + return; + } + if (request.id === 9001 && (request.result || request.error)) { + send({ method: "item/agentMessage/delta", params: { delta: `approval response: ${JSON.stringify(request.result || request.error)}` } }); + send({ method: "turn/completed", params: { threadId: activeThread, turn: { id: activeTurn } } }); + activeTurn = null; + } +}); diff --git a/aether-vscodex/package-lock.json b/aether-vscodex/package-lock.json new file mode 100644 index 000000000..97286de97 --- /dev/null +++ b/aether-vscodex/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "aether-vscodex", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aether-vscodex", + "version": "0.4.0", + "dependencies": { + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20" + } + }, + "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 + } + } + } + } +} diff --git a/aether-vscodex/package.json b/aether-vscodex/package.json new file mode 100644 index 000000000..831fe6aa0 --- /dev/null +++ b/aether-vscodex/package.json @@ -0,0 +1,23 @@ +{ + "name": "aether-vscodex", + "version": "0.4.0", + "private": true, + "description": "Synchronous VS Code Codex mirroring and asynchronous Codex control for local Web and Aether", + "type": "commonjs", + "main": "relay/server.js", + "scripts": { + "start": "node relay/server.js", + "start:cloud": "node cloud/server.js", + "build:web": "npm --prefix web run build", + "build:extension": "npm --prefix vscode-extension run build", + "build": "npm run build:web && npm run build:extension", + "test": "node --test test/*.test.js", + "test:web": "npm --prefix web test" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "ws": "^8.18.3" + } +} diff --git a/aether-vscodex/public/app.js b/aether-vscodex/public/app.js new file mode 100644 index 000000000..e6947d26b --- /dev/null +++ b/aether-vscodex/public/app.js @@ -0,0 +1,8199 @@ +(function () { + "use strict"; + + const $ = (id) => document.getElementById(id); + const embedBridge = window.AetherVscodexEmbed; + const embeddedInAether = Boolean(embedBridge?.active); + const i18n = window.VscodexI18n; + const t = (value) => i18n?.t ? i18n.t(value) : String(value ?? ""); + const uiLocale = () => i18n?.locale?.() || "zh-CN"; + // Translate renderer-owned labels while leaving values supplied by the host + // (conversation titles, file paths, commands, and message text) untouched. + const uiText = (zh, en) => uiLocale() === "en-US" ? en : zh; + const uiWithRaw = (zhPrefix, enPrefix, raw, zhSuffix = "", enSuffix = "") => + `${uiText(zhPrefix, enPrefix)}${String(raw ?? "")}${uiText(zhSuffix, enSuffix)}`; + const appStatusLabel = (value) => { + const normalized = String(value || "").trim().toLowerCase(); + const labels = { + ready: "已连接", + online: "已连接", + offline: "VS Code 主机未连接", + waiting_for_host: "等待 VS Code 主机连接", + app_not_ready: "等待 VS Code 主机连接", + starting: "正在连接", + connecting: "正在连接", + stopped: "已停止", + }; + return t(labels[normalized] || value || "未知"); + }; + const state = { + ws: null, + token: "", + role: null, + appReady: false, + lastSeq: 0, + // A control snapshot is authoritative for every event up to this + // sequence. Replayed notifications from the subscribe handshake must not + // resurrect an already-finished turn or duplicate its transcript. + lastSnapshotSeq: 0, + awaitingSnapshot: false, + threadId: "", + turnId: "", + attachMode: false, + authRequired: null, + outputSynced: false, + structuredMessages: [], + requests: new Map(), + responding: new Set(), + commandResults: new Set(), + reconnectTimer: null, + embedTicket: "", + embedWsUrl: "", + embedDeviceId: "", + embedTicketRequested: false, + embedStopped: false, + activeAssistantBody: null, + activeAssistantStream: null, + activeAssistantText: "", + pendingUserText: "", + retiredTurnIds: new Set(), + syncedThreadId: null, + snapshotNoticeShown: false, + // Live work items are rendered as updateable transcript entries. The + // adapter may emit item lifecycle notifications or only output chunks; + // keeping a small client-side index lets both forms converge on one row. + activities: new Map(), + commandDisclosure: new Map(), + activitySequence: 0, + activityTimer: null, + activeAssistantActivityKey: null, + turnStartedAt: null, + // The official worked-for row measures from the first work item until + // the final assistant response starts. Keep this separate from the + // overall turn clock because the latter also includes queue/approval time. + turnWorkStartedAt: null, + finalAssistantStartedAt: null, + turnStatus: "idle", + lastTurnDurationMs: null, + lastWorkedDurationMs: null, + workedDurationMs: null, + currentActivity: "idle", + currentActivityStartedAt: null, + currentActivityDurationMs: null, + currentActivityTurnId: "", + currentModel: "", + // Empty means the host has not reported an effort yet; null is an + // authoritative "use the model default" value and must not be serialized + // as medium on the next turn. + currentEffort: "", + sandboxPolicy: "workspace-write", + approvalPolicy: "on-request", + tokenUsage: null, + availableModels: [], + subagents: [], + controlMode: "sync", + modeEpoch: -1, + capabilities: { + followsVscodeRoute: true, + sessionList: false, + sessionSelect: false, + sessionCreate: false, + threadSettings: false, + }, + modeSnapshotReady: false, + modeSwitching: false, + modeCommandId: "", + requestedControlMode: "", + modeRequestEpoch: -1, + sessions: [], + sessionPickerOpen: false, + sessionSearch: "", + sessionFocusedId: "", + sessionListLoading: false, + sessionListError: "", + sessionListCommandId: "", + sessionSelectCommandId: "", + newSessionCommandId: "", + sessionSelectedThreadId: "", + sessionSwitching: false, + // Keep the previous view/title visible until the host confirms the target + // with an authoritative session snapshot. A target can be listed as + // attachable and still time out during owner hand-off; dropping the old + // DOM at `session.switching` would leave the browser blank in that case. + sessionSwitchContext: null, + modelUpdatePending: false, + modelAdvancedOpen: false, + // The official composer keeps the background-agent disclosure closed on + // first render; the @ hint appears only after the reader expands it. + subagentsCollapsed: true, + subagentsExpanded: { active: false, done: false }, + lastRenderedDateKey: "", + lastRenderedTimestamp: null, + lastRenderedRole: "", + hasRenderedUser: false, + lastDateSeparatorTimestamp: null, + turnDividers: new Map(), + // Preserve an explicit worked-for toggle across authoritative snapshot + // rebuilds. Unset entries follow the official default: the latest turn is + // open while older completed turns remain compact. + turnExpansion: new Map(), + // Legacy attach snapshots may omit turnId on individual projected items. + // Keep the derived association by object identity for the duration of a + // snapshot so all grouping/timing paths use the same anonymous turn key. + structuredTurnKeys: new WeakMap(), + liveActivityKey: null, + pendingUserArticle: null, + outputDistanceFromBottom: 0, + timelineAnchorLockUntil: 0, + timelineAnchorCancel: null, + timelineRevealCancel: null, + }; + const RESPONDABLE_METHODS = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "applyPatchApproval", + "execCommandApproval", + ]); + + const requestKey = (requestId) => `${typeof requestId}:${String(requestId)}`; + + function normalizeControlMode(value) { + const normalized = String(value || "").trim().toLowerCase(); + return normalized === "sync" || normalized === "async" ? normalized : ""; + } + + function normalizedControlCapabilities(value) { + const source = isRecord(value) ? value : {}; + return { + followsVscodeRoute: source.followsVscodeRoute === true, + sessionList: source.sessionList === true, + sessionSelect: source.sessionSelect === true, + sessionCreate: source.sessionCreate === true, + threadSettings: source.threadSettings === true, + }; + } + + function sessionControlAllowed(capability) { + return state.modeSnapshotReady + && state.controlMode === "async" + && state.capabilities[capability] === true; + } + + function threadSettingsAllowed() { + return state.modeSnapshotReady && state.capabilities.threadSettings === true; + } + + function controlModeChangeBlocked() { + return !state.modeSnapshotReady + || state.modeSwitching + || state.sessionSwitching + || !state.appReady + || !state.ws + || state.ws.readyState !== WebSocket.OPEN + || !["operator", "owner", "host"].includes(String(state.role || "")) + || Boolean(state.turnId) + || state.turnStartedAt !== null + || state.turnStatus === "active" + || state.turnStatus === "waiting" + || Boolean(state.pendingUserText) + || state.requests.size > 0 + || state.responding.size > 0 + || Boolean(state.newSessionCommandId) + || Boolean(state.sessionListCommandId) + || Boolean(state.sessionSelectCommandId) + || state.modelUpdatePending; + } + + function clearControlModeRequest() { + state.modeSwitching = false; + state.modeCommandId = ""; + state.requestedControlMode = ""; + state.modeRequestEpoch = -1; + } + + function renderControlMode() { + const control = $("controlModeSwitch"); + if (!control) return; + const listAllowed = sessionControlAllowed("sessionList"); + const createAllowed = sessionControlAllowed("sessionCreate"); + const settingsAllowed = threadSettingsAllowed(); + const blocked = controlModeChangeBlocked(); + control.dataset.mode = state.controlMode; + control.dataset.epoch = String(state.modeEpoch); + control.dataset.switching = String(state.modeSwitching); + control.setAttribute("aria-label", t("控制模式")); + control.setAttribute("aria-busy", String(state.modeSwitching)); + for (const button of control.querySelectorAll("[data-control-mode]")) { + const mode = normalizeControlMode(button.dataset.controlMode); + const current = mode === state.controlMode; + const pending = state.modeSwitching && mode === state.requestedControlMode; + button.textContent = t(mode === "async" ? "异步" : "同步"); + button.title = t(mode === "async" ? "异步模式可独立管理会话" : "同步模式跟随 VS Code 当前会话"); + button.setAttribute("aria-pressed", String(current)); + button.dataset.pending = String(pending); + button.disabled = blocked || current; + } + + const sessionPickerButton = $("sessionPickerButton"); + if (sessionPickerButton) { + sessionPickerButton.disabled = !listAllowed || state.modeSwitching || state.sessionSwitching; + sessionPickerButton.setAttribute("aria-disabled", String(sessionPickerButton.disabled)); + } + for (const id of ["backButton", "historyButton"]) { + const button = $(id); + if (button) button.hidden = !listAllowed; + } + const newSessionButton = $("newSessionButton"); + if (newSessionButton) newSessionButton.hidden = !createAllowed; + const sessionsMenuItem = document.querySelector('[data-menu-action="sessions"]'); + if (sessionsMenuItem) sessionsMenuItem.hidden = !listAllowed; + const refresh = $("sessionPickerRefresh"); + if (refresh) refresh.disabled = !listAllowed || state.modeSwitching; + if (!listAllowed && state.sessionPickerOpen) setSessionPicker(false); + + for (const action of document.querySelectorAll('[data-settings-action="model"], [data-settings-action="permission"]')) { + action.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching; + } + for (const id of ["modelPickerButton", "permissionChip"]) { + const button = $(id); + if (button) button.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching; + } + if (!settingsAllowed) { + setModelMenu(false); + setPermissionMenu(false); + } + } + + function applyControlModeSnapshot(metadata) { + if (!isRecord(metadata)) return false; + const mode = normalizeControlMode(metadata.controlMode); + const epoch = finiteNumber(metadata.modeEpoch); + if (!mode || epoch === null || (state.modeSnapshotReady && epoch < state.modeEpoch)) return false; + const wasSwitching = state.modeSwitching; + const requestedMode = state.requestedControlMode; + const requestEpoch = state.modeRequestEpoch; + state.controlMode = mode; + state.modeEpoch = epoch; + state.capabilities = normalizedControlCapabilities(metadata.capabilities); + state.modeSnapshotReady = true; + const requestResolved = wasSwitching && (epoch > requestEpoch || mode === requestedMode); + if (requestResolved) { + clearControlModeRequest(); + setConversationStatus(mode === requestedMode ? "控制模式已切换" : "控制模式切换失败", mode === requestedMode ? "ready" : "warning"); + } + updateIds(); + return true; + } + + function resolveRequestKey(requestId) { + const exact = requestKey(requestId); + if (state.requests.has(exact)) return exact; + const text = String(requestId); + const candidates = [...state.requests] + .filter(([, request]) => String(request.requestId) === text) + .map(([key]) => key); + return candidates.length === 1 ? candidates[0] : exact; + } + + function shouldFollowOutput(output) { + return output.scrollHeight - output.scrollTop - output.clientHeight <= 24; + } + + const disclosureAnimations = new WeakMap(); + const activityAnimations = new WeakMap(); + const commandAnimations = new WeakMap(); + + function motionDuration(value = 220) { + try { + if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) return 0; + } catch { + // Older embedded webviews may not expose matchMedia. + } + return Math.max(0, Number(value) || 0); + } + + function motionClock() { + return typeof performance === "object" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + } + + function scheduleFrame(callback) { + if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback); + return setTimeout(callback, 0); + } + + function cancelFrame(frame) { + if (frame === null || frame === undefined) return; + if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame); + clearTimeout(frame); + } + + function runMeasuredCssTransition(element, from, to, duration, onFinish) { + if (!element) return null; + const priorTransition = element.style.transition; + let frame = null; + let timer = null; + let cancelled = false; + const setStyles = (values) => { + for (const [property, value] of Object.entries(values)) element.style[property] = String(value); + }; + const finish = () => { + if (cancelled) return; + cancelled = true; + cancelFrame(frame); + frame = null; + if (timer !== null) clearTimeout(timer); + timer = null; + element.style.transition = priorTransition; + onFinish?.(); + }; + const controller = { + cancel() { + if (cancelled) return; + cancelled = true; + cancelFrame(frame); + frame = null; + if (timer !== null) clearTimeout(timer); + timer = null; + element.style.transition = priorTransition; + }, + }; + const properties = Object.keys(to).map((property) => property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)); + element.style.transition = properties + .map((property) => `${property} ${duration}ms cubic-bezier(.33,1,.68,1)`) + .join(", "); + setStyles(from); + // Force a layout boundary before moving to the measured target. This is + // the fallback for embedded Chromium builds without Element.animate(). + void element.offsetHeight; + frame = scheduleFrame(() => { + frame = null; + if (cancelled) return; + setStyles(to); + }); + timer = setTimeout(finish, duration + 55); + return controller; + } + + // Keep the element nearest the reader at the same viewport coordinate while + // a disclosure or streamed row changes height. This mirrors the official + // preserve-timeline-anchor-position helper and avoids bottom-relative jumps + // when the reader is inspecting an earlier turn. + function preserveTimelineAnchor(anchor, duration = 250) { + const target = anchor?.nodeType === 1 ? anchor : null; + const output = target?.closest?.(".chat-scroll") || $("output"); + if (!target || !output || !target.isConnected || !output.isConnected) return; + if (typeof state.timelineAnchorCancel === "function") state.timelineAnchorCancel(); + const initialTop = target.getBoundingClientRect().top; + if (!Number.isFinite(initialTop)) return; + const deadline = motionClock() + duration + 120; + state.timelineAnchorLockUntil = Math.max(state.timelineAnchorLockUntil || 0, deadline); + let frame = null; + let timer = null; + let disposed = false; + const adjust = () => { + if (disposed || !target.isConnected || !output.isConnected) return; + const delta = target.getBoundingClientRect().top - initialTop; + if (Number.isFinite(delta) && Math.abs(delta) >= 0.1) { + const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight); + output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta)); + updateScrollToBottom(output); + } + // A disclosure can change height without producing a ResizeObserver + // callback on older Chromium builds. Keep one frame queued for the full + // measured transition so the scrollbar and reader anchor move together. + if (motionClock() < deadline) schedule(); + }; + const schedule = () => { + if (disposed || frame !== null) return; + frame = scheduleFrame(() => { + frame = null; + adjust(); + }); + }; + const immediate = () => { + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + adjust(); + schedule(); + }; + const observed = target.closest("[data-turn-key]") || target.closest(".message") || target; + let observer = null; + if (typeof ResizeObserver === "function") { + observer = new ResizeObserver(immediate); + observer.observe(observed); + if (observed !== target) observer.observe(target); + } + schedule(); + const finish = () => { + if (disposed) return; + disposed = true; + cancelFrame(frame); + frame = null; + if (timer !== null) clearTimeout(timer); + timer = null; + observer?.disconnect(); + if (state.timelineAnchorCancel === finish) state.timelineAnchorCancel = null; + }; + state.timelineAnchorCancel = finish; + timer = setTimeout(finish, Math.max(250, duration + 150)); + } + + function setDisclosureBodyState(details, expanded) { + const body = details?.querySelector?.(":scope > .details-body"); + if (!body) return; + body.style.display = "block"; + body.style.height = expanded ? "auto" : "0px"; + body.style.opacity = expanded ? "1" : "0"; + body.style.overflow = expanded ? "" : "hidden"; + body.style.pointerEvents = expanded ? "auto" : "none"; + body.dataset.disclosureState = expanded ? "expanded" : "collapsed"; + details.dataset.expanded = String(Boolean(expanded)); + details.querySelector("summary")?.setAttribute("aria-expanded", String(Boolean(expanded))); + } + + function animateDisclosureBody(details, expanded, options = {}) { + const body = details?.querySelector?.(":scope > .details-body"); + if (!body) return; + const previous = disclosureAnimations.get(body); + if (previous) { + try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } + previous.cancel(); + } + disclosureAnimations.delete(body); + const next = Boolean(expanded); + details.dataset.expanded = String(next); + details.dataset.animating = "true"; + body.style.display = "block"; + const duration = motionDuration(options.duration ?? 220); + const computed = getComputedStyle(body); + const currentHeight = Math.max(0, body.getBoundingClientRect().height || 0); + const currentOpacity = Number.parseFloat(computed.opacity); + const fromOpacity = Number.isFinite(currentOpacity) ? currentOpacity : (next ? 0 : 1); + if (options.immediate || duration === 0) { + setDisclosureBodyState(details, next); + details.dataset.animating = "false"; + return; + } + let targetHeight = 0; + if (next) { + body.style.height = "auto"; + body.style.opacity = "1"; + body.style.overflow = "hidden"; + targetHeight = Math.max(0, body.getBoundingClientRect().height || body.scrollHeight || 0); + body.style.height = `${currentHeight}px`; + } else { + body.style.height = `${currentHeight}px`; + body.style.opacity = String(fromOpacity); + body.style.overflow = "hidden"; + } + if (Math.abs(targetHeight - currentHeight) < 0.5 && (next ? fromOpacity >= 0.99 : fromOpacity <= 0.01)) { + setDisclosureBodyState(details, next); + details.dataset.animating = "false"; + return; + } + if (typeof body.animate !== "function") { + let transition; + transition = runMeasuredCssTransition( + body, + { height: `${currentHeight}px`, opacity: fromOpacity }, + { height: `${targetHeight}px`, opacity: next ? 1 : 0 }, + duration, + () => { + if (disclosureAnimations.get(body) !== transition) return; + disclosureAnimations.delete(body); + setDisclosureBodyState(details, next); + details.dataset.animating = "false"; + }, + ); + if (transition) disclosureAnimations.set(body, transition); + return; + } + const animation = body.animate([ + { height: `${currentHeight}px`, opacity: fromOpacity }, + { height: `${targetHeight}px`, opacity: next ? 1 : 0 }, + ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); + disclosureAnimations.set(body, animation); + animation.addEventListener("finish", () => { + if (disclosureAnimations.get(body) !== animation) return; + disclosureAnimations.delete(body); + setDisclosureBodyState(details, next); + details.dataset.animating = "false"; + // `fill: forwards` keeps the animation in the cascade after finish. + // Release it only after the stable inline state is written, otherwise a + // later open can still measure the previous collapsed height (zero). + animation.cancel(); + }, { once: true }); + animation.addEventListener("cancel", () => { + if (disclosureAnimations.get(body) === animation) disclosureAnimations.delete(body); + }, { once: true }); + } + + function installDisclosure(details, initiallyOpen) { + if (!details || details.dataset.disclosureInstalled === "true") return; + const summary = details.querySelector(":scope > summary"); + const body = details.querySelector(":scope > .details-body"); + if (!summary || !body) return; + details.dataset.disclosureInstalled = "true"; + // Keep the native details element mounted. The summary still supplies the + // familiar keyboard/focus semantics, while the body itself is animated + // with measured height so a close does not jump the transcript. + details.open = true; + setDisclosureBodyState(details, Boolean(initiallyOpen)); + summary.addEventListener("click", (event) => { + event.preventDefault(); + setDetailsExpanded(details, !isDetailsExpanded(details)); + }, true); + details.addEventListener("toggle", () => { + // A browser/plugin script may still assign `.open = false`; restore the + // mounted shell and leave the visual state under our measured body. + if (!details.open && !details.__codexRestoring) { + details.__codexRestoring = true; + details.open = true; + details.__codexRestoring = false; + } + }); + } + + function isDetailsExpanded(details) { + if (!details) return false; + if (details.dataset.expanded !== undefined) return details.dataset.expanded === "true"; + return details.open === true; + } + + function setDetailsExpanded(details, expanded, options = {}) { + if (!details) return; + const next = Boolean(expanded); + const previous = isDetailsExpanded(details); + const installed = details.dataset.disclosureInstalled === "true"; + if (!installed) { + details.open = next; + return; + } + if (previous === next && !options.force) { + if (options.immediate) animateDisclosureBody(details, next, { immediate: true }); + return; + } + details.dataset.expanded = String(next); + if (options.preserve !== false && !options.immediate) preserveTimelineAnchor(details, options.duration ?? 230); + animateDisclosureBody(details, next, { immediate: Boolean(options.immediate), duration: options.duration }); + if (next && !options.immediate && options.reveal !== false) { + scheduleTimelineReveal(details.querySelector(":scope > .details-body") || details, options.duration ?? 220); + } + } + + function animateActivityArticle(article, expanded, options = {}) { + if (!article) return; + const previous = activityAnimations.get(article); + if (previous) { + try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } + previous.cancel(); + } + activityAnimations.delete(article); + const next = Boolean(expanded); + const duration = motionDuration(options.duration ?? 230); + const wasCollapsed = article.classList.contains("turn-collapsed"); + article.dataset.turnExpanded = String(next); + + // `worked-for` is an outer disclosure. The official renderer removes the + // whole activity group from layout while it is collapsed, rather than + // leaving one summary row per command. Animate the article shell itself; + // nested command/read disclosures keep their own expanded state. + if (next) article.classList.remove("turn-collapsed"); + article.style.display = ""; + article.style.height = ""; + article.style.opacity = ""; + article.style.visibility = ""; + article.style.pointerEvents = ""; + article.style.overflow = ""; + const currentHeight = Math.max(0, article.getBoundingClientRect().height || 0); + const naturalHeight = Math.max(0, article.scrollHeight || currentHeight); + const fromHeight = next && wasCollapsed ? 0 : currentHeight; + const targetHeight = next ? naturalHeight : 0; + const fromOpacity = next ? (wasCollapsed ? 0 : 1) : 1; + const targetOpacity = next ? 1 : 0; + const finish = () => { + if (next) { + article.classList.remove("turn-collapsed"); + article.style.height = ""; + article.style.opacity = ""; + article.style.visibility = ""; + article.style.pointerEvents = ""; + article.style.overflow = ""; + } else { + article.classList.add("turn-collapsed"); + article.style.height = "0px"; + article.style.opacity = "0"; + article.style.visibility = "hidden"; + article.style.pointerEvents = "none"; + article.style.overflow = "hidden"; + } + }; + if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) { + finish(); + return; + } + article.style.height = `${fromHeight}px`; + article.style.opacity = String(fromOpacity); + article.style.overflow = "hidden"; + if (typeof article.animate !== "function") { + let transition; + transition = runMeasuredCssTransition( + article, + { height: `${fromHeight}px`, opacity: fromOpacity }, + { height: `${targetHeight}px`, opacity: targetOpacity }, + duration, + () => { + if (activityAnimations.get(article) !== transition) return; + activityAnimations.delete(article); + finish(); + }, + ); + if (transition) activityAnimations.set(article, transition); + return; + } + const animation = article.animate([ + { height: `${fromHeight}px`, opacity: fromOpacity }, + { height: `${targetHeight}px`, opacity: targetOpacity }, + ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); + activityAnimations.set(article, animation); + animation.addEventListener("finish", () => { + if (activityAnimations.get(article) !== animation) return; + activityAnimations.delete(article); + finish(); + animation.cancel(); + }, { once: true }); + animation.addEventListener("cancel", () => { + if (activityAnimations.get(article) === animation) activityAnimations.delete(article); + }, { once: true }); + } + + function animateCommandRow(commandRow, expanded, options = {}) { + if (!commandRow) return; + const previous = commandAnimations.get(commandRow); + if (previous) { + try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } + previous.cancel(); + } + commandAnimations.delete(commandRow); + const next = Boolean(expanded); + const duration = motionDuration(options.duration ?? 190); + const fromHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0); + commandRow.dataset.expanded = String(next); + commandRow.setAttribute("aria-expanded", String(next)); + commandRow.style.overflow = "hidden"; + commandRow.style.height = "auto"; + const targetHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0); + if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) { + commandRow.style.height = ""; + commandRow.style.overflow = ""; + return; + } + if (typeof commandRow.animate !== "function") { + let transition; + transition = runMeasuredCssTransition( + commandRow, + { height: `${fromHeight}px` }, + { height: `${targetHeight}px` }, + duration, + () => { + if (commandAnimations.get(commandRow) !== transition) return; + commandAnimations.delete(commandRow); + commandRow.style.height = ""; + commandRow.style.overflow = ""; + }, + ); + if (transition) commandAnimations.set(commandRow, transition); + return; + } + commandRow.style.height = `${fromHeight}px`; + const animation = commandRow.animate([ + { height: `${fromHeight}px` }, + { height: `${targetHeight}px` }, + ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); + commandAnimations.set(commandRow, animation); + animation.addEventListener("finish", () => { + if (commandAnimations.get(commandRow) !== animation) return; + commandAnimations.delete(commandRow); + commandRow.style.height = ""; + commandRow.style.overflow = ""; + animation.cancel(); + }, { once: true }); + animation.addEventListener("cancel", () => { + if (commandAnimations.get(commandRow) === animation) commandAnimations.delete(commandRow); + }, { once: true }); + } + + function updateScrollToBottom(output = $("output")) { + const button = $("scrollToBottom"); + if (!button || !output) return; + const distance = Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight); + state.outputDistanceFromBottom = distance; + const visible = distance > 24; + const working = state.turnStartedAt !== null || state.turnStatus === "active" || state.turnStatus === "waiting"; + button.dataset.visible = String(visible); + button.dataset.working = String(working); + button.setAttribute("aria-label", t(working ? "正在工作,回到最新消息" : "回到最新消息")); + button.setAttribute("aria-hidden", String(!visible)); + button.tabIndex = visible ? 0 : -1; + } + + function scrollOutput(output, force = false) { + if (!output) return; + if (force || shouldFollowOutput(output)) { + if (typeof output.scrollTo === "function") output.scrollTo({ top: output.scrollHeight, behavior: "auto" }); + else output.scrollTop = output.scrollHeight; + } + updateScrollToBottom(output); + } + + function updateScrollPadding() { + const output = $("output"); + const panel = document.querySelector(".chat-panel"); + const composer = $("messageForm"); + if (!output || !panel || !composer) return; + const outputRect = output.getBoundingClientRect(); + const composerRect = composer.getBoundingClientRect(); + // The composer is an overlay in the official panel. Reserve only the + // portion that actually covers the scroll viewport, plus a small gap. + const overlap = Math.max(0, Math.ceil(outputRect.bottom - composerRect.top)); + const reserve = Math.max(72, overlap + 16); + output.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`); + panel.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`); + updateScrollToBottom(output); + } + + function timelineVisibleBounds(output) { + if (!output) return null; + const outputRect = output.getBoundingClientRect(); + const composer = $("messageForm"); + const composerRect = composer?.getBoundingClientRect?.(); + const top = outputRect.top + 8; + const composerTop = composerRect && Number.isFinite(composerRect.top) ? composerRect.top - 10 : outputRect.bottom - 8; + const bottom = Math.min(outputRect.bottom - 8, composerTop); + return { top, bottom: Math.max(top, bottom) }; + } + + // Keep an expanding activity row inside the portion of the transcript that + // is actually readable above the composer. This runs across the measured + // height animation because one layout pass is not enough when streamed + // command output arrives at the same time. + function ensureTimelineVisible(target) { + const element = target?.nodeType === 1 ? target : null; + const output = element?.closest?.(".chat-scroll") || $("output"); + if (!element || !output || !element.isConnected || !output.isConnected) return; + const bounds = timelineVisibleBounds(output); + if (!bounds) return; + const rect = element.getBoundingClientRect(); + let delta = 0; + if (rect.top < bounds.top) delta = rect.top - bounds.top; + else if (rect.bottom > bounds.bottom) delta = rect.bottom - bounds.bottom; + if (!Number.isFinite(delta) || Math.abs(delta) < 0.25) return; + const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight); + output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta)); + updateScrollToBottom(output); + } + + function scheduleTimelineReveal(target, duration = 220) { + const element = target?.nodeType === 1 ? target : null; + if (!element) return; + if (typeof state.timelineRevealCancel === "function") state.timelineRevealCancel(); + const deadline = motionClock() + motionDuration(duration) + 90; + let frame = null; + let timer = null; + let disposed = false; + const tick = () => { + if (disposed) return; + frame = null; + ensureTimelineVisible(element); + if (motionClock() < deadline) frame = scheduleFrame(tick); + }; + const cancel = () => { + if (disposed) return; + disposed = true; + cancelFrame(frame); + frame = null; + if (timer !== null) clearTimeout(timer); + timer = null; + if (state.timelineRevealCancel === cancel) state.timelineRevealCancel = null; + }; + state.timelineRevealCancel = cancel; + tick(); + timer = setTimeout(cancel, Math.max(180, motionDuration(duration) + 130)); + } + + function comparableText(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); + } + + function hasRenderedMessage(text, turnId, role) { + const target = comparableText(text); + if (!target) return false; + const output = $("output"); + if (!output) return false; + return [...output.querySelectorAll(`.message.${role}`)].some((article) => { + if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false; + return comparableText(article.dataset.rawText) === target; + }); + } + + function hasRenderedCompletedMessage(text, turnId, role) { + const target = comparableText(text); + if (!target) return false; + const output = $("output"); + if (!output) return false; + return [...output.querySelectorAll(`.message.${role}`)].some((article) => { + if (article.classList.contains("streaming")) return false; + if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false; + const raw = comparableText(article.dataset.rawText); + return raw === target || raw.includes(target) || target.includes(raw); + }); + } + + function appendInlineMarkdown(parent, source) { + const pattern = /(\[[^\]]+\]\(https?:\/\/[^)\s]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|~~[^~\n]+~~|\*[^*\n]+\*|_[^_\n]+_)/g; + let cursor = 0; + const appendText = (value) => { + const parts = String(value).split("\n"); + parts.forEach((part, index) => { + if (part) parent.append(document.createTextNode(part)); + if (index < parts.length - 1) parent.append(document.createElement("br")); + }); + }; + for (const match of String(source).matchAll(pattern)) { + if (match.index > cursor) appendText(String(source).slice(cursor, match.index)); + const token = match[0]; + if (token.startsWith("[") && token.endsWith(")")) { + const split = token.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/); + if (split) { + const link = document.createElement("a"); + link.href = split[2]; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = split[1]; + parent.append(link); + } else appendText(token); + } else if (token.startsWith("`") && token.endsWith("`")) { + const code = document.createElement("code"); + code.textContent = token.slice(1, -1); + parent.append(code); + } else if (token.startsWith("**") || token.startsWith("__")) { + const strong = document.createElement("strong"); + strong.textContent = token.slice(2, -2); + parent.append(strong); + } else if (token.startsWith("~~")) { + const deleted = document.createElement("del"); + deleted.textContent = token.slice(2, -2); + parent.append(deleted); + } else if (token.startsWith("*") || token.startsWith("_")) { + const emphasis = document.createElement("em"); + emphasis.textContent = token.slice(1, -1); + parent.append(emphasis); + } else appendText(token); + cursor = match.index + token.length; + } + if (cursor < String(source).length) appendText(String(source).slice(cursor)); + } + + function tableCells(line) { + let value = String(line || "").trim(); + if (value.startsWith("|")) value = value.slice(1); + if (value.endsWith("|")) value = value.slice(0, -1); + return value.split("|").map((cell) => cell.trim()); + } + + function isTableDivider(line) { + const cells = tableCells(line); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); + } + + function appendTable(container, headerLine, bodyLines) { + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const header = document.createElement("tr"); + for (const cell of tableCells(headerLine)) { + const th = document.createElement("th"); + appendInlineMarkdown(th, cell); + header.append(th); + } + thead.append(header); + table.append(thead); + const tbody = document.createElement("tbody"); + for (const line of bodyLines) { + const row = document.createElement("tr"); + for (const cell of tableCells(line)) { + const td = document.createElement("td"); + appendInlineMarkdown(td, cell); + row.append(td); + } + tbody.append(row); + } + table.append(tbody); + container.append(table); + } + + /** + * Render the small, safe Markdown subset used by Codex messages. The + * official webview uses a full Markdown/ProseMirror pipeline; the relay + * intentionally keeps this browser-side renderer dependency-free and never + * assigns untrusted text to innerHTML. + */ + function renderMarkdown(container, source) { + container.replaceChildren(); + const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n"); + let index = 0; + const addParagraph = (paragraph) => { + if (!paragraph.length) return; + const element = document.createElement("p"); + appendInlineMarkdown(element, paragraph.join("\n")); + container.append(element); + }; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { index += 1; continue; } + const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/); + if (fence) { + index += 1; + const codeLines = []; + while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) codeLines.push(lines[index++]); + if (index < lines.length) index += 1; + const pre = document.createElement("pre"); + const code = document.createElement("code"); + if (fence[1]) code.dataset.language = fence[1]; + code.textContent = codeLines.join("\n"); + pre.append(code); + container.append(pre); + continue; + } + if (index + 1 < lines.length && line.includes("|") && isTableDivider(lines[index + 1])) { + const body = []; + index += 2; + while (index < lines.length && lines[index].trim() && lines[index].includes("|")) body.push(lines[index++]); + appendTable(container, line, body); + continue; + } + if (/^\s*(?:---+|___+|\*\*\*+)\s*$/.test(line)) { + container.append(document.createElement("hr")); + index += 1; + continue; + } + const heading = line.match(/^\s*(#{1,3})\s+(.+?)\s*#*$/); + if (heading) { + const element = document.createElement(`h${heading[1].length}`); + appendInlineMarkdown(element, heading[2]); + container.append(element); + index += 1; + continue; + } + if (/^\s*>\s?/.test(line)) { + const quote = document.createElement("blockquote"); + while (index < lines.length && /^\s*>\s?/.test(lines[index])) { + const paragraph = document.createElement("p"); + appendInlineMarkdown(paragraph, lines[index].replace(/^\s*>\s?/, "")); + quote.append(paragraph); + index += 1; + } + container.append(quote); + continue; + } + const list = line.match(/^\s*([-*+]|\d+[.)])\s+(.+)$/); + if (list) { + const ordered = /^\d/.test(list[1]); + const listElement = document.createElement(ordered ? "ol" : "ul"); + while (index < lines.length) { + const item = lines[index].match(/^\s*([-*+]|\d+[.)])\s+(.+)$/); + if (!item || /^\d/.test(item[1]) !== ordered) break; + const li = document.createElement("li"); + const task = item[2].match(/^\[([ xX])\]\s+(.+)$/); + if (task) { + li.className = "task-list-item"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = task[1].toLowerCase() === "x"; + checkbox.disabled = true; + checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成")); + li.append(checkbox); + appendInlineMarkdown(li, task[2]); + } else appendInlineMarkdown(li, item[2]); + listElement.append(li); + index += 1; + } + container.append(listElement); + continue; + } + const paragraph = [line]; + index += 1; + while (index < lines.length + && lines[index].trim() + && !/^\s*```/.test(lines[index]) + && !/^\s*(#{1,3})\s+/.test(lines[index]) + && !/^\s*>\s?/.test(lines[index]) + && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[index])) { + paragraph.push(lines[index++]); + } + addParagraph(paragraph); + } + } + + function renderMessageBody(body, text, role, tone, kind) { + const value = String(text ?? ""); + body.dataset.rawText = value; + // User-authored prompts use the same safe Markdown subset as assistant + // messages. Tool/status rows stay literal so command output cannot be + // mistaken for formatted content. + const markdownRole = role === "assistant" || role === "user" + || kind === "reasoning" || kind === "plan" || kind === "subagent" || kind === "commentary"; + body.classList.toggle("markdown-body", markdownRole && tone !== "meta" && tone !== "error" && kind !== "tool"); + body.classList.toggle("diff-body", kind === "edit"); + if (kind === "edit") { + body.replaceChildren(); + const output = document.createElement("pre"); + output.className = "diff-output"; + for (const line of value.replace(/\r\n?/g, "\n").split("\n")) { + const row = document.createElement("span"); + row.className = line.startsWith("+") && !line.startsWith("+++") + ? "diff-line added" + : line.startsWith("-") && !line.startsWith("---") + ? "diff-line removed" + : line.startsWith("@@") || line.startsWith("diff ") || line.startsWith("[") + ? "diff-line context" + : "diff-line"; + row.textContent = line || " "; + output.append(row); + } + body.append(output); + } else if (kind === "tool" || kind === "read") body.textContent = value; + else if (markdownRole && tone !== "meta" && tone !== "error") renderMarkdown(body, value); + else body.textContent = value; + } + + function createTerminalIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.className.baseVal = "activity-summary-icon terminal-icon"; + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + const frame = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + frame.setAttribute("x", "2.25"); + frame.setAttribute("y", "2.75"); + frame.setAttribute("width", "11.5"); + frame.setAttribute("height", "10.5"); + frame.setAttribute("rx", "1.4"); + const prompt = document.createElementNS("http://www.w3.org/2000/svg", "path"); + prompt.setAttribute("d", "m4.5 6 2 2-2 2"); + const cursor = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cursor.setAttribute("d", "M8.5 10h2.5"); + svg.append(frame, prompt, cursor); + return svg; + } + + function createSubagentIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.className.baseVal = "activity-summary-icon subagent-summary-icon"; + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + // The installed Codex webview uses the filled blossom mark for both + // sub-agent activity rows and the composer agent rail. Keep the path + // inline so the standalone relay page does not depend on VS Code's URI + // resolver or on an extension-owned asset path. + const mark = document.createElementNS("http://www.w3.org/2000/svg", "path"); + mark.setAttribute("fill", "currentColor"); + mark.setAttribute("d", "M13.795 23.856q-1.188 0-2.256-.448a6.1 6.1 0 0 1-1.9-1.247 5.8 5.8 0 0 1-1.875.306 5.8 5.8 0 0 1-2.944-.777 6.1 6.1 0 0 1-2.184-2.12q-.807-1.34-.808-2.99 0-.682.19-1.482a6.3 6.3 0 0 1-1.472-2.002 5.76 5.76 0 0 1 .024-4.85q.546-1.177 1.52-2.024a5.5 5.5 0 0 1 2.303-1.2A5.55 5.55 0 0 1 5.485 2.62 6.06 6.06 0 0 1 7.575.925 5.85 5.85 0 0 1 10.21.313q1.187 0 2.255.447a6.1 6.1 0 0 1 1.9 1.248 5.8 5.8 0 0 1 1.875-.306q1.59 0 2.944.776a5.9 5.9 0 0 1 2.16 2.12q.832 1.34.832 2.99 0 .682-.19 1.483a6.2 6.2 0 0 1 1.472 2.024q.522 1.13.522 2.378 0 1.272-.546 2.449a6.1 6.1 0 0 1-1.543 2.048 5.45 5.45 0 0 1-2.28 1.177 5.4 5.4 0 0 1-1.115 2.402 5.8 5.8 0 0 1-2.066 1.695 5.85 5.85 0 0 1-2.635.612M7.93 20.913q1.188 0 2.066-.495l4.463-2.542a.52.52 0 0 0 .238-.448v-2.024L8.95 18.676a.97.97 0 0 1-1.044 0L3.419 16.11a.7.7 0 0 1-.024.165v.282q0 1.201.57 2.213.594.99 1.639 1.554 1.044.59 2.326.589m.238-3.838q.143.07.26.07a.46.46 0 0 0 .238-.07l1.781-1.012-5.722-3.296q-.522-.306-.522-.918v-5.11a4.27 4.27 0 0 0-1.9 1.602 4.13 4.13 0 0 0-.712 2.354q0 1.155.594 2.213.593 1.06 1.543 1.601zm5.627 5.227q1.258 0 2.279-.565a4.25 4.25 0 0 0 1.614-1.554q.594-.99.594-2.213v-5.085q0-.283-.237-.424l-1.805-1.036v6.568q0 .613-.522.919l-4.487 2.566q1.163.825 2.564.824m.902-8.617v-3.202l-2.683-1.507-2.707 1.507v3.202l2.707 1.507zm-6.933-7.51q0-.612.522-.918l4.488-2.567a4.34 4.34 0 0 0-2.564-.824q-1.26 0-2.28.565a4.25 4.25 0 0 0-1.614 1.554q-.57.99-.57 2.213v5.062q0 .283.237.447l1.781 1.036zm12.061 11.253a4.13 4.13 0 0 0 1.876-1.6 4.2 4.2 0 0 0 .712-2.355q0-1.154-.593-2.213-.594-1.06-1.544-1.6l-4.44-2.543q-.142-.095-.26-.071a.46.46 0 0 0-.238.07l-1.78.99 5.745 3.319q.26.141.38.377a.9.9 0 0 1 .142.518zm-4.772-11.96q.522-.33 1.045 0l4.51 2.614v-.424q0-1.13-.57-2.142a4.1 4.1 0 0 0-1.59-1.648q-1.02-.613-2.374-.613-1.187 0-2.066.495L9.545 6.292a.52.52 0 0 0-.238.448v2.025z"); + svg.append(mark); + return svg; + } + + function createReadIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.className.baseVal = "activity-summary-icon read-summary-icon"; + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M2.5 4.5h4l1.2 1.4h5.8v6.2c0 .8-.5 1.4-1.3 1.4H3.8c-.8 0-1.3-.6-1.3-1.4V4.5Z"); + const top = document.createElementNS("http://www.w3.org/2000/svg", "path"); + top.setAttribute("d", "M2.5 6h11"); + svg.append(path, top); + return svg; + } + + // The official sub-agent activity renderer keeps the agent name in a + // bordered chip and places the lifecycle text beside it. Keeping those two + // nodes separate also lets a running row update only its status without + // replacing the blossom icon or the accessible label. + function setSubagentSummary(summary, name, statusText = "") { + if (!summary) return; + let chip = summary.querySelector(".subagent-summary-chip"); + let label = chip?.querySelector(".subagent-summary-label"); + let status = summary.querySelector(".subagent-summary-status"); + if (!chip || !label || !status) { + chip = document.createElement("span"); + chip.className = "subagent-summary-chip"; + const icon = createSubagentIcon(); + label = document.createElement("span"); + label.className = "subagent-summary-label"; + chip.append(icon, label); + status = document.createElement("span"); + status.className = "subagent-summary-status"; + summary.replaceChildren(chip, status); + } + label.textContent = String(name || t("子代理")); + status.textContent = statusText ? t(statusText) : ""; + summary.setAttribute("aria-label", [label.textContent, status.textContent].filter(Boolean).join(" ")); + } + + function setActivitySummary(summary, text, kind = "") { + if (!summary) return; + if (kind === "subagent") { + const value = String(text || ""); + const match = value.match(/^(.*?)(?:\s+(已开始工作|已完成|失败|已中断|等待中|处理中|Started working|Completed|Failed|Interrupted|Waiting|Working))$/); + setSubagentSummary(summary, match ? match[1] : value || t("子代理"), match ? t(match[2]) : ""); + return; + } + if (kind !== "tool" && kind !== "read" && kind !== "subagent") { + summary.textContent = String(text || ""); + return; + } + let icon = summary.querySelector(".activity-summary-icon"); + let label = summary.querySelector(".activity-summary-label"); + if (!icon || !label) { + icon = kind === "subagent" ? createSubagentIcon() : kind === "read" ? createReadIcon() : createTerminalIcon(); + label = document.createElement("span"); + label.className = "activity-summary-label"; + summary.replaceChildren(icon, label); + } + label.textContent = String(text || ""); + } + + function stripShellQuotes(value) { + let text = String(value || "").trim(); + let changed = true; + while (changed) { + changed = false; + if (text.startsWith("$'") && text.endsWith("'")) { + text = text.slice(2, -1).replace(/\\'/g, "'"); + changed = true; + } else if ((text.startsWith("'") && text.endsWith("'")) + || (text.startsWith('"') && text.endsWith('"'))) { + const quoted = text.startsWith('"'); + text = text.slice(1, -1); + if (quoted) text = text.replace(/\\"/g, '"'); + changed = true; + } + } + return text.trim(); + } + + // The official command renderer hides the shell bootstrap used by the IPC + // runner (`/bin/zsh -lc '…'`) and shows the actual user command instead. + function terminalCommandText(value) { + const command = stripShellQuotes(value); + const match = command.match(/^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)\s+-lc\s+([\s\S]+)$/i); + return match ? stripShellQuotes(match[1]) : command; + } + + function addMessageActions(article, enabled = true) { + // Tool/reasoning rows have their own disclosure controls and should not + // grow a second action rail. Editing an earlier turn is intentionally not + // exposed until the relay can perform the official branch/edit operation. + if (!enabled || (!article.classList.contains("user") && !article.classList.contains("assistant"))) return null; + if (article.classList.contains("assistant") && article.classList.contains("streaming")) return null; + const actions = document.createElement("div"); + actions.className = "message-actions"; + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "message-action"; + copy.title = t("复制消息"); + copy.setAttribute("aria-label", t("复制消息")); + const setCopyIcon = (copied = false) => { + copy.replaceChildren(); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + if (copied) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); + svg.append(path); + } else { + const back = document.createElementNS("http://www.w3.org/2000/svg", "path"); + back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5"); + const front = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + front.setAttribute("x", "2.5"); + front.setAttribute("y", "5.5"); + front.setAttribute("width", "7.5"); + front.setAttribute("height", "7.5"); + front.setAttribute("rx", "1.2"); + svg.append(back, front); + } + copy.append(svg); + }; + setCopyIcon(); + copy.addEventListener("click", async () => { + const value = article.dataset.rawText || ""; + try { + await navigator.clipboard?.writeText(value); + setCopyIcon(true); + setTimeout(() => { setCopyIcon(false); }, 1_200); + } catch { + copy.textContent = "!"; + } + }); + actions.append(copy); + article.append(actions); + return actions; + } + + function appendMessage(text, role = "assistant", tone = "text", meta = "", options = {}) { + if (text === undefined || text === null) return null; + const output = $("output"); + const follow = shouldFollowOutput(output); + const article = document.createElement("article"); + article.className = `message ${role}${tone && tone !== "text" ? ` ${tone}` : ""}`; + if (options.kind) article.dataset.kind = options.kind; + if (options.status) article.dataset.status = String(options.status); + if (options.turnId) article.dataset.turnId = String(options.turnId); + if (options.structuredKey) article.dataset.structuredKey = String(options.structuredKey); + if (options.activityKey) article.dataset.activityKey = String(options.activityKey); + if (options.itemId !== undefined && options.itemId !== null) article.dataset.itemId = String(options.itemId); + if (options.itemType) article.dataset.itemType = String(options.itemType); + if (options.agentThreadId) article.dataset.agentThreadId = String(options.agentThreadId); + if (options.timestamp) { + const parsedTimestamp = timestampMs(options.timestamp); + if (parsedTimestamp !== null) article.dataset.timestamp = String(parsedTimestamp); + } + article.dataset.rawText = String(text); + const content = document.createElement("div"); + content.className = "message-content"; + let body = document.createElement("div"); + body.className = "message-body"; + let details = null; + const initiallyOpen = options.open !== false; + if (options.collapsible) { + details = document.createElement("details"); + details.className = `message-details ${options.kind || ""}`; + const summary = document.createElement("summary"); + const summaryText = options.summary || options.label || t("详情"); + if (options.kind === "tool" || options.kind === "read" || options.kind === "subagent") { + setActivitySummary(summary, summaryText, options.kind); + if (options.command) summary.title = terminalCommandText(options.command); + } else { + summary.textContent = summaryText; + if (options.command && !options.summary) { + // Translate only the renderer-owned label. The command is host data + // and must remain byte-for-byte unchanged in every locale. + summary.textContent = `${t(options.label || "命令")} · ${String(options.command)}`; + summary.title = String(options.command); + } + } + body.classList.add("details-body"); + details.append(summary, body); + content.append(details); + } else content.append(body); + renderMessageBody(body, text, role, tone, options.kind); + article.append(content); + const actions = addMessageActions(article, options.showActions !== false); + const metaParts = []; + if (meta) metaParts.push(String(meta)); + if (options.showTimestamp === true && options.timestamp) { + const timestamp = formatMessageTime(options.timestamp); + if (timestamp) metaParts.push(timestamp); + } + if (options.showDuration === true && options.durationMs !== undefined && options.durationMs !== null) { + const duration = formatDuration(options.durationMs); + if (duration) metaParts.push(uiWithRaw("用时 ", "Worked for ", duration)); + } + if (metaParts.length) { + const stamp = document.createElement("div"); + stamp.className = "message-meta"; + stamp.textContent = metaParts.join(" · "); + if (actions) actions.prepend(stamp); + else content.append(stamp); + } + output.append(article); + if (details) installDisclosure(details, initiallyOpen); + if (follow) scrollOutput(output, true); + return { article, content: body, wrapper: content }; + } + + function appendDateSeparator(timestamp, options = {}) { + const parsedTimestamp = timestampMs(timestamp); + const role = String(options.role || "user"); + // The official timestamp projection treats an item without a usable time + // as an adjacency break. Do not carry the previous role/time across an + // untimestamped item, otherwise a later assistant message can inherit an + // unrelated 10-minute/1-hour gap. + if (parsedTimestamp === null) { + state.lastRenderedDateKey = ""; + state.lastRenderedTimestamp = null; + state.lastRenderedRole = ""; + if (role === "user") state.hasRenderedUser = true; + return; + } + const previousTimestamp = state.lastRenderedTimestamp; + const previousRole = state.lastRenderedRole; + const hour = 60 * 60 * 1000; + const tenMinutes = 10 * 60 * 1000; + const gap = previousTimestamp === null ? null : parsedTimestamp - previousTimestamp; + // This mirrors the official timestamps projection: a first/next user turn + // is separated only after a substantial pause, while consecutive assistant + // entries can be separated after a shorter gap. + const threshold = previousRole === "assistant" + ? role === "user" ? hour : tenMinutes + : Infinity; + const firstUserIsOld = !state.hasRenderedUser && role === "user" + && Date.now() - parsedTimestamp > hour; + // The official local composer always gives the first user message a + // centered date/time anchor, even when that message was sent today. It is + // also the visual boundary that separates a freshly attached history from + // the input composer, so do not hide it merely because the turn is recent. + const firstUserTurn = !state.hasRenderedUser && role === "user"; + const show = options.force === true || options.breaksPreviousAdjacency === true || firstUserIsOld + || firstUserTurn + || (gap !== null && gap > 0 && previousRole === "assistant" && gap > threshold); + if (show && state.lastDateSeparatorTimestamp !== parsedTimestamp) { + const label = formatMessageDate(parsedTimestamp); + if (label) { + const separator = document.createElement("div"); + separator.className = "date-separator"; + separator.setAttribute("role", "separator"); + separator.setAttribute("aria-label", label); + const time = document.createElement("time"); + time.dateTime = new Date(parsedTimestamp).toISOString(); + const splitAt = label.lastIndexOf(" "); + if (splitAt > 0 && splitAt < label.length - 1) { + const dateLabel = document.createElement("span"); + dateLabel.className = "date-label"; + dateLabel.textContent = label.slice(0, splitAt); + const timeLabel = document.createElement("span"); + timeLabel.className = "date-time"; + timeLabel.textContent = label.slice(splitAt + 1); + time.append(dateLabel, " ", timeLabel); + } else time.textContent = label; + separator.append(time); + $("output").append(separator); + state.lastDateSeparatorTimestamp = parsedTimestamp; + } + } + state.lastRenderedDateKey = messageDateKey(parsedTimestamp); + state.lastRenderedTimestamp = parsedTimestamp; + state.lastRenderedRole = role; + if (role === "user") state.hasRenderedUser = true; + } + + function turnDividerLabel(status, durationMs) { + const duration = elapsedDuration(durationMs); + const normalized = normalizeActivityStatus(status, "completed"); + if (normalized === "interrupted") return duration + ? uiWithRaw("你在 ", "You stopped after ", duration, " 后停止了", "") + : uiText("你停止了工作", "You stopped working"); + if (normalized === "failed") return duration + ? uiWithRaw("执行失败 · ", "Action failed · ", duration) + : uiText("执行失败", "Action failed"); + if (normalized === "inProgress") { + const visible = elapsedDuration(durationMs); + return visible ? uiWithRaw("用时 ", "Worked for ", visible) : uiText("正在处理", "Working"); + } + return duration ? uiWithRaw("用时 ", "Worked for ", duration) : uiText("已完成", "Completed"); + } + + // A worked-for disclosure only has meaning when the turn owns at least one + // concrete activity row. Keep this cleanup centralized so a transient + // status-only row cannot leave an empty divider behind after it is retired. + function removeEmptyTurnDivider(turnId) { + const key = String(turnId || ""); + if (!key) return; + const output = $("output"); + if (!output) return; + const hasActivity = [...output.querySelectorAll(".message.activity")] + .some((entry) => entry.dataset.turnId === key); + if (hasActivity) return; + const divider = state.turnDividers.get(key) + || [...output.querySelectorAll(".turn-divider")] + .find((entry) => entry.dataset.turnId === key) + || null; + if (divider?.parentNode) divider.remove(); + state.turnDividers.delete(key); + } + + function retireActivity(activity) { + if (!activity) return; + const key = String(activity.key || ""); + const turnId = String(activity.turnId || ""); + if (key && state.activities.get(key) === activity) state.activities.delete(key); + if (key) state.commandDisclosure.delete(key); + if (state.liveActivityKey === key) state.liveActivityKey = null; + if (state.activeAssistantActivityKey === key) { + state.activeAssistantBody = null; + state.activeAssistantStream = null; + state.activeAssistantText = ""; + state.activeAssistantActivityKey = null; + } + if (activity.article?.parentNode) activity.article.remove(); + removeEmptyTurnDivider(turnId); + stopActivityTimerIfIdle(); + updateScrollToBottom($("output")); + } + + function retireStatusOnlyActivities(turnId = "") { + const key = String(turnId || ""); + for (const activity of [...state.activities.values()]) { + if (activity.statusOnly !== true || activity.concrete === true) continue; + if (key && activity.turnId && activity.turnId !== key) continue; + retireActivity(activity); + } + } + + function appendTurnDivider(turnId, status = "completed", durationMs, beforeArticle = null, options = {}) { + const key = String(turnId || `anonymous-${state.activitySequence}`); + const output = $("output"); + const autoPosition = beforeArticle === null; + const turnEntries = [...output.querySelectorAll(".message")] + .filter((entry) => entry.dataset.turnId === key); + // The official worked-for disclosure owns the activity portion of a turn. + // A final assistant answer remains visible below the disclosure; commentary + // and concrete work rows are the entries that collapse underneath it. + const firstTurnActivity = turnEntries.find((entry) => entry.classList.contains("activity")) || null; + // Do not manufacture an empty worked-for row for a final-only turn. This + // can happen when completion metadata arrives before any item lifecycle + // event, or when a transient status row has just been retired. + if (!firstTurnActivity) { + removeEmptyTurnDivider(key); + return null; + } + const firstTurnContent = firstTurnActivity + || turnEntries.find((entry) => !entry.classList.contains("user")) + || null; + if (firstTurnContent) beforeArticle = firstTurnContent; + else if (autoPosition) beforeArticle = turnEntries.at(-1) || null; + let divider = state.turnDividers.get(key); + if (!divider) { + divider = [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) || null; + } + if (!divider) { + divider = document.createElement("div"); + divider.className = "turn-divider"; + divider.dataset.turnId = key; + const button = document.createElement("button"); + button.type = "button"; + button.className = "turn-divider-toggle"; + const initialStatus = normalizeActivityStatus(status, "completed"); + const rememberedExpansion = state.turnExpansion.get(key); + const initialExpanded = rememberedExpansion !== undefined + ? rememberedExpansion + : options.defaultExpanded === true || initialStatus === "inProgress"; + if (rememberedExpansion !== undefined || options.defaultExpanded === true) { + button.dataset.userToggled = rememberedExpansion !== undefined ? "true" : "false"; + } + button.setAttribute("aria-expanded", String(initialExpanded)); + const label = document.createElement("span"); + label.className = "turn-divider-label"; + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 16 16"); + icon.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "m6 3 5 5-5 5"); + icon.append(path); + button.append(label, icon); + const rule = document.createElement("div"); + rule.className = "turn-divider-rule"; + divider.append(button, rule); + button.addEventListener("click", () => { + const expanded = button.getAttribute("aria-expanded") === "true"; + const next = !expanded; + // Hydration may choose an expanded latest turn for parity with the + // official panel. Once the reader explicitly toggles it, preserve + // that choice across subsequent structured snapshots. + button.dataset.userToggled = "true"; + state.turnExpansion.set(key, next); + button.setAttribute("aria-expanded", String(next)); + setTurnActivityVisibility(key, next); + if (next) { + const firstVisibleEntry = [...output.querySelectorAll(".message.activity")] + .filter((entry) => entry.dataset.turnId === key) + .find((entry) => !entry.classList.contains("turn-collapsed")); + if (firstVisibleEntry) scheduleTimelineReveal(firstVisibleEntry, 280); + } + }); + if (beforeArticle && beforeArticle.parentNode === output) output.insertBefore(divider, beforeArticle); + else output.append(divider); + state.turnDividers.set(key, divider); + } else if (beforeArticle === false && divider.parentNode === output && output.lastElementChild !== divider) { + // An additional activity can arrive after an already-created live + // divider. Explicit `false` means place the divider at the current tail. + output.append(divider); + } else if (beforeArticle && beforeArticle.parentNode === output && divider !== beforeArticle) { + // Completion can arrive after the final assistant article. Move the + // existing disclosure instead of creating a second one at the tail. + output.insertBefore(divider, beforeArticle); + } + const label = divider.querySelector(".turn-divider-label"); + if (durationMs !== null && durationMs !== undefined && finiteNumber(durationMs) !== null) { + divider.dataset.durationMs = String(Math.max(0, Number(durationMs))); + } + const storedDuration = finiteNumber(durationMs, divider.dataset.durationMs); + const liveDuration = durationMs === null || durationMs === undefined + ? key === state.turnId && state.turnWorkStartedAt !== null + ? Math.max(0, Date.now() - state.turnWorkStartedAt) + : storedDuration + : storedDuration; + if (label) label.textContent = turnDividerLabel(status, liveDuration); + const normalizedStatus = normalizeActivityStatus(status, "completed"); + divider.dataset.status = normalizedStatus; + const toggle = divider.querySelector(".turn-divider-toggle"); + if (options.defaultExpanded === true && toggle?.dataset.userToggled !== "true") { + toggle.dataset.defaultExpanded = "true"; + } + if (normalizedStatus === "inProgress" && toggle?.dataset.userToggled !== "true") { + toggle.setAttribute("aria-expanded", "true"); + } + const rememberedExpansion = state.turnExpansion.get(key); + // The latest completed turn is expanded on first render in the official + // panel. A remembered click always wins, including an explicit collapse. + const expanded = rememberedExpansion !== undefined + ? rememberedExpansion + : toggle?.dataset.userToggled === "true" + ? toggle.getAttribute("aria-expanded") === "true" + : normalizedStatus === "inProgress" || toggle?.dataset.defaultExpanded === "true"; + setTurnActivityVisibility(key, expanded); + return divider; + } + + function appendCompletedTurnDivider(turnId, status = "completed", durationMs) { + const key = String(turnId || ""); + if (!key) return null; + const output = $("output"); + if (!output) return null; + const hasTurnMessage = [...output.querySelectorAll(".message")] + .some((article) => article.dataset.turnId === key); + if (!hasTurnMessage) return null; + const hasTurnActivity = [...output.querySelectorAll(".message.activity")] + .some((article) => article.dataset.turnId === key); + // A final assistant article by itself is not a worked-for group. The + // official UI shows its duration in the assistant block without an empty + // disclosure row above it. + if (!hasTurnActivity) return null; + const firstContent = [...output.querySelectorAll(".message")] + .find((entry) => entry.dataset.turnId === key && entry.classList.contains("activity")) + || [...output.querySelectorAll(".message")] + .find((entry) => entry.dataset.turnId === key && !entry.classList.contains("user")) + || null; + return appendTurnDivider(key, status, durationMs, firstContent); + } + + // Structured snapshots can append bookkeeping or collaboration items after + // the final assistant item. Reconcile the outer worked-for boundary after + // hydration so every activity row remains in the same disclosure group. + function reconcileTurnDividers() { + const output = $("output"); + if (!output) return; + const turnIds = new Set( + [...output.querySelectorAll(".message.activity")] + .map((article) => article.dataset.turnId) + .filter(Boolean), + ); + for (const key of turnIds) { + const activities = [...output.querySelectorAll(".message.activity")] + .filter((article) => article.dataset.turnId === key); + if (!activities.length) continue; + let divider = state.turnDividers.get(key) + || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) + || null; + const finalAssistant = [...output.querySelectorAll(".message")] + .filter((article) => article.dataset.turnId === key + && !article.classList.contains("activity") + && article.dataset.kind === "assistant") + .at(-1) + || null; + const activeTurn = key === state.turnId + && state.turnStartedAt !== null + && ["active", "waiting"].includes(state.turnStatus); + if (!divider) { + divider = appendTurnDivider(key, activeTurn ? "inProgress" : "completed", state.workedDurationMs, activities[0]); + } else { + if (activities[0].parentNode === output && divider.parentNode === output && divider !== activities[0]) { + output.insertBefore(divider, activities[0]); + } + const toggle = divider.querySelector(".turn-divider-toggle"); + if (toggle && toggle.dataset.userToggled !== "true") { + const rememberedExpansion = state.turnExpansion.get(key); + toggle.setAttribute("aria-expanded", String(rememberedExpansion !== undefined + ? rememberedExpansion + : activeTurn)); + } + const status = activeTurn ? "inProgress" : normalizeActivityStatus(divider.dataset.status, "completed"); + divider.dataset.status = status; + setTurnActivityVisibility(key, toggle?.getAttribute("aria-expanded") === "true"); + } + // Keep a divider before the activity group even when a final answer was + // rendered first. The answer itself intentionally stays outside it. + if (divider && activities[0].parentNode === output && divider.nextSibling !== activities[0]) { + output.insertBefore(divider, activities[0]); + } + if (finalAssistant && divider) divider.dataset.hasFinalAssistant = "true"; + if (finalAssistant) { + // Keep trailing bookkeeping/collaboration rows inside the same outer + // group. Moving only rows that currently follow the final response + // preserves the chronological order of the already-correct prefix. + const trailing = activities.filter((activity) => ( + activity.compareDocumentPosition(finalAssistant) & Node.DOCUMENT_POSITION_PRECEDING + )); + for (const activity of trailing) output.insertBefore(activity, finalAssistant); + // The moves above can carry the first activity past the existing + // disclosure. Re-anchor it after every move so the worked-for button + // always remains the first node in the activity group. + if (activities[0].parentNode === output && divider.parentNode === output) { + output.insertBefore(divider, activities[0]); + } + } + } + } + + function expandLatestTurnActivity() { + const output = $("output"); + if (!output) return; + const dividers = [...output.querySelectorAll(".turn-divider")]; + const divider = dividers.at(-1); + // The latest worked-for group is expanded after a completed snapshot so + // the reader can see the activity stream immediately. Older groups stay + // compact, and an explicit user toggle always wins. + if (!divider) return; + const toggle = divider.querySelector(".turn-divider-toggle"); + if (!toggle || toggle.dataset.userToggled === "true") return; + const key = divider.dataset.turnId || ""; + if (!key || ![...output.querySelectorAll(".message.activity")].some((entry) => entry.dataset.turnId === key)) return; + const rememberedExpansion = state.turnExpansion.get(key); + const shouldExpand = rememberedExpansion !== undefined + ? rememberedExpansion + : divider.dataset.status === "inProgress" || divider === dividers.at(-1); + if (!shouldExpand) return; + if (toggle.getAttribute("aria-expanded") === "true") return; + toggle.setAttribute("aria-expanded", "true"); + setTurnActivityVisibility(key, true); + } + + function setTurnActivityVisibility(turnId, expanded) { + const key = String(turnId || ""); + if (!key) return; + const output = $("output"); + if (!output) return; + const divider = state.turnDividers.get(key) + || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) + || null; + // Keep the final assistant response and its timestamp/actions mounted. + // Only rows explicitly marked as activity participate in the outer turn + // disclosure, matching the official conversation-blocks split. + const activities = [...output.querySelectorAll(".message.activity")] + .filter((entry) => entry.dataset.turnId === key); + if (!activities.length) return; + const knownState = activities.some((activity) => activity.dataset.turnExpanded !== undefined); + if (!knownState && !expanded) { + for (const activity of activities) { + activity.dataset.turnExpanded = "false"; + animateActivityArticle(activity, false, { immediate: true }); + } + return; + } + if (divider || activities[0]) preserveTimelineAnchor(divider || activities[0], 280); + for (const activity of activities) { + const previous = activity.dataset.turnExpanded === "true"; + activity.dataset.turnExpanded = String(Boolean(expanded)); + // The outer worked-for disclosure controls every non-user entry. Each + // nested command/reasoning disclosure keeps its own state, matching the + // official panel where opening a completed turn does not open every + // terminal body at once. + animateActivityArticle(activity, expanded, { + immediate: !knownState && !previous, + fromHeight: expanded && !previous ? 0 : undefined, + preserve: false, + }); + } + if (expanded) revealTerminalOutputs(key); + } + + function revealTerminalOutputs(turnId) { + const key = String(turnId || ""); + if (!key) return; + requestAnimationFrame(() => { + const output = $("output"); + if (!output) return; + for (const activity of output.querySelectorAll(".message.activity")) { + if (activity.dataset.turnId !== key) continue; + const terminal = activity.querySelector(".terminal-output"); + if (!terminal || terminal.scrollHeight <= terminal.clientHeight) continue; + // Hidden turn sections have no layout while hydrated. Once revealed, + // show the newest terminal lines just like the official reverse list. + terminal.scrollTop = terminal.scrollHeight; + updateTerminalOutputFade(terminal); + } + }); + } + + function ensureLiveTurnDivider(turnId) { + const key = String(turnId || ""); + if (!key) return null; + const output = $("output"); + const entries = [...output.querySelectorAll(".message")] + .filter((entry) => entry.dataset.turnId === key); + const firstContent = entries.find((entry) => entry.classList.contains("activity")) + || entries.find((entry) => !entry.classList.contains("user")) + || null; + if (firstContent) return appendTurnDivider(key, "inProgress", null, firstContent); + return appendTurnDivider(key, "inProgress", null, false); + } + + const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); + + function finiteNumber(...values) { + for (const value of values) { + if (value === null || value === undefined || value === "" || typeof value === "boolean") continue; + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return null; + } + + function timestampMs(...values) { + let value = null; + for (const candidate of values) { + if (candidate === null || candidate === undefined || candidate === "" || typeof candidate === "boolean") continue; + if (typeof candidate === "string" && candidate.trim() && !/^\d+(?:\.\d+)?$/.test(candidate.trim())) { + const parsed = Date.parse(candidate); + if (Number.isFinite(parsed)) { value = parsed; break; } + } + const numeric = Number(candidate); + if (Number.isFinite(numeric)) { value = numeric; break; } + } + if (value === null || value <= 0) return null; + // Accept ISO epoch seconds from older followers as well as the current + // app-server's millisecond fields. + return value < 100_000_000_000 ? value * 1_000 : value; + } + + function workedDurationFor(value, fallback = null) { + const source = isRecord(value) ? value : {}; + const nestedTurn = isRecord(source.turn) ? source.turn : {}; + const nestedStatus = isRecord(source.status) ? source.status : {}; + const explicit = finiteNumber( + source.workedDurationMs, + source.workDurationMs, + source.workedForMs, + source.turnWorkedDurationMs, + source.worked_for_ms, + nestedTurn.workedDurationMs, + nestedTurn.workDurationMs, + nestedTurn.workedForMs, + nestedStatus.workedDurationMs, + nestedStatus.workDurationMs, + ); + if (explicit !== null) return Math.max(0, explicit); + const started = timestampMs( + source.firstTurnWorkItemStartedAtMs, + source.workStartedAtMs, + source.turnWorkStartedAtMs, + nestedTurn.firstTurnWorkItemStartedAtMs, + nestedTurn.workStartedAtMs, + nestedTurn.turnStartedAtMs, + ); + const completed = timestampMs( + source.finalAssistantStartedAtMs, + source.workCompletedAtMs, + nestedTurn.finalAssistantStartedAtMs, + nestedTurn.workCompletedAtMs, + source.completedAtMs, + ); + if (started !== null && completed !== null) return Math.max(0, completed - started); + return finiteNumber(fallback); + } + + function formatDuration(value) { + const duration = finiteNumber(value); + if (duration === null || duration < 0) return ""; + if (uiLocale() === "en-US") { + if (duration < 1_000) return `${Math.round(duration)}ms`; + const totalSeconds = Math.floor(duration / 1_000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (!minutes) return `${seconds}s`; + return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + if (duration < 1_000) return `${Math.round(duration)}毫秒`; + const totalSeconds = Math.floor(duration / 1_000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (!minutes) return `${seconds}秒`; + // The Chinese locale in the official panel uses the long form for one + // minute and the compact form for longer durations. + const minuteLabel = minutes === 1 ? "1分钟" : `${minutes}分`; + return seconds ? `${minuteLabel}${seconds}秒` : minuteLabel; + } + + // Working indicators in the official panel stay textual until a full + // second has elapsed. This avoids the visually noisy "0ms"/"1ms" state on + // every newly-created activity row while preserving precise durations once + // an operation is complete. + function elapsedDuration(value) { + const duration = finiteNumber(value); + return duration !== null && duration >= 1_000 ? formatDuration(duration) : ""; + } + + function formatMessageTime(value) { + const timestamp = timestampMs(value); + if (timestamp === null) return ""; + try { + return new Intl.DateTimeFormat(uiLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(timestamp)); + } catch { + return ""; + } + } + + function formatMessageDate(value, nowValue = Date.now()) { + const timestamp = timestampMs(value); + if (timestamp === null) return ""; + try { + const date = new Date(timestamp); + const now = new Date(nowValue); + // Match the official timestamp separator: compare calendar dates while + // avoiding DST changes in the local timezone. + const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); + const nowDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + const dayDifference = Math.max(0, Math.round((nowDay - dateDay) / 86_400_000)); + const locale = uiLocale(); + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date); + if (dayDifference <= 1) { + try { + const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-Math.max(dayDifference, 0), "day"); + return `${relative} ${time}`; + } catch { + return `${t(dayDifference === 1 ? "昨天" : "今天")} ${time}`; + } + } + if (dayDifference <= 7 && dayDifference > 0) { + const weekday = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date); + return `${weekday} ${time}`; + } + const datePart = dayDifference <= 365 + ? new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", weekday: "short" }).format(date) + : new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date); + return `${datePart} ${time}`; + } catch { + return ""; + } + } + + function messageDateKey(value) { + const timestamp = timestampMs(value); + if (timestamp === null) return ""; + const date = new Date(timestamp); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + } + + function normalizeActivityStatus(value, fallback = "inProgress") { + const normalized = String(value ?? fallback).replace(/[\s_-]+/g, "").toLowerCase(); + if (["inprogress", "running", "started", "active", "pending"].includes(normalized)) return "inProgress"; + if (["completed", "complete", "success", "succeeded", "done"].includes(normalized)) return "completed"; + if (["failed", "failure", "error"].includes(normalized)) return "failed"; + if (["declined", "denied", "rejected"].includes(normalized)) return "declined"; + if (["interrupted", "cancelled", "canceled", "aborted"].includes(normalized)) return "interrupted"; + return fallback; + } + + function activityStatusLabel(status) { + if (status === "inProgress") return "进行中"; + if (status === "completed") return "已完成"; + if (status === "failed") return "失败"; + if (status === "declined") return "已拒绝"; + if (status === "interrupted") return "已中断"; + return String(status || ""); + } + + const isRunningActivity = (activity) => activity && activity.status === "inProgress"; + + function eventParams(payload) { + if (!isRecord(payload)) return {}; + return isRecord(payload.params) ? payload.params : payload; + } + + function eventThreadId(payload) { + const params = eventParams(payload); + return payload?.threadId || params.threadId || params.thread?.id || params.turn?.threadId || ""; + } + + function eventTurnId(payload) { + const params = eventParams(payload); + return payload?.turnId || params.turnId || params.turn?.id || params.item?.turnId || ""; + } + + function itemFromPayload(payload) { + const params = eventParams(payload); + if (isRecord(params.item)) return params.item; + if (isRecord(payload?.item)) return payload.item; + return isRecord(params) && (params.type || params.kind) ? params : {}; + } + + function normalizedItemType(item) { + return String(item?.type || item?.kind || "").replace(/[\s/_.-]+/g, "").toLowerCase(); + } + + function activityKindForItem(item) { + const type = normalizedItemType(item); + if (isReadActivity(item)) return "read"; + if (!type) return ""; + if (type.includes("subagent") || type.includes("collabagent")) return "subagent"; + if (type.includes("filechange") || type.includes("patch") || type.includes("edit")) return "edit"; + if (type.includes("reasoning") || type.includes("contextcompaction")) return "reasoning"; + if (type.includes("plan") || type.includes("reviewmode")) return "plan"; + if (type.includes("command") || type.includes("exec") || type.includes("process")) return "tool"; + if (type.includes("tool") || type.includes("mcp") || type.includes("websearch") || type.includes("imageview")) return "tool"; + if (type.includes("usermessage")) return "user"; + if (type.includes("agentmessage") || type.includes("assistantmessage")) return "assistant"; + return ""; + } + + function activityLabelForItem(item, kind) { + const type = normalizedItemType(item); + if (kind === "subagent" || type.includes("subagent") || type.includes("collabagent")) { + return firstString(item.displayName, item.agentPath, item.action) || "子代理"; + } + if (type.includes("contextcompaction")) return "整理上下文"; + if (type.includes("websearch")) return "搜索"; + if (type.includes("imageview")) return "查看图像"; + if (type.includes("mcp")) return "MCP 工具"; + if (type.includes("dynamictool")) return "工具"; + if (kind === "edit") return "编辑文件"; + if (kind === "read") return "读取文件"; + if (kind === "plan") return "计划"; + if (kind === "reasoning") return "思考"; + if (kind === "commentary") return "工作说明"; + if (kind === "tool") return "运行命令"; + return "执行步骤"; + } + + function historyActivitySummary(item, kind, status, duration) { + const elapsed = duration ? ` · ${duration}` : ""; + if (kind === "subagent") { + const name = firstString(item.displayName, item.agentPath, item.agentThreadId ? `thread ${item.agentThreadId}` : "") || t("子代理"); + const uiStatus = firstString(item.displayStatus, item.activityKind); + if (status === "inProgress" || uiStatus === "active" || uiStatus === "updated") return `${name} ${t("已开始工作")}`; + if (status === "failed") return `${name} ${t("失败")}`; + if (status === "interrupted") return `${name} ${t("已中断")}`; + return `${name} ${t("已完成")}`; + } + if (kind === "tool" || kind === "read") { + const command = terminalCommandText(commandText(item)); + const actionSource = String(item.label || "工具"); + const action = t(actionSource); + const path = firstString(...readPathList(item)); + if (kind === "read" && status === "inProgress") return readSummaryLabel(item, status); + if (kind === "read" && !command) { + return readSummaryLabel(item, status); + } + if (kind === "read" && status !== "inProgress") { + // Parsed read/search actions are coalesced by the official renderer + // into one compact exploration row rather than a path plus a second + // timed command row. + if (command) return status === "failed" ? t("读取文件运行命令失败") : status === "interrupted" ? t("已停止读取文件运行命令") : t("已读取文件运行了命令"); + return readSummaryLabel(item, status); + } + if (status === "inProgress") return command + ? uiWithRaw("正在运行 ", "Running ", command) + : uiLocale() === "en-US" ? `Running ${action}` : `正在${action}`; + if (status === "failed") return command + ? `${uiWithRaw("命令运行失败 · ", "Command failed · ", command)}${elapsed}` + : `${action}${uiText("失败", " failed")}${elapsed}`; + if (status === "interrupted") return command + ? `${uiWithRaw("已停止 ", "Stopped ", command)}${elapsed}` + : `${uiText("已停止", "Stopped ")}${action}${elapsed}`; + if (command) return duration + ? `${uiWithRaw("已在 ", "Ran ", command, " 内运行 ", " in ")}${duration}` + : uiWithRaw("已运行 ", "Ran ", command); + return uiLocale() === "en-US" + ? `Ran ${action}${elapsed}` + : `已${action}${elapsed}`; + } + if (kind === "edit") return status === "inProgress" ? t("正在编辑文件") : `${t("编辑了文件")}${elapsed}`; + if (kind === "reasoning") return status === "inProgress" ? t("正在思考") : duration ? uiWithRaw("已思考 ", "Thought for ", duration) : t("已完成思考"); + if (kind === "plan") return status === "inProgress" ? t("正在制定计划") : `${t("已完成计划")}${elapsed}`; + if (kind === "commentary") { + if (status === "inProgress") return t("正在处理"); + return t("工作说明"); + } + return ""; + } + + function textFromValue(value, depth = 0) { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + const parts = value.map((entry) => textFromValue(entry, depth + 1)).filter(Boolean); + return parts.length ? parts.join("\n") : ""; + } + if (!isRecord(value) || depth > 3) return ""; + for (const key of ["text", "value", "output", "stdout", "stderr", "delta", "summary", "message"]) { + const text = textFromValue(value[key], depth + 1); + if (text) return text; + } + return ""; + } + + function displayValue(value) { + const text = textFromValue(value); + if (text) return text; + if (value === undefined || value === null) return ""; + try { return JSON.stringify(value, null, 2); } catch { return String(value); } + } + + function commandText(item) { + if (Array.isArray(item.command)) return item.command.map(String).join(" "); + if (typeof item.command === "string") return item.command; + if (typeof item.commandLine === "string") return item.commandLine; + if (Array.isArray(item.commandActions)) { + return item.commandActions + .map((action) => isRecord(action) ? action.command || action.description || "" : "") + .filter(Boolean) + .join("\n"); + } + return ""; + } + + function fileChangesText(changes) { + if (!Array.isArray(changes)) return displayValue(changes); + return changes.map((change) => { + if (!isRecord(change)) return displayValue(change); + const path = change.path || change.file || change.filePath || change.name || "文件"; + const kind = change.kind || change.type || change.status || ""; + const diff = displayValue(change.diff || change.patch || change.output || change.text); + const heading = `${kind ? `[${kind}] ` : ""}${path}`; + return diff ? `${heading}\n${diff}` : heading; + }).filter(Boolean).join("\n\n"); + } + + function planText(value) { + const plan = Array.isArray(value) ? value : value?.plan || value?.steps; + if (!Array.isArray(plan)) return displayValue(value?.text || value); + return plan.map((entry) => { + if (!isRecord(entry)) return `- ${displayValue(entry)}`; + const status = normalizeActivityStatus(entry.status, "pending"); + const marker = status === "completed" ? "[x]" : status === "inProgress" ? "[~]" : "[ ]"; + const step = entry.step || entry.text || entry.title || entry.description || "步骤"; + return `${marker} ${step}`; + }).join("\n"); + } + + function activityHeader(item, kind) { + // Terminal activities render command/cwd as separate fields below. Keep + // this helper for non-terminal activity kinds and future item types. + return ""; + } + + function activityOutput(item, kind) { + if (kind === "reasoning") return displayValue(item.summary || item.content || item.text); + if (kind === "plan") return planText(item.plan || item.steps || item.content || item.text); + if (kind === "edit") return fileChangesText(item.changes || item.files || item.diff || item.patch || item.output || item.text); + if (kind === "tool" || kind === "read") { + if (kind === "read") { + // Paths are rendered as their own compact exploration rows. Keep only + // the actual file content here; adapter summaries such as + // "已读取 ..." must not be repeated inside a Shell block. + let value = displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result); + const summary = firstString(item.text, item.summary); + if (summary && value === summary) return ""; + if (summary && value.startsWith(`${summary}\n`)) value = value.slice(summary.length + 1); + return value; + } + return displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result ?? item.error ?? item.text); + } + return displayValue(item.text || item.content || item.output); + } + + function activityKey(payload, item, kind, explicitKey) { + if (explicitKey) return explicitKey; + const params = eventParams(payload); + const id = item.id ?? params.itemId ?? payload?.itemId; + const threadId = eventThreadId(payload) || state.threadId || "thread"; + const turnId = eventTurnId(payload) || state.turnId || "turn"; + if (id !== undefined && id !== null) return `${threadId}:${turnId}:${kind}:${typeof id}:${String(id)}`; + state.activitySequence += 1; + return `${threadId}:${turnId}:${kind}:anonymous:${state.activitySequence}`; + } + + function ensureActivity(key, config = {}) { + const kind = config.kind || "reasoning"; + let existing = state.activities.get(key); + if (!existing) { + const requestedItemId = config.itemId === undefined || config.itemId === null ? "" : String(config.itemId); + const sameTurn = (entry) => entry.kind === kind + && (!config.turnId || !entry.turnId || entry.turnId === String(config.turnId)); + const entries = [...state.activities.entries()].reverse(); + const exact = requestedItemId + ? entries.find(([, entry]) => sameTurn(entry) && entry.itemId === requestedItemId) + : null; + // Status snapshots may arrive before the item lifecycle notification. + // When the concrete item appears, adopt the still-running anonymous row + // instead of appending a second "正在思考/读取/编辑" entry. + const anonymous = entries.find(([, entry]) => sameTurn(entry) + && entry.anonymous + && isRunningActivity(entry)); + const unkeyed = !requestedItemId + ? entries.find(([, entry]) => sameTurn(entry) && !entry.itemId && isRunningActivity(entry)) + : null; + const match = exact || anonymous || unkeyed; + if (match) { + const [oldKey, candidate] = match; + existing = candidate; + if (oldKey !== key) { + state.activities.delete(oldKey); + existing.key = key; + existing.article.dataset.activityKey = key; + if (state.liveActivityKey === oldKey) state.liveActivityKey = key; + if (state.activeAssistantActivityKey === oldKey) state.activeAssistantActivityKey = key; + } + if (requestedItemId && existing.anonymous) { + existing.itemId = requestedItemId; + existing.anonymous = false; + } + state.activities.set(key, existing); + } + } + if (existing) { + // A lifecycle event or real output upgrades a transient status row into + // a concrete activity. Once upgraded, it must survive turn completion; + // status-only rows are retired when the host reports a terminal state. + if (config.concrete === true) { + existing.concrete = true; + existing.statusOnly = false; + // Keep the anonymous marker for id-less lifecycle streams so the + // terminal status path can still close them when the host omits an + // explicit item/completed event. An identified item is authoritative. + if (config.itemId !== undefined && config.itemId !== null && String(config.itemId)) { + existing.anonymous = false; + } + } else if (config.statusOnly === true && existing.concrete !== true) { + existing.statusOnly = true; + } + if (config.label) existing.label = config.label; + if (config.itemId !== undefined) existing.itemId = String(config.itemId); + if (config.command) existing.command = kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command); + if (config.filePath !== undefined) existing.filePath = String(config.filePath || ""); + if (config.cwd !== undefined) existing.cwd = String(config.cwd || ""); + if (config.shellName) existing.shellName = String(config.shellName); + if (config.agentThreadId !== undefined) existing.agentThreadId = String(config.agentThreadId || ""); + if (config.displayName !== undefined) existing.displayName = String(config.displayName || ""); + if (config.objective !== undefined) existing.objective = String(config.objective || ""); + if (config.activityKind !== undefined) existing.activityKind = String(config.activityKind || ""); + if (config.displayStatus !== undefined) existing.displayStatus = String(config.displayStatus || ""); + if (config.model !== undefined) existing.model = String(config.model || ""); + if (config.action !== undefined) existing.action = String(config.action || ""); + if (config.prompt !== undefined) existing.prompt = String(config.prompt || ""); + if (config.senderThreadId !== undefined) existing.senderThreadId = String(config.senderThreadId || ""); + if (config.receiverThreadIds !== undefined) existing.receiverThreadIds = Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : []; + if (config.agentsStates !== undefined) existing.agentsStates = isRecord(config.agentsStates) ? config.agentsStates : {}; + if (config.canInteract !== undefined) existing.canInteract = config.canInteract !== false; + if (config.exitCode !== undefined) existing.exitCode = finiteNumber(config.exitCode); + if (config.turnId) existing.turnId = String(config.turnId); + if (config.startedAt) existing.startedAt = timestampMs(config.startedAt) || existing.startedAt; + if (existing.agentThreadId) existing.article.dataset.agentThreadId = existing.agentThreadId; + else delete existing.article.dataset.agentThreadId; + if (kind === "tool" || kind === "read" || kind === "subagent" || kind === "commentary") renderActivityText(existing); + refreshActivity(existing); + if (existing.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) { + ensureLiveTurnDivider(existing.turnId); + } + return existing; + } + const role = kind === "commentary" + ? "assistant" + : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system"; + const messageKind = kind === "tool" || kind === "read" ? "tool" : kind === "reasoning" ? "reasoning" : kind === "plan" ? "plan" : kind; + const message = appendMessage("", role, "activity", "", { + kind: messageKind, + label: config.label || "执行步骤", + command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "", + turnId: config.turnId || "", + agentThreadId: config.agentThreadId || "", + // Commentary is an assistant paragraph in the official transcript, not + // a nested disclosure. The outer worked-for group still owns its layout. + collapsible: kind !== "commentary", + showActions: false, + // Keep commentary visible while a turn is open; command/read/reasoning + // bodies remain independently collapsible. + open: config.open === true || kind === "commentary", + }); + if (!message) return null; + const activity = { + key, + kind, + role, + messageKind, + label: config.label || "执行步骤", + command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "", + cwd: config.cwd ? String(config.cwd) : "", + shellName: config.shellName ? String(config.shellName) : "Shell", + agentThreadId: config.agentThreadId ? String(config.agentThreadId) : "", + displayName: config.displayName ? String(config.displayName) : "", + objective: config.objective ? String(config.objective) : "", + activityKind: config.activityKind ? String(config.activityKind) : "", + displayStatus: config.displayStatus ? String(config.displayStatus) : "", + model: config.model ? String(config.model) : "", + action: config.action ? String(config.action) : "", + filePath: config.filePath ? String(config.filePath) : "", + prompt: config.prompt ? String(config.prompt) : "", + senderThreadId: config.senderThreadId ? String(config.senderThreadId) : "", + receiverThreadIds: Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : [], + agentsStates: isRecord(config.agentsStates) ? config.agentsStates : {}, + canInteract: config.canInteract !== false, + exitCode: config.exitCode === undefined ? null : finiteNumber(config.exitCode), + itemId: config.itemId === undefined ? "" : String(config.itemId), + threadId: config.threadId || state.threadId || "", + turnId: config.turnId || state.turnId || "", + startedAt: timestampMs(config.startedAt) || Date.now(), + finishedAt: null, + durationMs: null, + durationExplicit: false, + status: normalizeActivityStatus(config.status, "inProgress"), + headerText: "", + outputText: "", + anonymous: Boolean(config.anonymous), + concrete: config.concrete === true, + statusOnly: config.statusOnly === true, + article: message.article, + body: message.content, + wrapper: message.wrapper, + details: message.article.querySelector("details"), + summary: message.article.querySelector("summary"), + }; + activity.article.dataset.activityKey = key; + activity.article.dataset.activityKind = kind; + if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId; + state.activities.set(key, activity); + while (state.activities.size > 500) { + const removable = [...state.activities].find(([, entry]) => !isRunningActivity(entry)); + if (!removable) break; + state.activities.delete(removable[0]); + } + renderActivityText(activity); + refreshActivity(activity); + if (activity.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) { + ensureLiveTurnDivider(activity.turnId); + } + ensureActivityTimer(); + return activity; + } + + function activityText(activity) { + if (activity.kind === "tool" || activity.kind === "read") { + const command = terminalCommandText(activity.command); + const commandLine = command ? `$ ${command}` : ""; + return [commandLine, activity.outputText].filter(Boolean).join("\n"); + } + if (activity.kind === "subagent") { + const name = activity.displayName || activity.label || t("子代理"); + const objective = activity.objective || activity.outputText; + return [name, objective].filter(Boolean).join("\n"); + } + if (activity.headerText && activity.outputText) return `${activity.headerText}\n\n${activity.outputText}`; + return activity.headerText || activity.outputText || ""; + } + + function normalizeSubagentActionStatus(value) { + const normalized = String(value || "").replace(/[\s_-]+/g, "").toLowerCase(); + if (["pendinginit", "pending", "waiting"].includes(normalized)) return "waiting"; + if (["running", "working", "active", "started", "interacted", "updated", "inprogress"].includes(normalized)) return "working"; + if (["completed", "complete", "done", "interrupted", "shutdown"].includes(normalized)) return "done"; + if (["errored", "error", "failed", "notfound"].includes(normalized)) return "failed"; + return "waiting"; + } + + function renderSubagentBody(activity) { + const body = activity?.body; + if (!body) return; + body.replaceChildren(); + body.classList.remove("terminal-body", "diff-body"); + body.classList.add("subagent-body"); + + const prompt = firstString(activity.prompt, activity.objective, activity.outputText); + if (prompt) { + const promptNode = document.createElement("div"); + promptNode.className = "subagent-prompt markdown-body"; + renderMarkdown(promptNode, prompt); + body.append(promptNode); + } + if (activity.model || activity.action) { + const meta = document.createElement("div"); + meta.className = "subagent-action-meta"; + if (activity.action) { + const action = document.createElement("span"); + action.textContent = activity.action === "spawnAgent" ? t("启动子代理") + : activity.action === "sendInput" ? t("发送输入") + : activity.action === "resumeAgent" ? t("恢复子代理") + : activity.action === "closeAgent" ? t("关闭子代理") : activity.action; + meta.append(action); + } + if (activity.model) { + const model = document.createElement("span"); + model.className = "subagent-model"; + model.textContent = activity.model; + meta.append(model); + } + body.append(meta); + } + + const states = isRecord(activity.agentsStates) ? activity.agentsStates : {}; + const receiverIds = Array.isArray(activity.receiverThreadIds) ? activity.receiverThreadIds : []; + const ids = [...new Set([...receiverIds, ...Object.keys(states)])].filter(Boolean); + if (!ids.length) return; + const rows = document.createElement("div"); + rows.className = "subagent-action-rows"; + for (const threadId of ids) { + const raw = isRecord(states[threadId]) ? states[threadId] : {}; + const status = normalizeSubagentActionStatus(raw.status); + const row = document.createElement("div"); + row.className = "subagent-action-row"; + row.dataset.status = status; + const icon = document.createElement("span"); + icon.className = "subagent-action-icon"; + icon.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.className = "subagent-action-label"; + label.textContent = threadId === activity.agentThreadId + ? firstString(activity.displayName, threadId) + : `thread ${threadId}`; + const statusNode = document.createElement("span"); + statusNode.className = "subagent-action-status"; + statusNode.textContent = subagentStatusLabel(status); + row.append(icon, label, statusNode); + const message = firstString(raw.message, raw.statusMessage); + if (message) { + const note = document.createElement("div"); + note.className = "subagent-action-note"; + note.textContent = message; + row.append(note); + } + rows.append(row); + } + body.append(rows); + } + + function terminalOutputText(activity) { + const value = String(activity?.outputText || ""); + // A few older bridge payloads included an exit-code suffix in the output + // string. Strip only that exact synthetic line; real command output stays + // untouched. + return normalizeTerminalOutput(value.replace(/\n?exit code:\s*-?\d+\s*$/i, "")); + } + + function normalizeTerminalOutput(value) { + // Commands often use carriage returns/backspaces for progress updates and + // ANSI SGR sequences for color. Resolve the control characters before the + // lightweight renderer turns the result into safe DOM nodes. + const stripped = String(value || "") + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[(?![0-9;]*m)[0-?]*[ -/]*[@-~]/g, ""); + return stripped.replace(/\r\n/g, "\n").split("\n").map((line) => { + const cells = []; + let cursor = 0; + for (const character of line) { + if (character === "\r") { cursor = 0; continue; } + if (character === "\b") { cursor = Math.max(0, cursor - 1); continue; } + cells[cursor] = character; + cursor += 1; + } + return cells.join(""); + }).join("\n"); + } + + function renderTerminalOutput(parent, value) { + parent.replaceChildren(); + const text = String(value || ""); + const sgr = /\x1b\[([0-9;]*)m/g; + let cursor = 0; + const style = { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false }; + const appendSegment = (segment) => { + if (!segment) return; + const classes = []; + if (style.fg) classes.push(`ansi-${style.fg}-fg`); + if (style.bg) classes.push(`ansi-${style.bg}-bg`); + if (style.bold) classes.push("ansi-bold"); + if (style.dim) classes.push("ansi-dim"); + if (style.italic) classes.push("ansi-italic"); + if (style.underline) classes.push("ansi-underline"); + if (style.strike) classes.push("ansi-strikethrough"); + if (!classes.length) parent.append(document.createTextNode(segment)); + else { + const span = document.createElement("span"); + span.className = classes.join(" "); + span.textContent = segment; + parent.append(span); + } + }; + const applySgr = (codes) => { + const values = codes.length ? codes : [0]; + for (const code of values) { + if (code === 0) Object.assign(style, { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false }); + else if (code === 1) style.bold = true; + else if (code === 2) style.dim = true; + else if (code === 3) style.italic = true; + else if (code === 4) style.underline = true; + else if (code === 9) style.strike = true; + else if (code === 22) { style.bold = false; style.dim = false; } + else if (code === 23) style.italic = false; + else if (code === 24) style.underline = false; + else if (code === 29) style.strike = false; + else if (code === 39) style.fg = ""; + else if (code === 49) style.bg = ""; + else if (code >= 30 && code <= 37) style.fg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 30]; + else if (code >= 90 && code <= 97) style.fg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 90]; + else if (code >= 40 && code <= 47) style.bg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 40]; + else if (code >= 100 && code <= 107) style.bg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 100]; + } + }; + for (const match of text.matchAll(sgr)) { + appendSegment(text.slice(cursor, match.index)); + applySgr(match[1] ? match[1].split(";").map((part) => Number(part) || 0) : [0]); + cursor = match.index + match[0].length; + } + appendSegment(text.slice(cursor)); + } + + function updateTerminalOutputFade(output) { + if (!output) return; + const overflow = output.scrollHeight - output.clientHeight; + output.dataset.fadeTop = String(output.scrollTop > 1); + output.dataset.fadeBottom = String(overflow - output.scrollTop > 1); + } + + function terminalStatusText(activity) { + if (!activity) return ""; + // File-read rows do not have a process exit code. The official renderer + // closes them with the read summary, not a misleading "unknown" status. + if (activity.kind === "read" && (activity.exitCode === null || activity.exitCode === undefined)) return ""; + if (activity.status === "inProgress") return ""; + if (activity.status === "interrupted") return t("已停止"); + if (activity.status === "failed" || activity.status === "declined") { + return activity.exitCode === null || activity.exitCode === undefined + ? t("退出码 未知") + : t(`退出码 ${activity.exitCode}`); + } + if (activity.status === "completed") { + if (activity.exitCode === 0) return t("成功"); + if (activity.exitCode !== null && activity.exitCode !== undefined) return t(`退出码 ${activity.exitCode}`); + return t("退出码 未知"); + } + return ""; + } + + function appendTerminalCheckIcon(parent) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.className.baseVal = "terminal-status-icon"; + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); + svg.append(path); + parent.append(svg); + } + + function setTerminalActionIcon(button, kind = "copy") { + if (!button) return; + button.replaceChildren(); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + if (kind === "check") { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); + svg.append(path); + } else if (kind === "collapse" || kind === "expand") { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", kind === "collapse" ? "m5 6 3 3 3-3" : "m5 10 3-3 3 3"); + svg.append(path); + } else { + const back = document.createElementNS("http://www.w3.org/2000/svg", "path"); + back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5"); + const front = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + front.setAttribute("x", "2.5"); + front.setAttribute("y", "5.5"); + front.setAttribute("width", "7.5"); + front.setAttribute("height", "7.5"); + front.setAttribute("rx", "1.2"); + svg.append(back, front); + } + button.append(svg); + } + + function createTerminalAction(kind, title) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "terminal-action"; + button.title = t(title); + button.setAttribute("aria-label", t(title)); + setTerminalActionIcon(button, kind); + return button; + } + + async function copyTerminalValue(value, button) { + try { + await navigator.clipboard?.writeText(String(value || "")); + setTerminalActionIcon(button, "check"); + button.dataset.copied = "true"; + setTimeout(() => { + if (!button.isConnected) return; + setTerminalActionIcon(button, "copy"); + button.dataset.copied = "false"; + }, 1_500); + } catch { + button.dataset.copied = "false"; + } + } + + function createReadPathIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.className.baseVal = "read-path-icon"; + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + const folder = document.createElementNS("http://www.w3.org/2000/svg", "path"); + folder.setAttribute("d", "M2.5 4.5h3l1.2 1.4h6.8v6.2a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z"); + const top = document.createElementNS("http://www.w3.org/2000/svg", "path"); + top.setAttribute("d", "M2.5 4.5v-1h3l1.1 1h5.9"); + svg.append(folder, top); + return svg; + } + + function renderReadBody(activity) { + const body = activity?.body; + if (!body) return; + body.replaceChildren(); + body.classList.remove("terminal-body", "diff-body", "markdown-body"); + body.classList.add("read-body"); + const paths = String(activity.filePath || "").split("\n").map((value) => value.trim()).filter(Boolean); + if (paths.length) { + const list = document.createElement("div"); + list.className = "read-path-list"; + for (const path of paths) { + const row = document.createElement("div"); + row.className = "read-path-row"; + row.append(createReadPathIcon()); + const value = document.createElement("span"); + value.textContent = path; + value.title = path; + row.append(value); + list.append(row); + } + body.append(list); + } + const output = terminalOutputText(activity); + if (output) { + const outputBlock = document.createElement("pre"); + outputBlock.className = "read-output"; + outputBlock.textContent = output; + body.append(outputBlock); + } + if (!paths.length && !output) { + const empty = document.createElement("span"); + empty.className = "read-empty"; + empty.textContent = t(activity.status === "inProgress" ? "正在读取文件" : "读取完成"); + body.append(empty); + } + } + + function renderTerminalBody(activity) { + const body = activity?.body; + if (!body) return; + if (activity.kind === "read" && !terminalCommandText(activity.command)) { + renderReadBody(activity); + return; + } + const previousOutput = body.querySelector(".terminal-output"); + const previousScrollTop = previousOutput?.scrollTop || 0; + const previousScrollLeft = previousOutput?.scrollLeft || 0; + const previousAtBottom = previousOutput + ? previousOutput.scrollHeight - previousOutput.scrollTop - previousOutput.clientHeight <= 2 + : true; + body.replaceChildren(); + body.classList.add("terminal-body"); + body.classList.remove("markdown-body", "diff-body"); + + const shell = document.createElement("div"); + shell.className = "terminal-shell"; + const command = terminalCommandText(activity.command); + const output = terminalOutputText(activity); + + const shellHeader = document.createElement("div"); + shellHeader.className = "terminal-shell-header"; + const shellLabel = document.createElement("div"); + shellLabel.className = "terminal-shell-label"; + shellLabel.textContent = activity.shellName || "Shell"; + if (activity.cwd) shellLabel.title = `cwd\\n${activity.cwd}`; + // The official embedded shell uses a lightweight label row. The parent + // activity disclosure owns collapse; command and output each expose their + // own copy action on hover. + shellHeader.append(shellLabel); + shell.append(shellHeader); + + if (activity.kind === "read" && activity.filePath && !command) { + const pathRow = document.createElement("div"); + pathRow.className = "terminal-file-path"; + const paths = activity.filePath.split("\n").filter(Boolean); + pathRow.textContent = paths.length > 1 ? `${paths[0]} (+${paths.length - 1})` : paths[0]; + pathRow.title = activity.filePath; + shell.append(pathRow); + } + + if (command) { + const commandRow = document.createElement("div"); + commandRow.className = "terminal-command-line"; + const prompt = document.createElement("span"); + prompt.className = "terminal-prompt"; + prompt.textContent = "$"; + const commandCode = document.createElement("code"); + commandCode.textContent = command; + const commandChevron = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + commandChevron.classList.add("terminal-command-chevron"); + commandChevron.setAttribute("viewBox", "0 0 16 16"); + commandChevron.setAttribute("aria-hidden", "true"); + const commandChevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + commandChevronPath.setAttribute("d", "m4.5 6 3.5 3.5L11.5 6"); + commandChevron.append(commandChevronPath); + const commandExpanded = activity.commandExpanded === true + || state.commandDisclosure.get(activity.key) === true; + commandRow.dataset.expanded = String(commandExpanded); + commandRow.setAttribute("role", "button"); + commandRow.setAttribute("tabindex", "0"); + commandRow.setAttribute("aria-expanded", String(commandExpanded)); + commandRow.setAttribute("aria-label", `$ ${command}`); + const toggleCommand = (event) => { + if (event.target.closest(".terminal-action")) return; + event.preventDefault(); + const expanded = commandRow.dataset.expanded === "true"; + const next = !expanded; + activity.commandExpanded = next; + if (activity.key) state.commandDisclosure.set(activity.key, next); + preserveTimelineAnchor(commandRow, 210); + animateCommandRow(commandRow, next); + if (next) scheduleTimelineReveal(commandRow, 190); + }; + commandRow.addEventListener("click", toggleCommand); + commandRow.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") toggleCommand(event); + }); + const copyCommand = createTerminalAction("copy", "复制命令"); + copyCommand.classList.add("terminal-command-action"); + copyCommand.addEventListener("click", (event) => { + event.stopPropagation(); + copyTerminalValue(command, copyCommand); + }); + commandRow.append(prompt, commandCode, commandChevron, copyCommand); + shell.append(commandRow); + } + + const outputWrap = document.createElement("div"); + outputWrap.className = "terminal-output-wrap"; + if (output) { + const outputBlock = document.createElement("pre"); + outputBlock.className = "terminal-output"; + const outputContent = document.createElement("div"); + outputContent.className = "terminal-output-content"; + renderTerminalOutput(outputContent, output); + outputBlock.append(outputContent); + // Activity deltas rebuild the lightweight DOM. Preserve the reader's + // position, while still following the tail when they were already at + // the bottom of the terminal stream. + outputBlock.scrollLeft = previousScrollLeft; + requestAnimationFrame(() => { + if (previousAtBottom) outputBlock.scrollTop = outputBlock.scrollHeight; + else outputBlock.scrollTop = previousScrollTop; + outputBlock.scrollLeft = previousScrollLeft; + updateTerminalOutputFade(outputBlock); + }); + outputBlock.addEventListener("scroll", () => updateTerminalOutputFade(outputBlock), { passive: true }); + const copyOutput = createTerminalAction("copy", "复制输出"); + copyOutput.classList.add("terminal-output-action"); + copyOutput.addEventListener("click", (event) => { + event.stopPropagation(); + copyTerminalValue(output, copyOutput); + }); + outputWrap.append(outputBlock, copyOutput); + } else if (activity.status !== "inProgress") { + const outputBlock = document.createElement("pre"); + outputBlock.className = "terminal-output terminal-output-empty"; + const outputContent = document.createElement("div"); + outputContent.className = "terminal-output-content terminal-no-output"; + outputContent.textContent = t("无输出"); + outputBlock.append(outputContent); + outputWrap.append(outputBlock); + } + shell.append(outputWrap); + + const statusText = terminalStatusText(activity); + const footer = document.createElement("div"); + footer.className = "terminal-footer"; + footer.dataset.status = activity.status; + if (statusText) { + const status = document.createElement("span"); + status.className = "terminal-status"; + if (activity.status === "completed" && activity.exitCode === 0) appendTerminalCheckIcon(status); + status.append(document.createTextNode(statusText)); + footer.append(status); + } + shell.append(footer); + body.append(shell); + } + + function renderActivityText(activity) { + const text = activityText(activity); + const visibleText = text || t(isRunningActivity(activity) ? "等待输出…" : activityStatusLabel(activity.status)); + if (activity.kind === "tool" || activity.kind === "read") renderTerminalBody(activity); + else if (activity.kind === "subagent") renderSubagentBody(activity); + else renderMessageBody(activity.body, visibleText, activity.role, "activity", activity.messageKind); + activity.article.dataset.rawText = text; + } + + function setActivityContent(activity, header, output, append = false) { + if (!activity) return; + if (header !== undefined && header !== null && String(header)) activity.headerText = String(header); + if (output !== undefined && output !== null) { + activity.outputText = append ? `${activity.outputText}${String(output)}` : String(output); + } + renderActivityText(activity); + } + + function activityElapsed(activity) { + if (activity.durationMs !== null) return Math.max(0, activity.durationMs); + if (!activity.startedAt) return null; + // A completed legacy row without an end marker has an unknown duration; + // showing the current wall clock makes old work appear to keep running. + if (!isRunningActivity(activity) && !activity.finishedAt) return null; + const end = activity.finishedAt || Date.now(); + return Math.max(0, end - activity.startedAt); + } + + function refreshActivity(activity) { + if (!activity?.summary) return; + const elapsed = activityElapsed(activity); + // The official activity rows only show an item duration when the owner + // supplied one. A timestamp interval inferred while hydrating history is + // useful for the turn clock, but should not become a misleading per-row + // wall-clock label. + const duration = activity.durationExplicit === true ? elapsedDuration(elapsed) : ""; + const label = t(activity.label || "执行步骤"); + const setSummary = (value) => setActivitySummary(activity.summary, value, activity.kind); + // Recompute the summary in the active locale. Dynamic command/path/name + // values are appended as raw strings by historyActivitySummary(). + if (activity.kind === "subagent") { + const name = activity.displayName || activity.label || t("子代理"); + const statusText = activity.status === "inProgress" ? t("已开始工作") + : activity.status === "completed" ? t("已完成") + : activity.status === "failed" ? t("失败") + : activity.status === "interrupted" ? t("已中断") : ""; + setSubagentSummary(activity.summary, name, statusText); + } else { + const localizedSummary = historyActivitySummary(activity, activity.kind, activity.status, duration); + if (localizedSummary) setSummary(localizedSummary); + else if (activity.status === "inProgress") setSummary(label); + else if (activity.status === "failed") setSummary(`${label} · ${t("失败")}`); + else if (activity.status === "interrupted") setSummary(`${label} · ${t("已中断")}`); + else setSummary(label); + } + activity.article.dataset.status = activity.status; + } + + function finishActivity(activity, status = "completed", durationMs, finishedAt) { + if (!activity) return; + // `updateLiveActivity` may create a short-lived row before the host emits + // an item lifecycle event. It is useful while the turn is live, but the + // official transcript does not retain a blank "思考/编辑/读取" item once + // the turn ends. A concrete lifecycle row (or a row that received real + // output) has already cleared `statusOnly` and follows the normal path. + if (activity.statusOnly === true && activity.concrete !== true) { + retireActivity(activity); + return; + } + activity.status = normalizeActivityStatus(status, "completed"); + activity.finishedAt = timestampMs(finishedAt) || Date.now(); + const explicitDuration = finiteNumber(durationMs); + activity.durationExplicit = explicitDuration !== null; + const inferredDuration = activity.startedAt === null + ? null + : Math.max(0, activity.finishedAt - activity.startedAt); + activity.durationMs = explicitDuration === null + ? inferredDuration + : Math.max(0, explicitDuration); + activity.article.classList.remove("streaming"); + renderActivityText(activity); + refreshActivity(activity); + if (activity.details && (activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "subagent")) { + setDetailsExpanded(activity.details, false); + } + if (state.activeAssistantActivityKey === activity.key) { + state.activeAssistantBody = null; + state.activeAssistantStream = null; + state.activeAssistantText = ""; + state.activeAssistantActivityKey = null; + } + stopActivityTimerIfIdle(); + } + + function latestRunningActivity(kind, context = {}) { + const itemId = context.itemId === undefined || context.itemId === null ? "" : String(context.itemId); + const turnId = context.turnId || state.turnId || ""; + const activities = [...state.activities.values()].reverse(); + if (itemId) { + const exact = activities.find((activity) => activity.itemId === itemId && isRunningActivity(activity)); + if (exact) return exact; + } + const acceptedKinds = kind === "reasoning" ? new Set(["reasoning", "plan"]) : new Set([kind]); + return activities.find((activity) => isRunningActivity(activity) + && acceptedKinds.has(activity.kind) + && (!turnId || !activity.turnId || activity.turnId === turnId)) || null; + } + + function appendActivityChunk(activity, text) { + if (!activity || !text) return; + // A stream chunk is evidence of a real work item, even when the host + // omitted its item id. It should not be mistaken for a status-only row at + // turn completion. + activity.concrete = true; + activity.statusOnly = false; + activity.status = "inProgress"; + setActivityContent(activity, undefined, text, true); + activity.article.classList.add("streaming"); + if ((activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "commentary") && activity.details) { + // Reasoning is the one activity the official transcript expands while + // it is streaming; completed reasoning collapses again in finishActivity. + setDetailsExpanded(activity.details, true); + } + refreshActivity(activity); + ensureActivityTimer(); + } + + function handleItemLifecycle(phase, payload) { + const params = eventParams(payload); + const item = itemFromPayload(payload); + const kind = activityKindForItem(item); + if (!kind || kind === "user" || kind === "assistant") return false; + const itemId = item.id ?? params.itemId ?? payload?.itemId; + const duration = finiteNumber(item.durationMs, params.durationMs); + const finishedAt = timestampMs(item.completedAtMs, item.finishedAtMs, params.completedAtMs, params.emittedAtMs); + const startedAt = timestampMs(item.startedAtMs, item.startedAt, params.startedAtMs) + || (duration !== null && finishedAt ? finishedAt - duration : null); + const fallbackStatus = phase === "completed" ? "completed" : "inProgress"; + const activity = ensureActivity(activityKey(payload, item, kind), { + kind, + label: activityLabelForItem(item, kind), + command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : "", + filePath: kind === "read" ? readPathList(item).join("\n") : "", + cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "", + shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" ? item.shellName : "Shell", + agentThreadId: firstString(item.agentThreadId, item.childThreadId, item.threadId), + displayName: firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath), + objective: firstString(item.objective, item.prompt, item.statusMessage, item.message), + activityKind: firstString(item.activityKind, item.kind), + displayStatus: firstString(item.displayStatus, item.status), + model: firstString(item.model, item.modelId), + action: firstString(item.action, item.tool), + prompt: item.prompt === null ? "" : firstString(item.prompt), + senderThreadId: firstString(item.senderThreadId), + receiverThreadIds: Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.map(String) + : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [], + agentsStates: isRecord(item.agentsStates) ? item.agentsStates : {}, + canInteract: item.canInteract !== false, + exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : undefined, + itemId, + threadId: eventThreadId(payload), + turnId: eventTurnId(payload), + startedAt, + status: normalizeActivityStatus(item.status, fallbackStatus), + concrete: true, + anonymous: itemId === undefined || itemId === null, + }); + if (!activity) return true; + if (kind === "tool" || kind === "read") { + const command = terminalCommandText(commandText(item)); + if (command) activity.command = command; + if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath; + if (typeof item.cwd === "string") activity.cwd = item.cwd; + if (typeof item.shellName === "string" && item.shellName) activity.shellName = item.shellName; + const exitCode = finiteNumber(item.exitCode, item.exit_code); + if (exitCode !== null) activity.exitCode = exitCode; + } + if (kind === "subagent") { + activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId); + activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName); + activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective); + activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind); + activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus); + activity.model = firstString(item.model, item.modelId, activity.model); + activity.action = firstString(item.action, item.tool, activity.action); + activity.prompt = item.prompt === null ? "" : firstString(item.prompt, activity.prompt); + activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId); + if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String); + else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String); + if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates; + if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false; + if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId; + else delete activity.article.dataset.agentThreadId; + } + const header = activityHeader(item, kind); + const output = activityOutput(item, kind); + if (header || output) setActivityContent(activity, header, output); + if (phase === "completed") { + const status = normalizeActivityStatus(item.status, item.error ? "failed" : "completed"); + finishActivity(activity, status, duration, finishedAt); + } else { + activity.status = normalizeActivityStatus(item.status, "inProgress"); + refreshActivity(activity); + } + return true; + } + + function handlePlanUpdate(payload) { + const params = eventParams(payload); + const steps = Array.isArray(params.plan) ? params.plan : Array.isArray(params.steps) ? params.steps : null; + const text = planText(steps || params.text || params); + if (!text) return false; + const threadId = eventThreadId(payload) || state.threadId || "thread"; + const turnId = eventTurnId(payload) || state.turnId || "turn"; + const activity = ensureActivity(`plan:${threadId}:${turnId}`, { + kind: "plan", + label: "计划", + threadId, + turnId, + status: "inProgress", + concrete: true, + }); + setActivityContent(activity, "", text); + const statuses = (steps || []).map((step) => normalizeActivityStatus(step?.status, "pending")); + if (statuses.length && statuses.every((status) => status === "completed")) finishActivity(activity, "completed"); + else refreshActivity(activity); + return true; + } + + function handleDiffUpdate(payload) { + const params = eventParams(payload); + const diff = fileChangesText(params.changes || params.diff || params.patch || params.delta || params.text || params.output); + if (!diff) return false; + const threadId = eventThreadId(payload) || state.threadId || "thread"; + const turnId = eventTurnId(payload) || state.turnId || "turn"; + const activity = ensureActivity(`diff:${threadId}:${turnId}`, { + kind: "edit", + label: "文件变更", + threadId, + turnId, + status: "inProgress", + concrete: true, + }); + setActivityContent(activity, "", diff); + refreshActivity(activity); + return true; + } + + function finishActivitiesForTurn(turnId, status) { + for (const activity of state.activities.values()) { + if (!isRunningActivity(activity)) continue; + if (turnId && activity.turnId && activity.turnId !== turnId) continue; + finishActivity(activity, status); + } + } + + function turnStatusLabel(status) { + if (status === "waiting") return t("等待授权"); + if (status === "generating") return t("正在生成"); + if (status === "interrupted") return t("已中断"); + if (status === "failed") return t("失败"); + if (status === "completed") return t("已完成"); + return t("正在工作"); + } + + function statusActivityLabel(activity, flags = []) { + const normalized = String(activity || "").replace(/[\s-]+/g, "_").toLowerCase(); + if (normalized === "waiting_approval" || normalized === "waiting_for_approval" || flags.some((flag) => /approval|permission/.test(String(flag)))) return t("等待授权"); + if (normalized === "waiting_input" || normalized === "waiting_for_user_input" || flags.some((flag) => /user.?input/.test(String(flag)))) return t("正在等待你的回答"); + if (normalized === "thinking" || normalized === "reasoning") return t("正在思考"); + if (normalized === "editing" || normalized === "edit") return t("正在编辑文件"); + if (normalized === "reading" || normalized === "reading_file" || normalized === "file_read") return t("正在读取文件"); + if (normalized === "running" || normalized === "running_command" || normalized === "tool") return t("正在运行命令"); + if (normalized === "searching" || normalized === "searching_web") return t("正在搜索网页"); + if (normalized === "responding" || normalized === "generating") return t("正在生成"); + if (normalized === "failed") return t("执行失败"); + if (normalized === "interrupted") return t("已中断"); + if (normalized === "completed") return t("已完成"); + return normalized && normalized !== "idle" ? t("处理中") : ""; + } + + function updateLiveActivity(activity, startedAt, durationMs, flags = [], turnId = "") { + const element = $("liveActivity"); + if (!element) return; + const normalized = String(activity || "idle").replace(/[\s-]+/g, "_").toLowerCase(); + const terminal = ["idle", "ready", "completed", "failed", "interrupted", "cancelled", "canceled"].includes(normalized); + state.currentActivity = normalized; + state.currentActivityStartedAt = timestampMs(startedAt) || state.currentActivityStartedAt; + state.currentActivityDurationMs = finiteNumber(durationMs); + state.currentActivityTurnId = turnId || state.currentActivityTurnId || ""; + if (terminal) { + const live = state.liveActivityKey ? state.activities.get(state.liveActivityKey) : null; + // Concrete lifecycle rows are finalized by item/turn completion events. + // A status transition may only close an anonymous streaming fallback; + // closing a concrete row here can race the owner snapshot and erase its + // command/edit label before the real completion payload arrives. + if (live?.anonymous && isRunningActivity(live)) { + finishActivity(live, normalized === "failed" ? "failed" : normalized === "interrupted" ? "interrupted" : "completed", durationMs); + } + state.liveActivityKey = null; + element.hidden = true; + element.dataset.activity = normalized; + element.dataset.active = "false"; + return; + } + const label = statusActivityLabel(normalized, flags); + if (!label) { + element.hidden = true; + return; + } + // The live status node remains available to assistive technology, while + // the visible transcript is the source of truth for work-in-progress rows. + element.hidden = false; + element.dataset.activity = normalized; + element.dataset.active = "true"; + const labelElement = element.querySelector(".activity-label"); + const elapsedElement = element.querySelector(".activity-elapsed"); + const activeTranscript = latestRunningActivity( + normalized === "thinking" || normalized === "reasoning" ? "reasoning" + : normalized === "editing" || normalized === "edit" ? "edit" + : normalized === "reading" || normalized === "reading_file" || normalized === "file_read" ? "read" + : normalized === "searching" || normalized === "searching_web" ? "tool" + : normalized === "running" || normalized === "running_command" || normalized === "tool" ? "tool" : "", + { turnId: turnId || state.turnId || "" }, + ); + let visibleLabel = label; + if (activeTranscript) { + if (activeTranscript.kind === "tool" && activeTranscript.command) { + visibleLabel = uiWithRaw("正在运行 ", "Running ", terminalCommandText(activeTranscript.command)); + } else if (activeTranscript.kind === "edit") visibleLabel = t("正在编辑文件"); + else if (activeTranscript.kind === "read") visibleLabel = t("正在读取文件"); + else if (activeTranscript.kind === "reasoning") visibleLabel = t("正在思考"); + else if (activeTranscript.label) visibleLabel = activeTranscript.label; + } + if (labelElement) labelElement.textContent = visibleLabel; + const elapsed = state.currentActivityStartedAt === null + ? state.currentActivityDurationMs + : Math.max(0, Date.now() - state.currentActivityStartedAt); + if (elapsedElement) elapsedElement.textContent = elapsedDuration(elapsed); + + // Status snapshots are projections of the current turn, not work items. + // Bind to an existing concrete lifecycle row when possible. For the few + // builds that publish a status before its item, the fallback below creates + // a transient, explicitly `statusOnly` row that is removed at completion; + // waiting approvals and ordinary status ticks never become history items. + const effectiveTurnId = turnId || state.turnId || ""; + const transcriptKind = normalized === "thinking" || normalized === "reasoning" + ? "reasoning" + : normalized === "editing" || normalized === "edit" + ? "edit" + : normalized === "reading" || normalized === "reading_file" || normalized === "file_read" + ? "read" + : normalized === "running" || normalized === "running_command" || normalized === "tool" + ? "tool" + : normalized === "searching" || normalized === "searching_web" + ? "tool" + : ""; + let transcript = transcriptKind + ? latestRunningActivity(transcriptKind, { turnId: effectiveTurnId }) + : null; + if (!transcript && effectiveTurnId && (transcriptKind === "reasoning" || transcriptKind === "edit" || transcriptKind === "read")) { + // A few official builds publish the turn activity before the first + // reasoning/diff item. Materialize one stable anonymous row so the + // reader sees "正在思考"/"正在编辑文件" immediately; a later concrete + // lifecycle item is merged into the same visual stream by kind/turn. + const key = `status:${state.threadId || "thread"}:${effectiveTurnId}:${transcriptKind}`; + transcript = ensureActivity(key, { + kind: transcriptKind, + label: transcriptKind === "reasoning" ? "思考" : transcriptKind === "edit" ? "编辑文件" : "读取文件", + threadId: state.threadId, + turnId: effectiveTurnId, + startedAt: state.currentActivityStartedAt || state.turnStartedAt, + status: "inProgress", + anonymous: true, + statusOnly: true, + open: false, + }); + } + if (transcript) { + state.liveActivityKey = transcript.key; + // Keep the concrete item label (command, tool name, or edit summary) + // supplied by its lifecycle event. The global status must not overwrite + // it with a generic "正在运行命令"/"正在思考" label. + transcript.status = "inProgress"; + if (state.currentActivityStartedAt && !transcript.startedAt) transcript.startedAt = state.currentActivityStartedAt; + refreshActivity(transcript); + if (effectiveTurnId) ensureLiveTurnDivider(effectiveTurnId); + } else { + state.liveActivityKey = null; + } + } + + function refreshTurnClock() { + if (state.turnStartedAt === null) return; + const elapsed = Math.max(0, Date.now() - state.turnStartedAt); + const workElapsed = state.turnWorkStartedAt === null + ? elapsed + : Math.max(0, Date.now() - state.turnWorkStartedAt); + const activity = state.currentActivity && state.currentActivity !== "idle" + ? statusActivityLabel(state.currentActivity) + : turnStatusLabel(state.turnStatus); + const visibleElapsed = elapsedDuration(workElapsed); + setConversationStatus(visibleElapsed ? `${activity} · ${visibleElapsed}` : activity, state.turnStatus === "waiting" ? "warning" : "active"); + const divider = state.turnDividers.get(state.turnId) + || [...$("output").querySelectorAll(".turn-divider")] + .find((entry) => entry.dataset.turnId === state.turnId); + if (divider?.dataset.status === "inProgress") { + const label = divider.querySelector(".turn-divider-label"); + if (label) label.textContent = turnDividerLabel("inProgress", workElapsed); + } + updateLiveActivity(state.currentActivity || "running", state.currentActivityStartedAt || state.turnStartedAt, null, [], state.currentActivityTurnId || state.turnId); + } + + function startTurnClock(turnId, startedAt, elapsedMs) { + const nextTurnId = turnId || state.turnId || ""; + const explicitStartedAt = timestampMs(startedAt); + const explicitElapsed = finiteNumber(elapsedMs); + if (nextTurnId && state.turnId && nextTurnId !== state.turnId) { + state.turnStartedAt = null; + state.turnWorkStartedAt = null; + state.finalAssistantStartedAt = null; + state.workedDurationMs = null; + state.currentActivityStartedAt = null; + state.currentActivityDurationMs = null; + state.currentActivity = "running"; + } + if (nextTurnId) state.turnId = nextTurnId; + if (state.turnStartedAt === null) { + state.turnStartedAt = explicitStartedAt + || (explicitElapsed !== null ? Date.now() - Math.max(0, explicitElapsed) : Date.now()); + } + state.turnStatus = "active"; + state.lastTurnDurationMs = null; + state.lastWorkedDurationMs = null; + if (state.turnWorkStartedAt === null) state.turnWorkStartedAt = state.turnStartedAt; + const output = $("output"); + if (output && [...output.querySelectorAll(".message.activity")] + .some((article) => article.dataset.turnId === nextTurnId)) ensureLiveTurnDivider(nextTurnId); + refreshTurnClock(); + ensureActivityTimer(); + } + + function stopTurnClock(status = "completed", durationMs, finishedAt, workedDurationMs) { + // Some completion envelopes do not carry an item lifecycle event and do + // not leave `liveActivityKey` pointing at the transient status row. Sweep + // those rows here as a final safety net before clearing the turn clock. + retireStatusOnlyActivities(state.turnId); + const end = timestampMs(finishedAt) || Date.now(); + const explicitDuration = finiteNumber(durationMs); + const elapsed = explicitDuration !== null + ? Math.max(0, explicitDuration) + : state.turnStartedAt === null ? null : Math.max(0, end - state.turnStartedAt); + // A hydrated terminal snapshot can arrive immediately after metadata. In + // that case the metadata duration is authoritative even though the + // terminal status envelope does not repeat it. + const authoritativeWorkedDuration = finiteNumber( + workedDurationMs, + state.workedDurationMs, + state.lastWorkedDurationMs, + ); + const worked = workedDurationFor({ + workedDurationMs: authoritativeWorkedDuration, + firstTurnWorkItemStartedAtMs: state.turnWorkStartedAt, + finalAssistantStartedAtMs: state.finalAssistantStartedAt, + completedAtMs: end, + }, state.turnWorkStartedAt === null ? elapsed : Math.max(0, end - state.turnWorkStartedAt)); + state.turnStartedAt = null; + state.turnWorkStartedAt = null; + state.turnStatus = status; + state.lastTurnDurationMs = elapsed; + state.lastWorkedDurationMs = worked; + state.workedDurationMs = worked; + const duration = elapsedDuration(worked ?? elapsed); + setConversationStatus(`${turnStatusLabel(status)}${duration ? ` · ${duration}` : ""}`, status === "completed" ? "ready" : "warning"); + state.currentActivity = status; + state.currentActivityStartedAt = null; + state.currentActivityDurationMs = worked ?? elapsed; + updateLiveActivity(status, null, worked ?? elapsed, [], state.turnId); + stopActivityTimerIfIdle(); + } + + function turnStatusFromValue(value, fallback = "active") { + const normalized = normalizeActivityStatus(value, fallback); + if (normalized === "inProgress") return "active"; + if (normalized === "declined" || normalized === "interrupted") return "interrupted"; + return normalized; + } + + function applyStatusSnapshot(payload, options = {}) { + const status = isRecord(payload?.status) ? payload.status : {}; + const metadata = isRecord(payload?.metadata) ? payload.metadata : {}; + const rawTurnStatus = payload?.turnStatus ?? status.turnStatus ?? metadata.turnStatus ?? payload?.state; + const activity = String(payload?.activity ?? status.activity ?? metadata.activity ?? "").toLowerCase(); + const flags = [ + ...(Array.isArray(payload?.activeFlags) ? payload.activeFlags : []), + ...(Array.isArray(status.activeFlags) ? status.activeFlags : []), + ...(Array.isArray(metadata.activeFlags) ? metadata.activeFlags : []), + ].map((flag) => String(flag).toLowerCase()); + const turnId = payload?.turnId || state.turnId || ""; + const projectedWorkedDuration = workedDurationFor(payload, + workedDurationFor(status, workedDurationFor(metadata, null))); + const projectedWorkStart = timestampMs( + payload?.firstTurnWorkItemStartedAtMs, + payload?.workStartedAtMs, + status.firstTurnWorkItemStartedAtMs, + status.workStartedAtMs, + metadata.firstTurnWorkItemStartedAtMs, + metadata.workStartedAtMs, + ); + const projectedFinalAssistantStart = timestampMs( + payload?.finalAssistantStartedAtMs, + status.finalAssistantStartedAtMs, + metadata.finalAssistantStartedAtMs, + ); + if (projectedWorkStart !== null) state.turnWorkStartedAt = projectedWorkStart; + if (projectedFinalAssistantStart !== null) state.finalAssistantStartedAt = projectedFinalAssistantStart; + if (projectedWorkedDuration !== null) state.workedDurationMs = projectedWorkedDuration; + const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase(); + const normalized = turnStatusFromValue(rawTurnStatus || (turnId ? "active" : "idle"), turnId ? "active" : "idle"); + const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized) + || ["completed", "failed", "interrupted"].includes(normalized); + const effectiveActivity = activity || (flags.some((flag) => /approval|permission/.test(flag)) ? "waiting_approval" : turnId ? "running" : normalized); + const explicitlyActive = !terminal && (Boolean(turnId) + || ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "readingfile", "fileread", "searching", "responding", "generating"].includes(activity.replace(/[\s_-]+/g, "")) + || normalized === "active"); + if (explicitlyActive) { + startTurnClock( + turnId, + payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs, + payload?.elapsedMs ?? status.elapsedMs ?? metadata.elapsedMs, + ); + state.turnStatus = flags.some((flag) => /approval|permission|input|waiting/.test(flag)) ? "waiting" : "active"; + state.currentActivity = effectiveActivity; + updateLiveActivity(effectiveActivity, payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs ?? state.turnStartedAt, payload?.durationMs ?? status.durationMs ?? metadata.durationMs, flags, turnId); + refreshTurnClock(); + return; + } + if (options.allowTerminal === false) return; + const duration = payload?.durationMs ?? status.durationMs ?? metadata.durationMs; + if (["completed", "interrupted", "failed"].includes(normalized) || terminal) { + stopTurnClock(normalized, duration, payload?.completedAtMs ?? status.completedAtMs ?? metadata.completedAtMs, projectedWorkedDuration); + } + else if (state.turnStartedAt === null && options.showIdle !== false) setConversationStatus("ready"); + } + + function snapshotStatusProjection(snapshot = {}, appState = {}) { + const snapshotRecord = isRecord(snapshot) ? snapshot : {}; + const stateRecord = isRecord(appState) ? appState : {}; + const statusRecord = [ + snapshotRecord.executionStatus, + snapshotRecord.status, + stateRecord.executionStatus, + stateRecord.status, + ].find(isRecord) || {}; + const rawTurnStatus = firstDefined( + snapshotRecord.turnStatus, + statusRecord.turnStatus, + statusRecord.status, + stateRecord.turnStatus, + typeof stateRecord.status === "string" ? stateRecord.status : undefined, + ); + const rawActivity = firstString( + snapshotRecord.activity, + statusRecord.activity, + stateRecord.activity, + ).toLowerCase(); + const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase(); + const fallbackStatus = rawActivity === "completed" || rawActivity === "complete" || rawActivity === "done" + ? "completed" + : rawActivity === "failed" || rawActivity === "error" + ? "failed" + : rawActivity === "interrupted" || rawActivity === "cancelled" || rawActivity === "canceled" + ? "interrupted" + : snapshotRecord.turnId || stateRecord.activeTurnId ? "active" : "idle"; + const normalized = turnStatusFromValue(rawTurnStatus ?? fallbackStatus, fallbackStatus); + const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized) + || ["completed", "failed", "interrupted"].includes(normalized) + || ["completed", "failed", "interrupted"].includes(rawActivity); + const activeActivity = ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "reading_file", "file_read", "searching", "searching_web", "responding", "generating"].includes(rawActivity.replace(/[\s-]+/g, "_")); + const explicitTurnId = snapshotRecord.turnId !== undefined + ? snapshotRecord.turnId + : stateRecord.activeTurnId; + return { + normalized, + terminal, + hasActiveTurn: Boolean(explicitTurnId) || activeActivity || normalized === "active" || normalized === "waiting", + durationMs: finiteNumber( + snapshotRecord.durationMs, + snapshotRecord.elapsedMs, + statusRecord.durationMs, + statusRecord.elapsedMs, + stateRecord.durationMs, + stateRecord.elapsedMs, + ), + workedDurationMs: workedDurationFor(snapshotRecord, + workedDurationFor(statusRecord, workedDurationFor(stateRecord, null))), + firstTurnWorkItemStartedAtMs: timestampMs( + snapshotRecord.firstTurnWorkItemStartedAtMs, + snapshotRecord.workStartedAtMs, + statusRecord.firstTurnWorkItemStartedAtMs, + statusRecord.workStartedAtMs, + stateRecord.firstTurnWorkItemStartedAtMs, + stateRecord.workStartedAtMs, + ), + finalAssistantStartedAtMs: timestampMs( + snapshotRecord.finalAssistantStartedAtMs, + statusRecord.finalAssistantStartedAtMs, + stateRecord.finalAssistantStartedAtMs, + ), + completedAtMs: timestampMs(snapshotRecord.completedAtMs, statusRecord.completedAtMs, stateRecord.completedAtMs), + }; + } + + // A late authoritative snapshot can arrive after replayed lifecycle events. + // Once it says there is no active turn, clear only the transient projection; + // hydrated history remains intact and a locally queued user message is kept. + function reconcileSnapshotTerminalState(snapshot = {}, appState = {}) { + const projection = snapshotStatusProjection(snapshot, appState); + if (!projection.terminal || projection.hasActiveTurn || state.pendingUserText) return; + const hadTransientTurn = Boolean( + state.turnId + || state.turnStartedAt !== null + || state.currentActivity === "active" + || state.currentActivity === "running" + || state.currentActivity === "working" + || state.currentActivity === "thinking" + || state.currentActivity === "editing" + || state.currentActivity === "generating" + || [...state.activities.values()].some(isRunningActivity), + ); + if (!hadTransientTurn) return; + const finishedTurnId = state.turnId; + finishAssistantStream(); + finishActivitiesForTurn(finishedTurnId, projection.normalized); + stopTurnClock(projection.normalized, projection.durationMs, projection.completedAtMs, projection.workedDurationMs); + state.turnId = ""; + state.currentActivityTurnId = ""; + state.currentActivityStartedAt = null; + state.currentActivityDurationMs = projection.durationMs; + updateIds(); + } + + function appendFileChangeChunk(payload, text) { + if (!text) return false; + const params = eventParams(payload); + const threadId = eventThreadId(payload) || state.threadId || "thread"; + const turnId = eventTurnId(payload) || state.turnId || "turn"; + const itemId = params.itemId ?? payload?.itemId; + let activity = latestRunningActivity("edit", { itemId, turnId }); + if (!activity) { + const key = itemId === undefined + ? `diff:${threadId}:${turnId}` + : `${threadId}:${turnId}:edit:${typeof itemId}:${String(itemId)}`; + activity = ensureActivity(key, { + kind: "edit", + label: "编辑文件", + itemId, + threadId, + turnId, + status: "inProgress", + concrete: true, + }); + } + appendActivityChunk(activity, text); + return true; + } + + function refreshElapsedDisplays() { + refreshTurnClock(); + for (const activity of state.activities.values()) if (isRunningActivity(activity)) refreshActivity(activity); + refreshSubagentElapsed(); + if (state.currentActivity && state.currentActivity !== "idle") { + updateLiveActivity( + state.currentActivity, + state.currentActivityStartedAt || state.turnStartedAt, + state.currentActivityDurationMs, + [], + state.currentActivityTurnId || state.turnId, + ); + } + stopActivityTimerIfIdle(); + } + + function ensureActivityTimer() { + if (state.activityTimer !== null) return; + state.activityTimer = setInterval(refreshElapsedDisplays, 250); + } + + function stopActivityTimerIfIdle() { + if (state.turnStartedAt !== null || [...state.activities.values()].some(isRunningActivity)) return; + if (state.activityTimer !== null) clearInterval(state.activityTimer); + state.activityTimer = null; + } + + function renderEmptyOutput() { + const output = $("output"); + output.replaceChildren(); + output.dataset.outputTail = ""; + state.activeAssistantBody = null; + state.activeAssistantStream = null; + state.activeAssistantText = ""; + state.activeAssistantActivityKey = null; + state.activities.clear(); + state.turnDividers.clear(); + state.liveActivityKey = null; + state.pendingUserArticle = null; + state.lastRenderedDateKey = ""; + state.lastRenderedTimestamp = null; + state.lastRenderedRole = ""; + state.hasRenderedUser = false; + state.lastDateSeparatorTimestamp = null; + state.outputDistanceFromBottom = 0; + state.structuredMessages = []; + stopActivityTimerIfIdle(); + } + + function appendOutput(text, tone) { + if (!text) return; + const visibleText = tone === "error" || tone === "meta" ? t(text) : text; + if (tone === "meta") { + setConversationStatus(String(visibleText)); + return; + } + finishAssistantStream(); + const role = tone === "error" ? "error" : tone === "meta" ? "system" : "assistant"; + appendMessage(visibleText, role, tone || "text", tone === "meta" ? t("状态") : ""); + } + + function setConversationStatus(text, tone = "ready") { + const value = t(text || ""); + const status = $("appState"); + if (status) { + status.textContent = value; + status.dataset.tone = tone; + } + const hint = $("outputHint"); + if (hint && value) hint.textContent = value; + } + + function sessionCommandMethod(value) { + return String(value || "").trim().replace(/\./g, "/").toLowerCase(); + } + + function sessionErrorMessage(value, fallback = "会话操作失败") { + const source = isRecord(value) ? value : {}; + const error = isRecord(source.error) ? source.error : {}; + const code = firstString(source.code, error.code).toLowerCase(); + const message = value instanceof Error + ? value.message + : firstString(source.message, error.message, typeof value === "string" ? value : ""); + const normalized = `${code} ${message}`.toLowerCase(); + if (/app_not_ready|app-server is not ready|waiting_for_host/.test(normalized)) return "等待 VS Code 主机连接"; + if (/host_unavailable|host_disconnected|vscode host is disconnected/.test(normalized)) return "VS Code 主机未连接"; + if (/mode_switch_pending/.test(normalized)) return "正在切换控制模式"; + if (/mode_busy|cannot switch control mode/.test(normalized)) return "当前任务或请求完成后才能切换控制模式"; + if (/session_busy|turn_active|running turn|pending request/.test(normalized)) return "当前任务结束或请求处理后才能切换"; + if (/timed out waiting for a snapshot|snapshot from vscode|找不到会话.*owner|no live vscode owner/.test(normalized)) { + return "目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它"; + } + if (/method_not_allowed/.test(normalized)) return "当前 relay 版本不支持此会话操作,请重启 relay"; + return message || fallback; + } + + function setSessionSwitchingVisual(switching) { + const active = Boolean(switching); + const panel = document.querySelector(".chat-panel"); + if (panel) panel.dataset.sessionSwitching = String(active); + const output = $("output"); + if (output) output.setAttribute("aria-busy", String(active)); + updateIds(); + renderRequests(); + } + + function sessionSwitchTargetTitle(threadId) { + const id = String(threadId || ""); + if (!id || !Array.isArray(state.sessions)) return ""; + const entry = state.sessions.find((candidate) => sessionEntryId(candidate) === id); + return entry ? sessionEntryTitle(entry) : ""; + } + + function beginSessionSwitchContext(threadId, title = "") { + const targetThreadId = String(threadId || ""); + if (state.sessionSwitchContext) { + if (!targetThreadId || state.sessionSwitchContext.targetThreadId === targetThreadId) { + return state.sessionSwitchContext; + } + // A newer VS Code navigation can supersede an in-flight target. Retain + // the original fallback, but reset both completion gates for the new + // target so an acknowledgement/snapshot from the older route cannot + // unlock the composer. + state.sessionSwitchContext.targetThreadId = targetThreadId; + state.sessionSwitchContext.targetTitle = String(title || sessionSwitchTargetTitle(targetThreadId) || ""); + state.sessionSwitchContext.targetSnapshotReady = false; + state.sessionSwitchContext.selectedAckReady = false; + return state.sessionSwitchContext; + } + const titleNode = $("threadTitle"); + const previousThreadId = String(state.threadId || state.syncedThreadId || ""); + state.sessionSwitchContext = { + previousThreadId, + previousTitle: titleNode?.textContent || "Codex", + targetThreadId, + targetTitle: String(title || sessionSwitchTargetTitle(targetThreadId) || ""), + targetSnapshotReady: false, + selectedAckReady: false, + }; + return state.sessionSwitchContext; + } + + function finishSessionSwitchContext() { + state.sessionSwitchContext = null; + setSessionSwitchingVisual(false); + } + + function restoreSessionSwitchContext() { + const context = state.sessionSwitchContext; + if (!context) { + state.sessionSwitching = false; + state.sessionSelectCommandId = ""; + setSessionSwitchingVisual(false); + return; + } + const titleNode = $("threadTitle"); + if (titleNode && context.previousTitle) titleNode.textContent = context.previousTitle; + if (context.previousThreadId) { + state.threadId = context.previousThreadId; + state.sessionSelectedThreadId = context.previousThreadId; + } else { + state.sessionSelectedThreadId = ""; + } + state.sessionSwitching = false; + state.sessionSelectCommandId = ""; + finishSessionSwitchContext(); + } + + function failSessionSwitch(value, fallback = "会话切换失败") { + // A target projection may arrive before the adapter's final owner check. + // A later failure must still restore the old routing context; treating the + // early snapshot as success strands the browser on an unconfirmed target. + restoreSessionSwitchContext(); + return sessionErrorMessage(value, fallback); + } + + function finishSessionSwitchIfReady() { + const context = state.sessionSwitchContext; + if (!context || !context.targetSnapshotReady || !context.selectedAckReady) return false; + const targetThreadId = String(context.targetThreadId || ""); + if (!targetThreadId || state.syncedThreadId !== targetThreadId) return false; + const titleNode = $("threadTitle"); + if (context.targetTitle && titleNode) titleNode.textContent = context.targetTitle; + state.sessionSelectedThreadId = ""; + state.sessionSwitching = false; + state.sessionSelectCommandId = ""; + finishSessionSwitchContext(); + syncSessionActive(targetThreadId); + return true; + } + + function sessionEntryId(entry) { + if (!isRecord(entry)) return ""; + const thread = isRecord(entry.thread) ? entry.thread : {}; + return firstString(entry.threadId, entry.conversationId, entry.conversation_id, entry.id, + thread.threadId, thread.conversationId, thread.conversation_id, thread.id); + } + + function sessionEntryTitle(entry) { + if (!isRecord(entry)) return ""; + const thread = isRecord(entry.thread) ? entry.thread : {}; + return firstString(entry.title, entry.name, entry.preview, entry.firstUserMessage, entry.first_user_message, + entry.threadTitle, entry.thread_name, thread.title, thread.name, thread.preview, thread.thread_name); + } + + function sessionEntryCwd(entry) { + if (!isRecord(entry)) return ""; + const thread = isRecord(entry.thread) ? entry.thread : {}; + return firstString(entry.cwd, entry.workspace, entry.workspacePath, entry.workspace_path, + thread.cwd, thread.workspace, thread.workspacePath, thread.workspace_path); + } + + function sessionEntryUpdatedAt(entry) { + if (!isRecord(entry)) return null; + const thread = isRecord(entry.thread) ? entry.thread : {}; + // App-server history is ordered by recency_at. Older relay versions only + // expose updatedAt, so keep those fields as a compatibility fallback. + return timestampMs( + entry.recencyAtMs, entry.recencyAt, entry.recency_at_ms, entry.recency_at, + thread.recencyAtMs, thread.recencyAt, thread.recency_at_ms, thread.recency_at, + entry.updatedAtMs, entry.updatedAt, entry.lastUpdatedAtMs, entry.lastUpdatedAt, + entry.updated_at_ms, entry.updated_at, entry.last_updated_at, + entry.mtime, entry.modifiedAt, thread.updatedAtMs, thread.updatedAt, + thread.updated_at_ms, thread.updated_at, + ); + } + + function sessionEntryStatus(entry) { + if (!isRecord(entry)) return { kind: "idle", label: "", active: false, attention: false, unread: false }; + const thread = isRecord(entry.thread) ? entry.thread : {}; + const nestedStatus = isRecord(entry.status) ? entry.status : {}; + const nestedExecutionStatus = isRecord(entry.executionStatus) ? entry.executionStatus : {}; + const nestedThreadStatus = isRecord(thread.status) ? thread.status : {}; + let rawStatus = firstString( + entry.activity, entry.activityStatus, entry.status, entry.executionStatus, entry.turnStatus, + entry.threadRuntimeStatus, entry.thread_runtime_status, entry.runtimeStatus, entry.lastTurnStatus, + entry.last_turn_status, entry.phase, entry.state, + nestedStatus.activity, nestedStatus.type, nestedStatus.status, nestedStatus.kind, + nestedExecutionStatus.activity, nestedExecutionStatus.type, nestedExecutionStatus.status, nestedExecutionStatus.kind, + nestedThreadStatus.activity, nestedThreadStatus.type, nestedThreadStatus.status, nestedThreadStatus.kind, + thread.threadRuntimeStatus, thread.thread_runtime_status, thread.turnStatus, thread.lastTurnStatus, thread.state, + ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); + if ((!rawStatus || rawStatus === "idle") && entry.active) { + rawStatus = firstString( + state.currentActivity !== "idle" ? state.currentActivity : "", + state.turnStatus, + state.turnId ? "working" : "", + ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); + } + const unread = [ + entry.hasUnreadTurn, entry.has_unread_turn, entry.unread, entry.isUnread, + entry.needsAttention, entry.needs_attention, thread.hasUnreadTurn, thread.has_unread_turn, + ].some((value) => value === true || value === 1 || ["true", "1", "yes"].includes(String(value || "").toLowerCase())); + let kind = "idle"; + if (entry.isApproval === true || /approval|permission|request_approval|awaiting_authorization|needs_authorization|requires_action/.test(rawStatus)) kind = "approval"; + else if (entry.isWaiting === true || /needs?_?input|waiting_for_input|pending_input|pending|queued/.test(rawStatus)) kind = "waiting"; + else if (entry.isEditing === true || /edit|apply_patch|file_change|writing/.test(rawStatus)) kind = "editing"; + else if (entry.isThinking === true || /think|reason/.test(rawStatus)) kind = "thinking"; + else if (entry.isWorking === true || entry.isRunning === true || /run|stream|working|in_progress|active|busy|generat/.test(rawStatus)) kind = "working"; + else if (/error|fail|cancel|interrupt/.test(rawStatus)) kind = "error"; + else if (unread) kind = "unread"; + const labels = { + approval: "等待授权", + waiting: "等待输入", + editing: "编辑中", + thinking: "思考中", + working: "进行中", + error: "异常", + unread: "未读", + idle: "", + }; + return { + kind, + label: labels[kind] || "", + active: ["approval", "waiting", "editing", "thinking", "working"].includes(kind), + attention: ["approval", "waiting", "error", "unread"].includes(kind) || unread, + unread, + }; + } + + function sessionEntrySearchText(entry) { + if (!isRecord(entry)) return ""; + const thread = isRecord(entry.thread) ? entry.thread : {}; + const status = sessionEntryStatus(entry); + return [ + sessionEntryTitle(entry), sessionEntryCwd(entry), sessionEntryId(entry), + status.label, entry.mode, entry.source, entry.threadSource, thread.mode, + ].filter((value) => typeof value === "string" && value.trim()).join(" ").toLowerCase(); + } + + function sessionOptionDomId(threadId) { + try { + return `session-option-${encodeURIComponent(String(threadId)).replace(/%/g, "_")}`; + } catch { + return `session-option-${String(threadId).replace(/[^a-z0-9_-]/gi, "_")}`; + } + } + + function sessionEntryIsActive(entry, activeId = "") { + if (!isRecord(entry)) return false; + const id = sessionEntryId(entry); + return id === activeId || entry.active === true || entry.active === 1 + || ["true", "1", "yes"].includes(String(entry.active || "").toLowerCase()); + } + + function sessionEntryIsAvailable(entry) { + return isRecord(entry) + && entry.available !== false + && entry.canAttach !== false + && entry.attachable !== false; + } + + function sessionIsSelectable(entry, activeId, canSwitch) { + if (!isRecord(entry)) return false; + const id = sessionEntryId(entry); + const available = sessionEntryIsAvailable(entry); + const current = sessionEntryIsActive(entry, activeId); + return Boolean(id && available && !current && canSwitch && !state.sessionSwitching); + } + + function filteredSessionEntries() { + const source = Array.isArray(state.sessions) + ? state.sessions + .filter(isRecord) + .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) + .map((entry, index) => ({ entry, index })) + : []; + source.sort((left, right) => { + const rightTime = sessionEntryUpdatedAt(right.entry) ?? 0; + const leftTime = sessionEntryUpdatedAt(left.entry) ?? 0; + return rightTime - leftTime || left.index - right.index; + }); + const ordered = source.map(({ entry }) => entry); + const query = String(state.sessionSearch || "").trim().toLowerCase(); + return query ? ordered.filter((entry) => sessionEntrySearchText(entry).includes(query)) : ordered; + } + + function sessionPathLabel(value) { + const text = String(value || "").trim(); + if (!text) return t("本地会话"); + const parts = text.split(/[\\/]+/).filter(Boolean); + return parts.length > 1 ? `${t("工作区")} · ${parts[parts.length - 1]}` : text; + } + + function sessionTimeLabel(value) { + const timestamp = timestampMs(value); + if (timestamp === null) return ""; + try { + const date = new Date(timestamp); + const now = new Date(); + const day = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); + const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + const difference = Math.round((today - day) / 86_400_000); + const locale = uiLocale(); + const time = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(date); + if (difference === 0) return time; + if (difference === 1) return `${t("昨天")} ${time}`; + if (difference > 1 && difference < 7) return `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date)} ${time}`; + return `${new Intl.DateTimeFormat(locale, { month: "numeric", day: "numeric" }).format(date)} ${time}`; + } catch { + return ""; + } + } + + function renderSessionPicker() { + const list = $("sessionList"); + const status = $("sessionPickerStatus"); + const picker = $("sessionPicker"); + if (!list || !status || !picker) return; + renderControlMode(); + const activeId = String(state.threadId || state.syncedThreadId || ""); + const canSwitch = sessionControlAllowed("sessionSelect") + && state.appReady + && state.ws?.readyState === WebSocket.OPEN + && !state.turnId + && state.requests.size === 0; + const allSessions = Array.isArray(state.sessions) + ? state.sessions.filter(isRecord).filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) + : []; + const sessions = filteredSessionEntries(); + const query = String(state.sessionSearch || "").trim(); + const selectableIds = sessions + .filter((entry) => sessionIsSelectable(entry, activeId, canSwitch)) + .map((entry) => sessionEntryId(entry)); + if (!selectableIds.includes(state.sessionFocusedId)) { + state.sessionFocusedId = selectableIds[0] || ""; + } + const searchInput = $("sessionSearchInput"); + const searchClear = $("sessionSearchClear"); + if (searchInput) { + if (searchInput.value !== state.sessionSearch) searchInput.value = state.sessionSearch; + searchInput.setAttribute("aria-expanded", String(state.sessionPickerOpen)); + searchInput.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : ""); + } + if (searchClear) searchClear.hidden = !query; + list.replaceChildren(); + picker.dataset.switching = String(state.sessionSwitching); + list.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : ""); + status.dataset.tone = state.sessionListError ? "warning" : ""; + if (state.sessionListLoading && !sessions.length) status.textContent = t("正在读取会话…"); + else if (state.sessionSwitching) { + const targetTitle = state.sessionSwitchContext?.targetTitle; + status.textContent = targetTitle + ? uiLocale() === "en-US" ? `Switching to “${targetTitle}”...` : `正在切换到「${targetTitle}」…` + : t("正在切换会话…"); + } + else if (state.sessionListError) status.textContent = t(state.sessionListError); + else if (!canSwitch && sessions.length > 1) status.textContent = t("当前任务结束或请求处理后才能切换"); + else if (query) status.textContent = uiLocale() === "en-US" + ? `${sessions.length}/${allSessions.length} conversations` + : `${sessions.length}/${allSessions.length} 个会话`; + else status.textContent = sessions.length + ? (uiLocale() === "en-US" ? `${sessions.length} conversations` : `${sessions.length} 个会话`) + : ""; + + if (!sessions.length && !state.sessionListLoading) { + const empty = document.createElement("div"); + empty.className = "session-list-empty"; + const listError = String(state.sessionListError || ""); + empty.textContent = listError === "等待 VS Code 主机连接" + ? t("等待 VS Code 伴随扩展连接") + : listError === "VS Code 主机未连接" + ? t("VS Code 伴随扩展未连接") + : listError === "等待 relay 连接" + ? t("等待 relay 连接") + : listError + ? t("无法读取会话") + : query + ? t("没有匹配的会话") + : t(state.attachMode ? "没有可附加的会话" : "没有可控制的会话"); + list.append(empty); + return; + } + for (const raw of sessions) { + if (!isRecord(raw)) continue; + const id = sessionEntryId(raw); + if (!id) continue; + const title = sessionEntryTitle(raw) || `${t("会话")} ${id.slice(0, 8)}`; + const cwd = sessionEntryCwd(raw); + const updated = sessionEntryUpdatedAt(raw); + const current = sessionEntryIsActive(raw, activeId); + const available = sessionEntryIsAvailable(raw); + const statusInfo = sessionEntryStatus(raw); + const switchingTarget = state.sessionSwitching && id === state.sessionSelectedThreadId; + const selectable = sessionIsSelectable(raw, activeId, canSwitch); + const option = document.createElement("button"); + option.type = "button"; + option.className = "session-option"; + option.id = sessionOptionDomId(id); + option.dataset.available = String(available); + option.dataset.threadId = id; + option.dataset.status = statusInfo.kind; + option.dataset.unread = String(statusInfo.unread); + option.dataset.switching = String(switchingTarget); + option.dataset.focused = String(id === state.sessionFocusedId); + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(current)); + option.setAttribute("aria-disabled", String(!selectable)); + option.disabled = !selectable; + const titleNode = document.createElement("span"); + titleNode.className = "session-option-title"; + titleNode.textContent = title; + titleNode.title = title; + const timeNode = document.createElement("span"); + timeNode.className = "session-option-time"; + timeNode.textContent = sessionTimeLabel(updated); + const metaNode = document.createElement("span"); + metaNode.className = "session-option-meta"; + metaNode.textContent = sessionPathLabel(cwd); + metaNode.title = cwd || title; + const stateNode = document.createElement("span"); + stateNode.className = "session-option-state"; + const stateLabels = []; + if (switchingTarget) stateLabels.push(t("正在切换")); + else if (!available) stateLabels.push(t("未打开")); + else if (current) stateLabels.push(t("当前")); + else if (!statusInfo.label) stateLabels.push(t("可切换")); + if (available && statusInfo.label && (!current || statusInfo.active || statusInfo.attention)) stateLabels.push(t(statusInfo.label)); + const stateDot = document.createElement("span"); + stateDot.className = "session-status-dot"; + stateDot.setAttribute("aria-hidden", "true"); + stateNode.append(stateDot, document.createTextNode(stateLabels.join(" · "))); + option.append(titleNode, timeNode, metaNode, stateNode); + option.setAttribute("aria-label", `${title}, ${stateLabels.join(", ") || t("会话")}`); + option.addEventListener("mouseenter", () => { + if (option.disabled) return; + state.sessionFocusedId = id; + for (const peer of list.querySelectorAll(".session-option")) peer.dataset.focused = String(peer.dataset.threadId === id); + list.setAttribute("aria-activedescendant", sessionOptionDomId(id)); + }); + if (!option.disabled) option.addEventListener("click", () => { + state.sessionFocusedId = id; + selectSession(id, title); + }); + list.append(option); + } + } + + function sessionPickerOptions() { + return [...document.querySelectorAll("#sessionList .session-option")] + .filter((option) => !option.disabled && option.dataset.threadId); + } + + function setSessionFocus(threadId, { scroll = true } = {}) { + const id = String(threadId || ""); + state.sessionFocusedId = id; + const list = $("sessionList"); + if (!list) return; + const options = list.querySelectorAll(".session-option"); + for (const option of options) option.dataset.focused = String(option.dataset.threadId === id); + list.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : ""); + $("sessionSearchInput")?.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : ""); + if (scroll) { + const target = [...list.querySelectorAll(".session-option")] + .find((option) => option.dataset.threadId === id); + target?.scrollIntoView?.({ block: "nearest" }); + } + } + + function moveSessionFocus(delta) { + const options = sessionPickerOptions(); + if (!options.length) return false; + let index = options.findIndex((option) => option.dataset.threadId === state.sessionFocusedId); + if (index < 0) index = delta >= 0 ? -1 : 0; + index = (index + delta + options.length) % options.length; + setSessionFocus(options[index].dataset.threadId); + return true; + } + + function activateFocusedSession() { + const id = state.sessionFocusedId; + if (!id) return false; + const entry = (Array.isArray(state.sessions) ? state.sessions : []) + .find((candidate) => sessionEntryId(candidate) === id); + if (!entry) return false; + const activeId = String(state.threadId || state.syncedThreadId || ""); + const canSwitch = !state.turnId && state.requests.size === 0; + if (!sessionIsSelectable(entry, activeId, canSwitch)) return false; + selectSession(id, sessionEntryTitle(entry)); + return true; + } + + function handleSessionPickerKeydown(event) { + if (!state.sessionPickerOpen) return; + if (event.key === "ArrowDown") { + if (moveSessionFocus(1)) event.preventDefault(); + return; + } + if (event.key === "ArrowUp") { + if (moveSessionFocus(-1)) event.preventDefault(); + return; + } + if (event.key === "Home") { + const options = sessionPickerOptions(); + if (options.length) { + setSessionFocus(options[0].dataset.threadId); + event.preventDefault(); + } + return; + } + if (event.key === "End") { + const options = sessionPickerOptions(); + if (options.length) { + setSessionFocus(options[options.length - 1].dataset.threadId); + event.preventDefault(); + } + return; + } + if (event.key === "Enter") { + if (activateFocusedSession()) event.preventDefault(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setSessionPicker(false); + } + } + + function setSessionPicker(open) { + const picker = $("sessionPicker"); + const button = $("sessionPickerButton"); + if (!picker || !button) return; + const next = Boolean(open) && sessionControlAllowed("sessionList") && !state.modeSwitching; + picker.hidden = !next; + state.sessionPickerOpen = next; + button.setAttribute("aria-expanded", String(next)); + if (next) { + state.sessionSearch = ""; + state.sessionFocusedId = ""; + $("panelMenu").hidden = true; + $("detailsPopover").hidden = true; + renderSessionPicker(); + requestSessionList(); + scheduleFrame(() => { + if (state.sessionPickerOpen) $("sessionSearchInput")?.focus(); + }); + } else { + state.sessionSearch = ""; + state.sessionFocusedId = ""; + const input = $("sessionSearchInput"); + if (input) { + input.value = ""; + input.setAttribute("aria-expanded", "false"); + input.setAttribute("aria-activedescendant", ""); + } + const clear = $("sessionSearchClear"); + if (clear) clear.hidden = true; + if (picker.contains(document.activeElement)) button.focus(); + } + } + + function openSessionHistory() { + if (!sessionControlAllowed("sessionList")) return; + setSessionPicker(true); + } + + function requestControlMode(value) { + const mode = normalizeControlMode(value); + if (!mode || mode === state.controlMode || controlModeChangeBlocked()) return; + closePopovers(); + state.modeSwitching = true; + state.requestedControlMode = mode; + state.modeRequestEpoch = state.modeEpoch; + setConversationStatus("正在切换控制模式", "active"); + updateIds(); + try { + state.modeCommandId = command("control/mode/set", { mode }); + } catch (error) { + clearControlModeRequest(); + setConversationStatus(error?.message || "控制模式切换失败", "warning"); + updateIds(); + } + } + + function requestNewSession() { + closePopovers(); + if (!sessionControlAllowed("sessionCreate")) { + setConversationStatus("同步模式下会话管理由 VS Code 控制", "warning"); + return; + } + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { + setConversationStatus("等待 relay 连接", "warning"); + return; + } + if (!state.appReady) { + setConversationStatus("等待 VS Code 主机连接", "warning"); + return; + } + if (state.role !== "operator" && state.role !== "owner" && state.role !== "host") { + setConversationStatus("当前角色不能创建会话", "warning"); + return; + } + if (state.newSessionCommandId) return; + try { + state.newSessionCommandId = command("session/new", {}); + setConversationStatus("正在创建新会话", "active"); + updateIds(); + } catch (error) { + state.newSessionCommandId = ""; + setConversationStatus(sessionErrorMessage(error, "无法创建新会话"), "warning"); + updateIds(); + } + } + + function requestSessionList() { + if (!sessionControlAllowed("sessionList")) return; + if (state.sessionListLoading) { + renderSessionPicker(); + return; + } + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { + state.sessionListError = "等待 relay 连接"; + renderSessionPicker(); + return; + } + if (!state.appReady) { + state.sessionListError = "等待 VS Code 主机连接"; + renderSessionPicker(); + return; + } + state.sessionListLoading = true; + state.sessionListError = ""; + renderSessionPicker(); + try { + state.sessionListCommandId = command("session/list", {}); + } catch (error) { + state.sessionListLoading = false; + state.sessionListError = sessionErrorMessage(error, "无法读取会话"); + renderSessionPicker(); + } + } + + function selectSession(threadId, title = "") { + const id = String(threadId || "").trim(); + if (!sessionControlAllowed("sessionSelect")) return; + if (!id || state.sessionSwitching || id === state.threadId) return; + if (state.turnId || state.requests.size) { + state.sessionListError = "当前任务仍在运行或等待授权,暂不能切换"; + renderSessionPicker(); + return; + } + const context = beginSessionSwitchContext(id, title); + state.sessionSwitching = true; + state.sessionListError = ""; + setSessionSwitchingVisual(true); + renderSessionPicker(); + try { + state.sessionSelectCommandId = command("session/select", { threadId: id }); + setConversationStatus( + context.targetTitle + ? uiLocale() === "en-US" ? `Switching to “${context.targetTitle}”` : `正在切换到「${context.targetTitle}」` + : t("正在切换会话"), + "active", + ); + } catch (error) { + restoreSessionSwitchContext(); + state.sessionListError = sessionErrorMessage(error, "会话切换失败"); + renderSessionPicker(); + } + } + + function applySessionListResult(result) { + const body = isRecord(result) ? result : {}; + const source = Array.isArray(result) ? result : Array.isArray(body.sessions) ? body.sessions : Array.isArray(body.threads) ? body.threads : []; + const activeId = firstString(body.activeThreadId, body.threadId); + state.sessions = source + .filter((entry) => isRecord(entry)) + .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) + .map((entry) => ({ ...entry })); + if (activeId) state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === activeId || entry.active === true })); + state.sessionListLoading = false; + state.sessionListCommandId = ""; + state.sessionListError = ""; + renderSessionPicker(); + } + + function applySessionSelectResult(result) { + const body = isRecord(result) ? result : {}; + const selected = firstString(body.threadId, body.activeThreadId); + state.sessionSelectedThreadId = selected; + if (selected && state.sessions.length) { + state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === selected })); + } + // A command result confirms only that the command completed. The switch + // itself stays fenced until both the sequenced `session.selected` event + // and the target's authoritative projection have independently arrived. + // This also keeps automatic VS Code navigation and picker navigation on + // the same completion path. + if (state.sessionSwitchContext) { + state.sessionSwitching = true; + setSessionSwitchingVisual(true); + const context = state.sessionSwitchContext; + setConversationStatus( + context.targetSnapshotReady ? "正在确认会话" : "正在加载会话", + "active", + ); + } else { + state.sessionSwitching = false; + state.sessionSelectCommandId = ""; + finishSessionSwitchContext(); + } + renderSessionPicker(); + if (!state.sessionSwitching) { + setSessionPicker(false); + setConversationStatus("会话已切换", "ready"); + } + requestRefresh(); + } + + function syncSessionActive(threadId) { + const activeId = String(threadId || ""); + if (!activeId || !Array.isArray(state.sessions) || !state.sessions.length) return; + state.sessions = state.sessions.map((entry) => ({ + ...entry, + active: sessionEntryId(entry) === activeId, + })); + renderSessionPicker(); + } + + function messageText(item) { + if (!isRecord(item)) return ""; + const direct = [item.text, item.content, item.message, item.summary, item.output] + .find((value) => typeof value === "string" && value.length); + if (direct) return direct; + if (Array.isArray(item.content)) { + return item.content.map((part) => { + if (typeof part === "string") return part; + if (!isRecord(part)) return ""; + return part.text || part.content || part.value || ""; + }).filter(Boolean).join("\n"); + } + // Official collaboration records are often state-only projections: they + // carry an agent id/name and lifecycle kind, but no user-facing `text`. + // Keep those rows in the transcript so the sub-agent disclosure and panel + // can be hydrated from the same snapshot. + if (historyKind(item) === "subagent") { + const agent = isRecord(item.agent) ? item.agent : {}; + const name = firstString( + item.displayName, + item.agentNickname, + item.agentName, + item.agentPath, + agent.displayName, + agent.name, + item.agentThreadId ? `thread ${item.agentThreadId}` : "子代理", + ); + const objective = firstString( + item.objective, + item.prompt, + item.statusMessage, + item.description, + agent.objective, + agent.prompt, + ); + const action = firstString(item.action, item.tool, item.activityKind, agent.action); + const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status); + const normalizedStatus = normalizeSubagentStatus(rawStatus); + const statusText = subagentStatusLabel(normalizedStatus); + if (objective && name) return `${name}:${objective}`; + if (action && name) return `${name} · ${action}`; + if (name) return `${name} · ${statusText}`; + return `子代理 · ${statusText}`; + } + if (isReadActivity(item)) { + return firstString(...readPathList(item), commandText(item), "读取文件"); + } + if (/(?:command|exec|process|tool)/i.test(String(item.type || item.kind || ""))) { + return firstString(commandText(item), "工具输出"); + } + const fallback = [item.reasoning, item.plan, item.steps, item.diff, item.patch, item.description] + .map((value) => displayValue(value)) + .find((value) => value); + if (fallback) return fallback; + return ""; + } + + function historyKind(item) { + const sourceKind = String(item?.kind || item?.type || "assistant").toLowerCase(); + if (item?.uiType === "subagent-activity" || item?.uiType === "multi-agent-action" + || sourceKind.includes("subagent") || sourceKind.includes("collabagent")) return "subagent"; + if (item?.role === "user" || sourceKind.includes("user")) return "user"; + if (isReadActivity(item)) return "read"; + if (sourceKind.includes("edit") || sourceKind.includes("filechange") || sourceKind.includes("patch")) return "edit"; + if (sourceKind.includes("tool") || sourceKind.includes("command") || sourceKind.includes("exec") || sourceKind.includes("process") + || sourceKind.includes("websearch") || sourceKind.includes("mcp") || sourceKind.includes("imageview") + || sourceKind.includes("generatedimage") || sourceKind.includes("dynamictool") + || sourceKind.includes("permissionrequest") || sourceKind.includes("userinput")) return "tool"; + if (sourceKind.includes("reasoning") || sourceKind.includes("contextcompaction") || sourceKind.includes("approvalreview")) return "reasoning"; + if (sourceKind.includes("plan") || sourceKind.includes("todo")) return "plan"; + return "assistant"; + } + + function normalizedAssistantPhase(item) { + return String(item?.phase || item?.messagePhase || "") + .replace(/[\s-]+/g, "_") + .toLowerCase(); + } + + // Agent commentary is a work item in the official transcript. It belongs + // inside the worked-for disclosure, while the final answer stays outside it. + // Older snapshots omit `phase`, so use the last item in the turn as the + // fallback final answer boundary. + function isAssistantCommentary(item, index, turnKey, turnLastIndex) { + if (historyKind(item) !== "assistant") return false; + const phase = normalizedAssistantPhase(item); + if (phase === "final_answer" || phase === "finalanswer") return false; + if (phase === "commentary" || phase === "analysis" || phase === "reasoning" || phase === "thinking") return true; + return turnLastIndex instanceof Map && turnLastIndex.get(turnKey) !== index; + } + + function structuredTurnFinalAssistantIndexes(messages) { + const result = new Map(); + const explicit = new Set(); + if (!Array.isArray(messages)) return result; + messages.forEach((item, index) => { + if (!isRecord(item) || historyKind(item) !== "assistant") return; + const key = structuredMessageTurn(item, index); + const phase = normalizedAssistantPhase(item); + if (phase === "final_answer" || phase === "finalanswer") { + result.set(key, index); + explicit.add(key); + } else if (!explicit.has(key)) { + // A bookkeeping/sub-agent item can follow the answer in newer + // snapshots. Choose the last assistant message, not the last item. + result.set(key, index); + } + }); + return result; + } + + function structuredDisplayKind(item, index, turnKey, turnLastIndex) { + const kind = historyKind(item); + return kind === "assistant" && isAssistantCommentary(item, index, turnKey, turnLastIndex) + ? "commentary" + : kind; + } + + function isCollapsibleKind(kind) { + return kind === "tool" || kind === "read" || kind === "edit" || kind === "reasoning" + || kind === "plan" || kind === "subagent" || kind === "commentary"; + } + + function isReadActivity(item) { + if (!isRecord(item)) return false; + const type = normalizedItemType(item); + const semantic = String(item.activityKind || item.uiType || item.operation || item.commandType || "") + .replace(/[\s_./-]+/g, "") + .toLowerCase(); + const parsed = item.parsedCmd || item.parsedCommand || item.parsedCommandType; + const parsedType = typeof parsed === "string" + ? parsed + : isRecord(parsed) ? firstString(parsed.type, parsed.kind, parsed.operation) : ""; + const parsedNormalized = String(parsedType).replace(/[\s_./-]+/g, "").toLowerCase(); + return semantic.includes("fileread") || semantic.includes("readfile") || semantic === "read" + || type.includes("fileread") || type.includes("readfile") || type === "read" + || parsedNormalized === "read" || parsedNormalized === "fileread"; + } + + function readPathList(item) { + if (!isRecord(item)) return []; + const paths = []; + const visit = (value) => { + if (typeof value === "string" && value.trim()) paths.push(value.trim()); + else if (Array.isArray(value)) value.forEach(visit); + else if (isRecord(value)) visit(value.path ?? value.file ?? value.filePath ?? value.name); + }; + [item.path, item.file, item.filePath, item.filename, item.name, item.readPath, item.readPaths, item.files, item.paths].forEach(visit); + return [...new Set(paths)]; + } + + function readSummaryLabel(item, status, duration = "") { + const paths = [...new Set(readPathList(item).flatMap((value) => String(value).split(/\r?\n/).map((part) => part.trim()).filter(Boolean)))]; + const suffix = duration ? ` · ${duration}` : ""; + if (status === "inProgress") return paths.length === 1 + ? uiWithRaw("正在读取 ", "Reading ", paths[0]) + : t("正在读取文件"); + if (status === "failed") return paths.length === 1 + ? `${uiWithRaw("读取失败 · ", "Failed to read ", paths[0])}${suffix}` + : `${t("读取文件失败")}${suffix}`; + if (status === "interrupted") return paths.length === 1 + ? `${uiWithRaw("已停止读取 ", "Stopped reading ", paths[0])}${suffix}` + : `${t("已停止读取文件")}${suffix}`; + if (paths.length === 1) return `${uiWithRaw("已读取 ", "Read ", paths[0])}${suffix}`; + if (paths.length > 1) { + const count = String(paths.length); + return `${uiText("已读取这些内容 · ", "Read these items · ")}${count}${uiText(" 个文件", " files")}${suffix}`; + } + return `${t("已读取文件")}${suffix}`; + } + + function structuredMessageKey(item, index) { + const id = item?.id ?? item?.itemId; + if (id !== undefined && id !== null && String(id)) return `id:${String(id)}`; + const turn = item?.turnId ? String(item.turnId) : "item"; + return `turn:${turn}:${historyKind(item)}:${index}`; + } + + function structuredMessageRole(item, kind) { + return item.role === "user" || kind === "user" + ? "user" + : item.role === "tool" || kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" + ? "tool" + : item.role === "reasoning" || kind === "reasoning" || kind === "plan" + ? "system" + : item.role === "error" + ? "error" + : "assistant"; + } + + function structuredMessageStatus(item) { + if (item.status !== undefined && item.status !== null && item.status !== "") { + return normalizeActivityStatus(item.status, String(item.status)); + } + if (item.completed === true) return "completed"; + if (item.completed === false) return "inProgress"; + if (item.turnStatus !== undefined && item.turnStatus !== null && item.turnStatus !== "") { + return normalizeActivityStatus(item.turnStatus, String(item.turnStatus)); + } + return ""; + } + + function explicitStructuredTurnId(item) { + if (!isRecord(item)) return ""; + const nestedTurn = isRecord(item.turn) ? item.turn : {}; + const value = [ + item.turnId, + item.conversationTurnId, + item.conversation_turn_id, + item.turn_id, + nestedTurn.id, + nestedTurn.turnId, + ].find((candidate) => candidate !== undefined && candidate !== null && String(candidate).trim()); + return value === undefined ? "" : String(value); + } + + function deriveStructuredTurnKeys(messages) { + if (!Array.isArray(messages)) return new Map(); + const keys = new Map(); + let anonymousNumber = 0; + let currentKey = ""; + let currentHasUser = false; + let currentEnded = false; + const makeAnonymousKey = () => `anonymous:${anonymousNumber++}`; + messages.forEach((item, index) => { + if (!isRecord(item)) return; + const explicit = explicitStructuredTurnId(item); + const kind = historyKind(item); + if (explicit) { + currentKey = explicit; + currentHasUser = kind === "user"; + currentEnded = false; + keys.set(item, currentKey); + return; + } + if (!currentKey || kind === "user" && (currentHasUser || currentEnded)) { + currentKey = makeAnonymousKey(); + currentHasUser = false; + currentEnded = false; + } + if (kind === "user") currentHasUser = true; + keys.set(item, currentKey); + // A final-answer marker is the only reliable boundary in a legacy + // projection. Do not use turnStatus here: adapters attach the terminal + // turn status to every item in the turn. + const phase = normalizedAssistantPhase(item); + if (kind === "assistant" && (phase === "final_answer" || phase === "finalanswer")) currentEnded = true; + // Keep the index in the map as a debugging fallback for primitive array + // entries, while object identity remains the canonical lookup. + if (index < 0) keys.set(item, currentKey); + }); + state.structuredTurnKeys = new WeakMap(); + for (const [item, key] of keys) state.structuredTurnKeys.set(item, key); + return keys; + } + + function structuredMessageTurn(item, index) { + const explicit = explicitStructuredTurnId(item); + if (explicit) return explicit; + if (isRecord(item)) { + const derived = state.structuredTurnKeys?.get(item); + if (derived) return derived; + } + return `item:${index}`; + } + + function structuredActivityKey(item, kind, turnKey, index) { + const itemId = item?.itemId ?? item?.id; + const threadKey = item?.threadId || state.threadId || "thread"; + if (itemId !== undefined && itemId !== null && String(itemId)) { + return `${threadKey}:${turnKey || "turn"}:${kind}:${typeof itemId}:${String(itemId)}`; + } + return `structured:${structuredMessageKey(item, index)}`; + } + + function structuredTurnWorkedDurations(messages) { + const result = new Map(); + const explicitKeys = new Set(); + const starts = new Map(); + const ends = new Map(); + if (!Array.isArray(messages)) return result; + deriveStructuredTurnKeys(messages); + const finalAssistantIndexes = structuredTurnFinalAssistantIndexes(messages); + messages.forEach((item, index) => { + const key = structuredMessageTurn(item, index); + const explicit = finiteNumber( + item?.workedDurationMs, + item?.workDurationMs, + item?.workedForMs, + item?.turnWorkedDurationMs, + item?.workedFor?.durationMs, + ); + if (explicit !== null) { + result.set(key, Math.max(0, explicit)); + explicitKeys.add(key); + } + const workStart = timestampMs( + item?.firstTurnWorkItemStartedAtMs, + item?.workStartedAtMs, + item?.turnWorkStartedAtMs, + item?.turnStartedAtMs, + item?.workedFor?.startedAtMs, + ); + if (workStart !== null && !starts.has(key)) starts.set(key, workStart); + const kind = historyKind(item); + if (kind !== "user") { + const itemStart = timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs); + if (itemStart !== null && kind !== "assistant") { + // Activity timestamps are the closest equivalent to the official + // first-turn-work-item marker in older follower snapshots. + if (!starts.has(key)) starts.set(key, itemStart); + } + } + const explicitAssistantStart = timestampMs( + item?.finalAssistantStartedAtMs, + item?.workedFor?.completedAtMs, + ); + // The official worked-for clock ends when the final assistant response + // starts. Earlier assistant commentary is part of the work group and + // must not extend the duration; this was the source of the recurring + // three-to-five-second discrepancy in legacy snapshots. + const isFinalAssistant = kind === "assistant" && finalAssistantIndexes.get(key) === index; + const assistantStart = explicitAssistantStart !== null + ? explicitAssistantStart + : isFinalAssistant ? timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs) : null; + if (assistantStart !== null) ends.set(key, Math.max(ends.get(key) || 0, assistantStart)); + }); + for (const [key, start] of starts) { + if (result.has(key)) continue; + const end = ends.get(key); + if (end !== undefined && end >= start) result.set(key, end - start); + } + // Some older host snapshots keep the authoritative worked-for duration in + // session metadata rather than repeating it on each projected item. Apply + // it to the most recent turn only; explicit per-item values always win. + const metadataDuration = finiteNumber(state.workedDurationMs, state.lastWorkedDurationMs); + if (metadataDuration !== null && messages.length) { + const keysInOrder = messages + .filter(isRecord) + .map((item, index) => structuredMessageTurn(item, index)); + const target = state.turnId && keysInOrder.includes(state.turnId) + ? state.turnId + : keysInOrder.at(-1); + // Session metadata is authoritative over timestamp inference, but an + // explicit per-turn/item value from the host remains the strongest + // signal. + if (target && !explicitKeys.has(target)) result.set(target, Math.max(0, metadataDuration)); + } + // Older attach snapshots may expose the final-answer start separately + // from the duration. Use it only for the corresponding most recent turn. + const metadataFinal = timestampMs(state.finalAssistantStartedAt); + if (metadataFinal !== null && messages.length) { + const keyed = messages.filter(isRecord).map((item, index) => structuredMessageTurn(item, index)); + const target = state.turnId && keyed.includes(state.turnId) ? state.turnId : keyed.at(-1); + const start = target ? starts.get(target) : null; + if (target && start !== undefined && !explicitKeys.has(target) && !result.has(target) && metadataFinal >= start) { + result.set(target, metadataFinal - start); + } + } + return result; + } + + function structuredActivityTiming(messages, index, turnKey, status) { + const item = Array.isArray(messages) ? messages[index] : null; + if (!isRecord(item)) return { durationMs: null, finishedAt: null }; + const explicitDuration = finiteNumber(item.durationMs, item.elapsedMs); + const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs); + const explicitFinishedAt = timestampMs(item.completedAtMs, item.completedAt, item.finishedAtMs, item.finishedAt); + if (explicitDuration !== null) { + return { + durationMs: explicitDuration, + finishedAt: explicitFinishedAt ?? (startedAt === null ? null : startedAt + explicitDuration), + }; + } + if (explicitFinishedAt !== null && startedAt !== null) { + return { durationMs: Math.max(0, explicitFinishedAt - startedAt), finishedAt: explicitFinishedAt }; + } + // Hydrated legacy rows sometimes expose only start times. The next item in + // the same turn is the closest end marker (and matches the official + // projection's item interval). Never infer an end for an explicitly live + // row, and never let a later turn's timestamp inflate this activity. + if (status !== "inProgress" && startedAt !== null && Array.isArray(messages)) { + for (let cursor = index + 1; cursor < messages.length; cursor += 1) { + const next = messages[cursor]; + if (!isRecord(next)) continue; + if (structuredMessageTurn(next, cursor) !== turnKey) break; + const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs); + if (nextStartedAt === null) continue; + if (nextStartedAt >= startedAt) return { + durationMs: nextStartedAt - startedAt, + finishedAt: nextStartedAt, + }; + break; + } + } + return { durationMs: null, finishedAt: explicitFinishedAt }; + } + + function indexStructuredActivity(article, item, kind, turnKey, index, messages = null) { + if (!article || !isCollapsibleKind(kind)) return null; + const key = article.dataset.activityKey || structuredActivityKey(item, kind, turnKey, index); + article.dataset.activityKey = key; + article.dataset.activityKind = kind; + const existing = state.activities.get(key); + const turnStatus = normalizeActivityStatus(item.turnStatus, ""); + const status = structuredMessageStatus(item) || (turnStatus === "inProgress" ? "inProgress" : "completed"); + const timing = structuredActivityTiming(messages, index, turnKey, status); + const durationMs = timing.durationMs; + const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs); + const activity = existing || { + key, + kind, + role: kind === "commentary" + ? "assistant" + : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system", + messageKind: kind === "tool" || kind === "read" ? "tool" : kind, + label: item.label || activityLabelForItem(item, kind), + command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : commandText(item), + filePath: kind === "read" ? readPathList(item).join("\n") : "", + cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "", + shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" && item.shellName ? item.shellName : "Shell", + agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "", + displayName: kind === "subagent" ? firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath) : "", + objective: kind === "subagent" ? firstString(item.objective, item.prompt, item.statusMessage, item.message) : "", + activityKind: kind === "subagent" ? firstString(item.activityKind, item.kind) : "", + displayStatus: kind === "subagent" ? firstString(item.displayStatus, item.status) : "", + model: kind === "subagent" ? firstString(item.model, item.modelId) : "", + action: kind === "subagent" ? firstString(item.action, item.tool) : "", + prompt: kind === "subagent" && item.prompt !== null ? firstString(item.prompt) : "", + senderThreadId: kind === "subagent" ? firstString(item.senderThreadId) : "", + receiverThreadIds: kind === "subagent" + ? Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.map(String) + : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [] + : [], + agentsStates: kind === "subagent" && isRecord(item.agentsStates) ? item.agentsStates : {}, + canInteract: item.canInteract !== false, + exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : null, + itemId: item.itemId === undefined || item.itemId === null ? "" : String(item.itemId), + threadId: item.threadId || state.threadId || "", + turnId: turnKey || "", + startedAt: startedAt || null, + finishedAt: null, + durationMs: null, + durationExplicit: finiteNumber(item.durationMs, item.elapsedMs) !== null, + status, + headerText: "", + outputText: "", + anonymous: false, + concrete: true, + statusOnly: false, + article, + body: article.querySelector(".details-body") || article.querySelector(".message-body"), + wrapper: article.querySelector(".message-content"), + details: article.querySelector("details"), + summary: article.querySelector("summary"), + }; + activity.key = key; + activity.kind = kind; + activity.messageKind = kind === "tool" || kind === "read" ? "tool" : kind; + activity.itemId = item.itemId === undefined || item.itemId === null ? activity.itemId || "" : String(item.itemId); + // Structured history is an authoritative concrete projection. If a live + // status row occupied the same slot during reconnect, promote it here so + // the next terminal update does not discard the hydrated item. + activity.concrete = true; + activity.statusOnly = false; + activity.anonymous = false; + activity.turnId = turnKey || activity.turnId || ""; + activity.threadId = item.threadId || activity.threadId || state.threadId || ""; + activity.label = item.label || activity.label || activityLabelForItem(item, kind); + activity.command = kind === "tool" || kind === "read" + ? terminalCommandText(commandText(item)) || activity.command || "" + : commandText(item) || activity.command || ""; + if (kind === "tool" || kind === "read") { + if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath; + activity.cwd = typeof item.cwd === "string" ? item.cwd : activity.cwd || ""; + activity.shellName = typeof item.shellName === "string" && item.shellName + ? item.shellName + : activity.shellName || "Shell"; + activity.exitCode = item.exitCode === undefined || item.exitCode === null + ? activity.exitCode ?? null + : finiteNumber(item.exitCode, item.exit_code); + } + if (kind === "subagent") { + activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId); + activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName); + activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective); + activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind); + activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus); + activity.model = firstString(item.model, item.modelId, activity.model); + activity.action = firstString(item.action, item.tool, activity.action); + if (item.prompt !== null && item.prompt !== undefined) activity.prompt = firstString(item.prompt, activity.prompt); + activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId); + if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String); + else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String); + if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates; + if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false; + if (activity.agentThreadId) article.dataset.agentThreadId = activity.agentThreadId; + else delete article.dataset.agentThreadId; + } + activity.startedAt = startedAt || activity.startedAt || null; + if (durationMs !== null || !existing) activity.durationMs = durationMs; + activity.durationExplicit = finiteNumber(item.durationMs, item.elapsedMs) !== null; + activity.status = status; + activity.finishedAt = timing.finishedAt || activity.finishedAt || null; + activity.article = article; + activity.body = article.querySelector(".details-body") || article.querySelector(".message-body"); + activity.wrapper = article.querySelector(".message-content"); + activity.details = article.querySelector("details"); + activity.summary = article.querySelector("summary"); + activity.outputText = kind === "tool" || kind === "read" ? activityOutput(item, kind) : messageText(item); + state.activities.set(key, activity); + // Hydrated rows may have been created before lifecycle metadata arrived. + // Recompute the disclosure label from the normalized status/timestamps so + // history and live updates cannot leave stale `0ms` or generic text behind. + renderActivityText(activity); + refreshActivity(activity); + return activity; + } + + function updateStructuredMessageArticle(article, item, index, turnLastIndex, turnHasActivity = new Set(), turnWorkedDurations = new Map()) { + const itemText = messageText(item); + if (!itemText) return false; + const sourceKind = historyKind(item); + const turnKey = structuredMessageTurn(item, index); + const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); + const role = structuredMessageRole(item, kind); + const status = structuredMessageStatus(item) || "completed"; + const durationMs = item.durationMs ?? item.elapsedMs; + const duration = elapsedDuration(durationMs); + const body = article.querySelector(".message-body"); + if (!body) return false; + const details = article.querySelector("details"); + const wasOpen = details ? isDetailsExpanded(details) : undefined; + article.dataset.rawText = String(itemText); + article.dataset.turnId = turnKey; + article.dataset.kind = kind; + if (item.itemId !== undefined && item.itemId !== null) article.dataset.itemId = String(item.itemId); + if (item.itemType) article.dataset.itemType = String(item.itemType); + const agentThreadId = kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : ""; + if (agentThreadId) article.dataset.agentThreadId = agentThreadId; + else delete article.dataset.agentThreadId; + if (item.startedAtMs || item.completedAtMs) { + const parsed = timestampMs(item.startedAtMs ?? item.completedAtMs); + if (parsed !== null) article.dataset.timestamp = String(parsed); + } + if (status) article.dataset.status = status; + const isActivity = isCollapsibleKind(kind); + article.classList.toggle("streaming", status === "inProgress" && !isActivity); + renderMessageBody(body, itemText, role, isActivity ? "activity" : "history", kind); + if (details) { + const summary = details.querySelector("summary"); + const activitySummary = historyActivitySummary(item, kind, status, duration); + if (summary && activitySummary) setActivitySummary(summary, activitySummary, kind); + if (wasOpen !== undefined) setDetailsExpanded(details, wasOpen, { immediate: true, preserve: false }); + } + const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant" + && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex)); + if (finalAssistant && status !== "inProgress" && !article.classList.contains("streaming") && !article.querySelector(".message-actions")) { + addMessageActions(article, true); + } + const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"] + .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase()); + const workedDuration = turnWorkedDurations.get(turnKey) + ?? workedDurationFor(item, null); + if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) { + appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, article); + } + return true; + } + + function reconcileStructuredOutput(text, structuredMessages) { + const output = $("output"); + if (!output || !Array.isArray(structuredMessages) || !structuredMessages.length) return false; + const articles = [...output.querySelectorAll(".message[data-structured-key]")]; + if (articles.length !== structuredMessages.length) return false; + const keys = structuredMessages.map((item, index) => structuredMessageKey(item, index)); + if (articles.some((article, index) => article.dataset.structuredKey !== keys[index])) return false; + const follow = shouldFollowOutput(output); + const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages); + const turnHasActivity = new Set(); + const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages); + structuredMessages.forEach((item, index) => { + const turnKey = structuredMessageTurn(item, index); + if (isCollapsibleKind(structuredDisplayKind(item, index, turnKey, turnLastIndex))) turnHasActivity.add(turnKey); + }); + for (const [index, item] of structuredMessages.entries()) { + if (!updateStructuredMessageArticle(articles[index], item, index, turnLastIndex, turnHasActivity, turnWorkedDurations)) return false; + const turnKey = structuredMessageTurn(item, index); + const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); + if (isCollapsibleKind(kind)) { + indexStructuredActivity(articles[index], item, kind, turnKey, index, structuredMessages); + } + } + reconcileTurnDividers(); + expandLatestTurnActivity(); + if (typeof text === "string") output.dataset.outputTail = text; + state.structuredMessages = structuredMessages.slice(); + state.outputSynced = true; + if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus) + && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) { + ensureLiveTurnDivider(state.turnId); + } + if (follow) scrollOutput(output, true); + updateScrollToBottom(output); + return true; + } + + function replaceOutput(text, structuredMessages) { + const output = $("output"); + const preserveBottom = !state.outputSynced || !output || shouldFollowOutput(output); + const preservedDistanceFromBottom = output + ? Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight) + : 0; + const pendingUserText = state.pendingUserText; + const snapshotHasPendingUser = Boolean(pendingUserText && ( + text.includes(`> ${pendingUserText}`) + || (Array.isArray(structuredMessages) && structuredMessages.some((item) => { + const kind = historyKind(item); + return kind === "user" && comparableText(messageText(item)) === comparableText(pendingUserText); + })) + )); + renderEmptyOutput(); + if (typeof text === "string") output.dataset.outputTail = text; + state.structuredMessages = Array.isArray(structuredMessages) ? structuredMessages.slice() : []; + if (Array.isArray(structuredMessages) && structuredMessages.length) { + // Older follower snapshots may omit the top-level subagents projection. + // Derive the small panel model from the same collaboration items used by + // the transcript so those agents remain discoverable after reconnect. + const suppliedSubagents = state.subagents; + const derivedSubagents = deriveSubagentsFromMessages(structuredMessages); + if (derivedSubagents.length) { + state.subagents = mergeSubagentProjections(derivedSubagents, suppliedSubagents); + renderSubagents(); + } else if (state.subagents.length) { + // A complete snapshot with no collaboration items is authoritative for + // the transcript. Do not carry a finished agent from the previous + // thread into the new composer. + state.subagents = []; + renderSubagents(); + } + const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages); + const turnHasActivity = new Set(); + const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages); + let previousTurnKey = ""; + structuredMessages.forEach((item, index) => { + const key = structuredMessageTurn(item, index); + if (isCollapsibleKind(structuredDisplayKind(item, index, key, turnLastIndex))) turnHasActivity.add(key); + }); + structuredMessages.forEach((item, index) => { + const itemText = messageText(item); + if (!itemText) return; + const sourceKind = historyKind(item); + const turnKey = structuredMessageTurn(item, index); + const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); + const role = structuredMessageRole(item, kind); + const status = structuredMessageStatus(item) || "completed"; + const durationMs = item.durationMs ?? item.elapsedMs; + const duration = elapsedDuration(durationMs); + const timestamp = item.startedAtMs ?? item.completedAtMs; + if (role === "user" || role === "assistant") { + appendDateSeparator(timestamp, { + role, + turnId: turnKey, + turnStart: turnKey !== previousTurnKey, + breaksPreviousAdjacency: item.breaksPreviousAdjacency === true, + }); + } + const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant" + && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex)); + const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"] + .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase()); + const workedDuration = turnWorkedDurations.get(turnKey) ?? workedDurationFor(item, null); + const activitySummary = historyActivitySummary(item, kind, status, duration) || undefined; + const collapsible = isCollapsibleKind(kind); + const activityKey = collapsible ? structuredActivityKey(item, kind, turnKey, index) : ""; + const message = appendMessage(itemText, role, collapsible ? "activity" : "history", "", { + kind, + status, + label: item.label || (kind === "reasoning" ? "思考" : kind === "plan" ? "计划" : kind === "edit" ? "文件变更" : kind === "read" ? "读取文件" : kind === "tool" ? "工具输出" : kind === "subagent" ? "子代理" : kind === "commentary" ? "工作说明" : ""), + summary: activitySummary, + command: item.command || item.commandLine, + turnId: turnKey, + structuredKey: structuredMessageKey(item, index), + activityKey, + itemId: item.itemId, + itemType: item.itemType, + agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "", + timestamp, + showTimestamp: role === "user" || role === "assistant", + showActions: role === "user" || (role === "assistant" && finalAssistant && kind !== "commentary"), + collapsible, + open: kind === "commentary" || (status === "inProgress" && (kind === "reasoning" || kind === "plan")), + }); + if (message && collapsible) indexStructuredActivity(message.article, item, kind, turnKey, index, structuredMessages); + if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) { + appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, message?.article || null); + } + previousTurnKey = turnKey; + }); + reconcileTurnDividers(); + expandLatestTurnActivity(); + } else if (text) { + // The attach adapter prefixes user items with `> ` and separates items + // with blank lines. Use that stable marker to recreate the two sides of + // the conversation without interpreting arbitrary Markdown as HTML. + const chunks = text.split(/\n{2,}/).map((chunk) => chunk.trim()).filter(Boolean); + let expectUser = true; + for (const chunk of chunks) { + const markedUser = chunk.startsWith("> "); + if (markedUser && expectUser) { + appendMessage(chunk.slice(2), "user", "history", "", { kind: "user" }); + expectUser = false; + } else { + appendMessage(chunk, "assistant", "history", "", { kind: "assistant" }); + expectUser = true; + } + } + } + if (pendingUserText && !snapshotHasPendingUser) { + appendDateSeparator(Date.now(), { role: "user", force: true }); + appendMessage(pendingUserText, "user", "streaming", "", { kind: "user", timestamp: Date.now(), showTimestamp: true }); + } + state.pendingUserText = snapshotHasPendingUser ? "" : pendingUserText; + state.outputSynced = true; + if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus) + && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) { + ensureLiveTurnDivider(state.turnId); + } + if (preserveBottom) scrollOutput(output, true); + else if (output) { + // Rebuilding a long history changes scrollHeight. Preserve the user's + // distance from the bottom just like the official thread scroll layout, + // so a background sync does not yank them away from the message they are + // reading. + output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - preservedDistanceFromBottom); + updateScrollToBottom(output); + } + } + + function applyStructuredMessagesPatch(patch) { + if (!isRecord(patch) || !Array.isArray(state.structuredMessages) || !Array.isArray(patch.messages)) return null; + const start = Number(patch.start); + const deleteCount = Number(patch.deleteCount); + if (!Number.isInteger(start) || start < 0 || start > state.structuredMessages.length + || !Number.isInteger(deleteCount) || deleteCount < 0 + || start + deleteCount > state.structuredMessages.length) return null; + return [ + ...state.structuredMessages.slice(0, start), + ...patch.messages, + ...state.structuredMessages.slice(start + deleteCount), + ]; + } + + function appendOutputChunk(text, stream = "codex", context = {}) { + if (!text) return; + const output = $("output"); + const follow = shouldFollowOutput(output); + const normalizedStream = String(stream || "codex").toLowerCase(); + if (normalizedStream === "codex") { + const userItem = text.match(/^(?:\n{2,})?> ([^\n]+)\n?$/); + if (userItem) { + const userText = userItem[1].trim(); + const eventTurn = context.turnId || state.turnId || ""; + finishAssistantStream(); + appendDateSeparator(context.timestamp || Date.now(), { role: "user", turnId: eventTurn, turnStart: true }); + if (!hasRenderedMessage(userText, eventTurn, "user")) { + appendMessage(userText, "user", "history", "", { + kind: "user", + turnId: eventTurn, + timestamp: context.timestamp || Date.now(), + showTimestamp: true, + }); + } + state.pendingUserText = ""; + state.outputSynced = true; + return; + } + } + if (normalizedStream === "codex" && state.pendingUserText) { + const marker = `> ${state.pendingUserText}`; + if (text.includes(marker)) { + text = text.replace(marker, "").replace(/^\n{1,2}/, ""); + state.pendingUserText = ""; + if (!text) return; + } + } + + const eventTurnId = context.turnId || state.turnId || ""; + if (state.outputSynced && eventTurnId && normalizedStream === "codex" + && hasRenderedCompletedMessage(text, eventTurnId, "assistant")) { + // A host replay often sends the same output delta immediately after an + // authoritative snapshot. Do not append a second assistant bubble. + state.pendingUserText = ""; + return; + } + + const inferredActivity = normalizedStream === "reasoning" + ? "thinking" + : normalizedStream === "read" || normalizedStream === "reading" + ? "reading" + : normalizedStream === "stdout" || normalizedStream === "stderr" + ? "running" + : "generating"; + if (!state.currentActivity || ["idle", "completed", "failed", "interrupted"].includes(state.currentActivity)) { + state.currentActivity = inferredActivity; + } + if (state.turnStartedAt === null) startTurnClock(context.turnId || state.turnId, null, null); + updateLiveActivity( + state.currentActivity, + state.currentActivityStartedAt || state.turnStartedAt, + null, + [], + context.turnId || state.turnId, + ); + const activityText = statusActivityLabel(state.currentActivity) || t("正在生成"); + const outputElapsed = elapsedDuration(Math.max(0, Date.now() - (state.turnStartedAt || Date.now()))); + setConversationStatus(outputElapsed ? `${activityText} · ${outputElapsed}` : activityText, "active"); + + // Lifecycle notifications create the canonical row first. Output deltas + // from app-server versions that omit itemId are attached to the newest + // running row of the corresponding kind. + const streamKind = normalizedStream === "reasoning" + ? "reasoning" + : normalizedStream === "read" || normalizedStream === "reading" + ? "read" + : normalizedStream === "stdout" || normalizedStream === "stderr" + ? "tool" + : context.kind || ""; + const activity = streamKind + ? latestRunningActivity(streamKind, context) + : null; + if (activity) { + appendActivityChunk(activity, text); + state.activeAssistantBody = activity.body; + state.activeAssistantStream = normalizedStream; + state.activeAssistantText = activity.outputText; + state.activeAssistantActivityKey = activity.key; + if (follow) scrollOutput(output, true); + state.outputSynced = true; + return; + } + if (state.activeAssistantStream !== normalizedStream) finishAssistantStream(); + if (!state.activeAssistantBody && normalizedStream === "codex") text = text.replace(/^\n{2,}/, ""); + if (!state.activeAssistantBody || !output.contains(state.activeAssistantBody)) { + const role = normalizedStream === "stderr" + ? "error" + : normalizedStream === "stdout" || normalizedStream === "read" || normalizedStream === "reading" + ? "tool" + : normalizedStream === "reasoning" + ? "system" + : "assistant"; + const label = normalizedStream === "reasoning" ? "思考" + : normalizedStream === "stdout" ? "命令输出" + : normalizedStream === "read" || normalizedStream === "reading" ? "读取文件" : ""; + const kind = normalizedStream === "reasoning" ? "reasoning" + : normalizedStream === "stdout" ? "tool" + : normalizedStream === "read" || normalizedStream === "reading" || context.kind === "read" ? "read" : "assistant"; + let message; + if (kind === "reasoning" || kind === "tool" || kind === "read") { + state.activitySequence += 1; + const key = `stream:${state.threadId || "thread"}:${state.turnId || "turn"}:${normalizedStream}:${state.activitySequence}`; + const streamActivity = ensureActivity(key, { + kind, + label: label || (kind === "tool" ? "工具输出" : kind === "read" ? "读取文件" : "思考"), + threadId: state.threadId, + turnId: eventTurnId || state.turnId, + status: "inProgress", + anonymous: true, + concrete: true, + }); + message = streamActivity && { article: streamActivity.article, content: streamActivity.body }; + state.activeAssistantActivityKey = streamActivity?.key || null; + } else { + appendDateSeparator(context.timestamp || Date.now(), { role: "assistant", turnId: eventTurnId, turnStart: false }); + message = appendMessage("", role, "streaming", "", { + kind, + label, + turnId: eventTurnId, + timestamp: context.timestamp || Date.now(), + showTimestamp: true, + showActions: false, + collapsible: false, + }); + } + state.activeAssistantBody = message?.content || null; + state.activeAssistantStream = normalizedStream; + state.activeAssistantText = ""; + } + if (state.activeAssistantActivityKey) { + const streamActivity = state.activities.get(state.activeAssistantActivityKey); + if (streamActivity) { + appendActivityChunk(streamActivity, text); + state.activeAssistantText = streamActivity.outputText; + if (follow) scrollOutput(output, true); + state.outputSynced = true; + return; + } + } + if (state.activeAssistantBody) { + state.activeAssistantText += text; + const article = state.activeAssistantBody.closest(".message"); + const role = article?.classList.contains("system") ? "system" : article?.classList.contains("error") ? "error" : "assistant"; + const kind = article?.dataset.kind || "assistant"; + renderMessageBody(state.activeAssistantBody, state.activeAssistantText, role, "streaming", kind); + if (article) article.dataset.rawText = state.activeAssistantText; + article?.classList.add("streaming"); + } + if (follow) scrollOutput(output, true); + state.outputSynced = true; + } + + function finishAssistantStream() { + const article = state.activeAssistantBody?.closest(".message"); + article?.classList.remove("streaming"); + if (article?.classList.contains("assistant") && !article.querySelector(".message-actions")) addMessageActions(article, true); + if (article?.dataset.kind === "reasoning") { + const details = article.querySelector("details"); + if (details) setDetailsExpanded(details, false); + } + const activity = state.activeAssistantActivityKey + ? state.activities.get(state.activeAssistantActivityKey) + : null; + if (activity?.anonymous && isRunningActivity(activity)) finishActivity(activity, "completed"); + state.activeAssistantBody = null; + state.activeAssistantStream = null; + state.activeAssistantText = ""; + state.activeAssistantActivityKey = null; + } + + function eventBelongsToCurrentTurn(payload) { + if (!payload || typeof payload !== "object") return true; + const threadId = eventThreadId(payload); + const turnId = eventTurnId(payload); + if (threadId && state.threadId && threadId !== state.threadId) return false; + if (turnId && state.turnId && turnId !== state.turnId) return false; + if (turnId && state.retiredTurnIds.has(turnId)) return false; + return true; + } + + function eventMessage(payload) { + if (!payload || typeof payload !== "object") return "未知错误"; + const candidates = [ + payload.message, + payload.error?.message, + payload.error, + payload.params?.message, + payload.params?.error, + payload.text, + ]; + const value = candidates.find((entry) => typeof entry === "string" && entry.trim()); + if (value) return value; + try { return JSON.stringify(payload); } catch { return "未知错误"; } + } + + function setAttachMode(value) { + state.attachMode = Boolean(value); + document.body.classList.toggle("attach-mode", state.attachMode); + $("sessionMode").textContent = t(state.attachMode + ? "已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。" + : "当前为独立 app-server 模式。"); + $("startThreadButton").textContent = t(state.attachMode ? "已附着现有会话" : "启动新 thread"); + const modeLabel = $("modeLabel"); + if (modeLabel) modeLabel.textContent = t(state.attachMode ? "本地模式" : "独立模式"); + const popoverMode = $("popoverMode"); + if (popoverMode) popoverMode.textContent = t(state.attachMode ? "本地模式" : "独立模式"); + } + + function firstString(...values) { + return values.find((value) => typeof value === "string" && value.trim())?.trim() || ""; + } + + // Unlike firstString, settings projections need to preserve an explicit + // null. Codex uses null for a model with no reasoning selector; treating it + // as "missing" leaves the browser showing the previous turn's effort. + function firstDefined(...values) { + return values.find((value) => value !== undefined); + } + + function modelDisplayName(model, effort) { + const value = String(model || "").trim(); + if (!value) return ""; + const words = value + .replace(/^gpt[-_]/i, "") + .replace(/[-_]+/g, " ") + .split(/\s+/) + .filter(Boolean) + .map((word) => /^\d+(?:\.\d+)*$/.test(word) ? word : `${word[0].toUpperCase()}${word.slice(1)}`); + const effortValue = String(effort || "").trim(); + if (effortValue && !words.some((word) => word.toLowerCase() === effortValue.toLowerCase())) { + words.push(`${effortValue[0].toUpperCase()}${effortValue.slice(1)}`); + } + return words.join(" "); + } + + // Keep this tiny fallback aligned with the model power choices shipped in + // the installed official webview. A host that exposes `availableModels` + // always wins; these entries only make the picker useful while an older + // private IPC build has no model/list projection. + const FALLBACK_MODELS = [ + { model: "gpt-5.6-sol", displayName: "5.6 Sol", description: "通用 Codex 模型", efforts: ["low", "medium", "high", "xhigh"] }, + { model: "gpt-5.6-terra", displayName: "5.6 Terra", description: "平衡速度与推理", efforts: ["low", "medium", "high", "xhigh"] }, + ]; + // Labels used by the shipped Work composer. The compact trigger shows the + // model plus its selected reasoning preset (for example `5.6 Sol 标准`). + const EFFORT_LABELS = { none: "默认", minimal: "极低", low: "轻度", medium: "标准", high: "深度", xhigh: "极高", max: "最大", ultra: "Ultra" }; + + // The compact Work picker is a power control. Keep the mapping deterministic + // so keyboard/range input still goes through the same effort protocol used by + // the advanced picker. + function modelPowerOptions(model) { + const efforts = Array.isArray(model?.efforts) ? model.efforts.filter(Boolean) : []; + return efforts.map((effort) => ({ effort, label: t(EFFORT_LABELS[effort] || effort) })); + } + + function normalizeModelOption(value) { + if (typeof value === "string") { + const fallback = FALLBACK_MODELS.find((entry) => entry.model === value); + return fallback ? { ...fallback } : { model: value, displayName: modelDisplayName(value), description: "可用模型", efforts: [] }; + } + if (!isRecord(value)) return null; + const model = firstString(value.model, value.id, value.slug, value.name); + if (!model) return null; + const hasSupported = Array.isArray(value.supportedReasoningEfforts) || Array.isArray(value.efforts); + const supported = Array.isArray(value.supportedReasoningEfforts) + ? value.supportedReasoningEfforts.map((entry) => typeof entry === "string" ? entry : firstString(entry?.reasoningEffort, entry?.effort)).filter(Boolean) + : Array.isArray(value.efforts) ? value.efforts.filter((entry) => typeof entry === "string") : []; + const fallback = FALLBACK_MODELS.find((entry) => entry.model === model); + return { + model, + displayName: firstString(value.displayName, value.label, fallback?.displayName, modelDisplayName(model)), + description: firstString(value.description, fallback?.description), + // Unknown catalog entries must not inherit a fabricated effort list. The + // official advanced picker disables the power controls until the host + // reports capabilities for that model. + efforts: hasSupported ? supported : fallback?.efforts || [], + defaultReasoningEffort: firstString(value.defaultReasoningEffort, value.defaultEffort, fallback?.efforts?.[1]), + hidden: value.hidden === true, + }; + } + + function normalizedModelOptions() { + let source; + if (state.availableModels.length) source = state.availableModels; + else if (state.currentModel && FALLBACK_MODELS.some((entry) => entry.model === state.currentModel)) source = FALLBACK_MODELS; + else if (state.currentModel) source = [state.currentModel]; + else return []; + const seen = new Set(); + const result = []; + for (const entry of source) { + const option = normalizeModelOption(entry); + if (!option || option.hidden || seen.has(option.model)) continue; + seen.add(option.model); + result.push(option); + } + if (state.currentModel && !seen.has(state.currentModel)) { + result.unshift(normalizeModelOption(state.currentModel)); + } + return result; + } + + function currentModelOption() { + return normalizedModelOptions().find((entry) => entry.model === state.currentModel) || normalizedModelOptions()[0]; + } + + function renderModelPicker() { + const button = $("modelPickerButton"); + const label = $("modelLabel"); + const effortLabelNode = $("modelEffortLabel"); + const menu = $("modelMenu"); + const modelOptions = $("modelOptions"); + const effortOptions = $("effortOptions"); + const powerView = $("modelPowerView"); + const advancedView = $("modelAdvancedView"); + const advancedToggle = $("modelAdvancedToggle"); + const powerSlider = $("modelPowerSlider"); + const powerValue = $("modelPowerValue"); + if (!button || !label || !effortLabelNode || !menu || !modelOptions || !effortOptions) return; + const current = currentModelOption(); + if (!current) { + button.hidden = true; + return; + } + button.hidden = false; + const selectedEffort = state.currentEffort === null + ? "" + : state.currentEffort || current.defaultReasoningEffort || current.efforts?.[1] || current.efforts?.[0] || ""; + const effortLabel = selectedEffort ? t(EFFORT_LABELS[selectedEffort] || selectedEffort) : ""; + label.textContent = current.displayName || modelDisplayName(current.model); + effortLabelNode.textContent = effortLabel; + effortLabelNode.hidden = !effortLabel; + const settingsModelValue = $("settingsModelValue"); + if (settingsModelValue) settingsModelValue.textContent = [label.textContent, effortLabel].filter(Boolean).join(" ") || t("默认"); + const triggerLabel = [label.textContent, effortLabel].filter(Boolean).join(" "); + button.setAttribute("aria-label", uiLocale() === "en-US" + ? `Current model: ${triggerLabel}; change model` + : `当前模型 ${triggerLabel},切换模型`); + button.setAttribute("title", uiLocale() === "en-US" + ? `Change model (current: ${triggerLabel})` + : `切换模型(当前 ${triggerLabel})`); + modelOptions.replaceChildren(); + for (const option of normalizedModelOptions()) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "model-option"; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(option.model === state.currentModel)); + item.dataset.model = option.model; + const name = document.createElement("span"); + name.className = "model-option-name"; + name.textContent = option.displayName || option.model; + const check = document.createElement("span"); + check.className = "model-option-check"; + check.textContent = "✓"; + const description = document.createElement("span"); + description.className = "model-option-description"; + description.textContent = option.description ? t(option.description) : option.model; + item.append(name, check, description); + item.addEventListener("click", () => selectModel(option.model)); + modelOptions.append(item); + } + effortOptions.replaceChildren(); + const efforts = current.efforts?.length ? [...current.efforts] : []; + // Ultra is capability-gated in the official picker. Preserve it when the + // host explicitly reports the current setting, but do not advertise it to + // every fallback session. + if (state.currentEffort === "ultra" && current.model === "gpt-5.6-sol" && !efforts.includes("ultra")) efforts.push("ultra"); + const selectedMenuEffort = state.currentEffort === null + ? "" + : state.currentEffort || current.defaultReasoningEffort || efforts[1] || efforts[0] || ""; + if (!efforts.length) { + const empty = document.createElement("div"); + empty.className = "effort-empty"; + empty.textContent = t("此模型使用默认推理强度"); + effortOptions.append(empty); + } + for (const effort of efforts) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "effort-option"; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(effort === selectedMenuEffort)); + item.dataset.effort = effort; + const name = document.createElement("span"); + name.textContent = t(EFFORT_LABELS[effort] || effort); + const check = document.createElement("span"); + check.className = "effort-option-check"; + check.textContent = "✓"; + item.append(name, check); + item.title = effort; + item.addEventListener("click", () => selectEffort(effort)); + effortOptions.append(item); + } + const powerOptions = modelPowerOptions(current); + if (powerView && advancedView) { + powerView.hidden = state.modelAdvancedOpen; + advancedView.hidden = !state.modelAdvancedOpen; + } + if (advancedToggle) { + advancedToggle.textContent = t(state.modelAdvancedOpen ? "简洁" : "高级"); + advancedToggle.setAttribute("aria-label", t(state.modelAdvancedOpen ? "返回简洁模型选择" : "显示高级模型选项")); + } + if (powerSlider) { + const hasPower = powerOptions.length > 0; + powerSlider.disabled = !hasPower; + powerSlider.min = "0"; + powerSlider.max = String(Math.max(0, powerOptions.length - 1)); + const selectedPower = powerOptions.findIndex((entry) => entry.effort === selectedMenuEffort); + powerSlider.value = String(selectedPower >= 0 ? selectedPower : Math.min(1, Math.max(0, powerOptions.length - 1))); + powerSlider.setAttribute("aria-valuetext", powerOptions[Number(powerSlider.value)]?.label || t("默认")); + powerSlider.style.setProperty("--power-position", powerOptions.length > 1 + ? `${Number(powerSlider.value) / (powerOptions.length - 1) * 100}%` + : "0%"); + if (powerValue) powerValue.textContent = powerOptions[Number(powerSlider.value)]?.label || t("默认"); + } else if (powerValue) powerValue.textContent = ""; + $("modelPicker")?.toggleAttribute("data-pending", state.modelUpdatePending); + renderControlMode(); + } + + function setModelMenu(open) { + const button = $("modelPickerButton"); + const menu = $("modelMenu"); + if (!button || !menu) return; + const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching; + menu.hidden = !next; + button.setAttribute("aria-expanded", String(next)); + if (next) { + state.modelAdvancedOpen = false; + renderModelPicker(); + } + } + + function setPermissionMenu(open) { + const button = $("permissionChip"); + const menu = $("permissionMenu"); + if (!button || !menu) return; + const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching; + menu.hidden = !next; + button.setAttribute("aria-expanded", String(next)); + if (next) renderPermissionMenu(); + } + + function permissionSandboxLabel(value) { + const label = { + "read-only": "只读", + "workspace-write": "工作区写入", + "danger-full-access": "完全访问", + }[normalizeSandboxName(value)] || String(value || "工作区写入"); + return t(label); + } + + function renderPermissionMenu() { + const menu = $("permissionMenu"); + const chip = $("permissionChip"); + const label = $("permissionLabel"); + if (!menu || !chip || !label) return; + const mode = state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never" + ? "full" + : state.sandboxPolicy === "read-only" + ? "readonly" + : state.approvalPolicy === "untrusted" || state.approvalPolicy === "never" ? "auto" : "ask"; + label.textContent = t({ + ask: "需要时询问", + auto: "由 Codex 审批", + full: "完全访问", + readonly: "只读", + }[mode]); + const settingsPermissionValue = $("settingsPermissionValue"); + if (settingsPermissionValue) settingsPermissionValue.textContent = label.textContent; + chip.setAttribute("aria-label", uiLocale() === "en-US" + ? `Change permissions; current: ${label.textContent}` + : `修改权限,当前为${label.textContent}`); + chip.setAttribute("title", uiLocale() === "en-US" + ? `Change permissions (current: ${label.textContent})` + : `修改权限(当前:${label.textContent})`); + menu.querySelectorAll("[data-permission-mode]").forEach((item) => { + item.setAttribute("aria-checked", String(item.dataset.permissionMode === mode)); + }); + } + + function selectPermissionMode(mode) { + if (!threadSettingsAllowed()) { + setConversationStatus("当前模式不支持修改会话设置", "warning"); + return; + } + if (mode === "custom") { + setPermissionMenu(false); + setConversationStatus("自定义权限由 config.toml 管理", "ready"); + return; + } + if (mode === "full" && !(state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never")) { + const confirm = $("permissionConfirm"); + if (confirm) { + confirm.hidden = false; + confirm.dataset.pendingMode = mode; + setPermissionMenu(false); + $("permissionConfirmAccept")?.focus(); + } + return; + } + applyPermissionMode(mode); + } + + function applyPermissionMode(mode) { + if (!threadSettingsAllowed()) return; + const presets = { + ask: { sandboxPolicy: "workspace-write", approvalPolicy: "on-request" }, + auto: { sandboxPolicy: "workspace-write", approvalPolicy: "untrusted" }, + full: { sandboxPolicy: "danger-full-access", approvalPolicy: "never" }, + readonly: { sandboxPolicy: "read-only", approvalPolicy: "on-request" }, + }; + const preset = presets[mode]; + if (!preset) return; + state.sandboxPolicy = preset.sandboxPolicy; + state.approvalPolicy = preset.approvalPolicy; + const sandboxInput = $("sandboxInput"); + const approvalInput = $("approvalInput"); + if (sandboxInput && [...sandboxInput.options].some((option) => option.value === state.sandboxPolicy)) sandboxInput.value = state.sandboxPolicy; + if (approvalInput && [...approvalInput.options].some((option) => option.value === state.approvalPolicy)) approvalInput.value = state.approvalPolicy; + renderPermissionMenu(); + updateThreadSettings(undefined, undefined, { + sandboxPolicy: state.sandboxPolicy, + approvalPolicy: state.approvalPolicy, + permissions: state.sandboxPolicy === "read-only" + ? ":read-only" + : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace", + approvalsReviewer: "user", + }); + setPermissionMenu(false); + } + + function selectPermissionSetting(kind, value) { + if (!threadSettingsAllowed()) return; + if (!value) return; + if (kind === "sandbox") { + state.sandboxPolicy = normalizeSandboxName(value); + const input = $("sandboxInput"); + if (input && [...input.options].some((option) => option.value === state.sandboxPolicy)) input.value = state.sandboxPolicy; + } else { + state.approvalPolicy = String(value); + const input = $("approvalInput"); + if (input && [...input.options].some((option) => option.value === state.approvalPolicy)) input.value = state.approvalPolicy; + } + renderPermissionMenu(); + updateThreadSettings(undefined, undefined, { + sandboxPolicy: state.sandboxPolicy, + approvalPolicy: state.approvalPolicy, + permissions: state.sandboxPolicy === "read-only" + ? ":read-only" + : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace", + approvalsReviewer: "user", + }); + setPermissionMenu(false); + } + + function normalizeUsage(value) { + if (!isRecord(value)) return null; + const source = isRecord(value.contextWindow) ? value.contextWindow : isRecord(value.context) ? value.context : value; + const total = isRecord(value.total) ? value.total : isRecord(source.total) ? source.total : {}; + const last = isRecord(value.last) ? value.last : isRecord(source.last) ? source.last : {}; + const breakdownTotal = (entry) => { + if (!isRecord(entry)) return null; + const explicit = finiteNumber(entry.totalTokens, entry.total_tokens, entry.tokens, entry.used, entry.inputTokens); + if (explicit !== null) return explicit; + const parts = [ + finiteNumber(entry.inputTokens, entry.input_tokens), + finiteNumber(entry.cachedInputTokens, entry.cached_input_tokens), + finiteNumber(entry.cacheWriteInputTokens, entry.cache_write_input_tokens), + finiteNumber(entry.outputTokens, entry.output_tokens), + finiteNumber(entry.reasoningOutputTokens, entry.reasoning_output_tokens), + ].filter((entry) => entry !== null); + return parts.length ? parts.reduce((sum, entry) => sum + entry, 0) : null; + }; + const used = finiteNumber( + source.used, + source.usedTokens, + source.inputTokens, + source.input_tokens, + source.tokensUsed, + source.totalTokens, + value.usedTokens, + value.inputTokens, + breakdownTotal(last), + breakdownTotal(total), + ); + const limit = finiteNumber( + value.modelContextWindow, + value.model_context_window, + source.limit, + source.max, + source.maxTokens, + typeof source.contextWindow === "number" ? source.contextWindow : null, + value.limit, + value.maxTokens, + ); + const remaining = finiteNumber(source.remaining, source.remainingTokens, value.remainingTokens); + const percent = finiteNumber(source.percent, source.percentage, value.percent, value.percentage); + if (used === null && limit === null && remaining === null && percent === null) return null; + const computedPercent = percent !== null + ? Math.max(0, Math.min(100, percent)) + : used !== null && limit !== null && limit > 0 ? Math.max(0, Math.min(100, used / limit * 100)) + : used !== null && remaining !== null && used + remaining > 0 ? used / (used + remaining) * 100 : 0; + const clampedUsed = used !== null && limit !== null && limit > 0 ? Math.min(used, limit) : used; + const computedRemaining = remaining !== null + ? remaining + : clampedUsed !== null && limit !== null ? Math.max(limit - clampedUsed, 0) : null; + return { + used: clampedUsed, + limit, + remaining: computedRemaining, + percent: computedPercent, + totalTokens: breakdownTotal(total), + lastTokens: breakdownTotal(last), + }; + } + + function renderUsage() { + const picker = $("usagePicker"); + const button = $("usageButton"); + const label = $("usageLabel"); + const ring = $("usageRing"); + const summary = $("usageSummary"); + const details = $("usageDetails"); + const bar = $("usageMeterBar"); + if (!picker || !button || !label || !ring || !summary || !details || !bar) return; + const usage = normalizeUsage(state.tokenUsage); + picker.hidden = !usage; + if (!usage) return; + const percent = Math.round(usage.percent); + label.textContent = `${percent}%`; + ring.style.setProperty("--usage-percent", `${percent}%`); + ring.dataset.level = percent >= 90 ? "critical" : percent >= 70 ? "warning" : "normal"; + const remainingPercent = Math.max(0, 100 - percent); + button.title = uiLocale() === "en-US" + ? `Context used ${percent}% (${remainingPercent}% remaining)` + : `上下文已使用 ${percent}%(剩余 ${remainingPercent}%)`; + button.setAttribute("aria-label", button.title); + summary.textContent = usage.limit !== null + ? `${usage.used ?? 0} / ${usage.limit} tokens (${percent}%)` + : uiLocale() === "en-US" ? `${percent}% used` : `${percent}% 已使用`; + bar.style.width = `${percent}%`; + details.textContent = [ + usage.remaining !== null ? (uiLocale() === "en-US" ? `${usage.remaining} tokens remaining` : `剩余 ${usage.remaining} tokens`) : "", + usage.used !== null ? (uiLocale() === "en-US" ? `Current context: ${usage.used} tokens` : `当前上下文 ${usage.used} tokens`) : "", + usage.lastTokens !== null && usage.lastTokens !== usage.used ? (uiLocale() === "en-US" ? `Latest request: ${usage.lastTokens} tokens` : `最近请求 ${usage.lastTokens} tokens`) : "", + usage.totalTokens !== null && usage.totalTokens !== usage.used ? (uiLocale() === "en-US" ? `Total: ${usage.totalTokens} tokens` : `累计 ${usage.totalTokens} tokens`) : "", + ].filter(Boolean).join("\n"); + } + + function setUsageMenu(open) { + const button = $("usageButton"); + const menu = $("usageMenu"); + if (!button || !menu) return; + const next = Boolean(open); + menu.hidden = !next; + button.setAttribute("aria-expanded", String(next)); + } + + function updateThreadSettings(model, effort, extra = {}) { + if (!threadSettingsAllowed() || !state.threadId || state.role !== "operator") return; + const threadSettings = {}; + if (model) threadSettings.model = model; + if (effort !== undefined) threadSettings.effort = effort; + if (isRecord(extra)) { + for (const [key, value] of Object.entries(extra)) { + if (value !== undefined) threadSettings[key] = value; + } + } + if (!Object.keys(threadSettings).length) return; + state.modelUpdatePending = true; + if (model) state.currentModel = model; + if (effort !== undefined) state.currentEffort = effort === null ? null : effort || ""; + renderModelPicker(); + try { + command("thread/settings/update", { threadId: state.threadId, threadSettings }); + } catch (error) { + state.modelUpdatePending = false; + appendOutput(error.message || "无法更新模型设置", "error"); + renderModelPicker(); + } + } + + function currentEffortParams() { + if (state.currentEffort === null) return { effort: null }; + return state.currentEffort ? { effort: state.currentEffort } : {}; + } + + function selectModel(model) { + const option = normalizedModelOptions().find((entry) => entry.model === model); + const effort = option?.efforts?.length + ? option.efforts.includes(state.currentEffort) + ? state.currentEffort + : option.defaultReasoningEffort || option.efforts[1] || option.efforts[0] + : null; + updateThreadSettings(model, effort); + setModelMenu(false); + } + + function selectEffort(effort) { + updateThreadSettings(undefined, effort); + setModelMenu(false); + } + + function selectPowerIndex(value) { + const current = currentModelOption(); + const options = modelPowerOptions(current); + if (!options.length) return; + const index = Math.max(0, Math.min(options.length - 1, Number(value) || 0)); + const option = options[index]; + if (!option) return; + updateThreadSettings(undefined, option.effort); + // The official power control remains open while keyboard arrows adjust it. + state.modelAdvancedOpen = false; + renderModelPicker(); + } + + function normalizeSubagentStatus(value) { + const raw = isRecord(value) ? firstString(value.status, value.type, value.state) : value; + const normalized = String(raw || "").replace(/[\s_-]+/g, "").toLowerCase(); + if (["running", "working", "interacted", "updated", "inprogress", "active", "started"].includes(normalized)) return "working"; + if (["pending", "pendinginit", "waiting", "queued", "waitingforinput", "awaitinginstruction", "waitingforinstruction", "needsinput"].includes(normalized)) return "waiting"; + if (["failed", "errored", "error", "notfound"].includes(normalized)) return "failed"; + if (["completed", "complete", "done", "interrupted", "shutdown", "cancelled", "canceled"].includes(normalized)) return "done"; + return normalized || "waiting"; + } + + function subagentStatusLabel(status) { + // These are the localized equivalents of the official background-agent + // rows ("is working", "is awaiting instruction", "is done"). + return t({ waiting: "正在等待指示", working: "正在工作", done: "已完成", failed: "失败" }[status] || status); + } + + function subagentThreadId(entry) { + return firstString(entry.threadId, entry.agentThreadId, entry.childThreadId); + } + + function subagentElapsed(entry) { + const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt); + if (startedAt === null) return ""; + const status = normalizeSubagentStatus(entry.status); + const completedAt = timestampMs(entry.completedAtMs, entry.completedAt, entry.finishedAtMs, entry.finishedAt, entry.lastAssistantMessageAtMs); + const end = status === "working" || status === "waiting" ? Date.now() : completedAt; + if (end === null || end === undefined) return ""; + return elapsedDuration(Math.max(0, end - startedAt)); + } + + function focusSubagentActivity(threadId) { + if (!threadId) return; + const output = $("output"); + const target = output + ? [...output.querySelectorAll(".message[data-agent-thread-id]")] + .reverse() + .find((article) => article.dataset.agentThreadId === threadId) + : null; + if (!target) return; + const turnId = target.dataset.turnId; + const turnActivity = turnId ? state.turnDividers.get(turnId) : null; + const turnToggle = turnActivity?.querySelector(".turn-divider-toggle"); + if (turnToggle?.getAttribute("aria-expanded") === "false") { + turnToggle.setAttribute("aria-expanded", "true"); + setTurnActivityVisibility(turnId, true); + } + target.scrollIntoView({ block: "center", behavior: "smooth" }); + target.classList.remove("subagent-focus"); + void target.offsetWidth; + target.classList.add("subagent-focus"); + setTimeout(() => target.classList.remove("subagent-focus"), 1_200); + } + + function createSubagentPanelRow(entry) { + const status = normalizeSubagentStatus(entry.status); + const threadId = subagentThreadId(entry); + const row = document.createElement(threadId ? "button" : "div"); + if (threadId) row.type = "button"; + row.className = "subagent-row"; + row.dataset.status = status; + if (threadId) row.dataset.agentThreadId = threadId; + const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt); + const completedAt = timestampMs( + entry.completedAtMs, + entry.completedAt, + entry.finishedAtMs, + entry.finishedAt, + entry.lastAssistantMessageAtMs, + entry.recencyAtMs, + entry.recencyAt, + ); + if (startedAt !== null) row.dataset.startedAt = String(startedAt); + if (completedAt !== null) row.dataset.completedAt = String(completedAt); + + const icon = document.createElement("span"); + icon.className = "subagent-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.append(createSubagentIcon()); + const copy = document.createElement("span"); + copy.className = "subagent-copy"; + const name = document.createElement("span"); + name.className = "subagent-name"; + name.textContent = firstString(entry.displayName, entry.agentNickname, entry.name, entry.agentPath) || t("子代理"); + copy.append(name); + const stateLabel = document.createElement("span"); + stateLabel.className = "subagent-status"; + const statusText = document.createElement("span"); + statusText.className = "subagent-status-text"; + statusText.textContent = subagentStatusLabel(status); + stateLabel.append(statusText); + const diff = isRecord(entry.diffStats) ? entry.diffStats : isRecord(entry.diff_stats) ? entry.diff_stats : null; + const added = finiteNumber(diff?.linesAdded, diff?.added); + const removed = finiteNumber(diff?.linesRemoved, diff?.removed); + if (added !== null || removed !== null) { + const diffLabel = document.createElement("span"); + diffLabel.className = "subagent-diff-stats"; + diffLabel.textContent = `+${Math.max(0, added || 0)} -${Math.max(0, removed || 0)}`; + stateLabel.append(diffLabel); + } + const elapsed = document.createElement("span"); + elapsed.className = "subagent-elapsed"; + elapsed.textContent = subagentElapsed(entry); + // The composer keeps the row compact, but exposes timing in the tooltip + // and makes the elapsed node available for live updates. + if (elapsed.textContent) stateLabel.append(elapsed); + row.append(icon, copy, stateLabel); + const objective = firstString(entry.statusMessage, entry.objective, entry.prompt, entry.role, entry.agentRole); + const model = firstString(entry.spawnModel, entry.model, entry.modelId); + const tooltipParts = [objective, model ? (uiLocale() === "en-US" ? `Using ${model}` : `使用 ${model}`) : "", elapsed.textContent ? (uiLocale() === "en-US" ? `Elapsed: ${elapsed.textContent}` : `已用时 ${elapsed.textContent}`) : ""].filter(Boolean); + if (tooltipParts.length) row.title = `${name.textContent}: ${tooltipParts.join(" · ")}`; + if (threadId) { + row.title = tooltipParts.length + ? `${name.textContent}: ${tooltipParts.join(" · ")}` + : uiLocale() === "en-US" ? `View ${name.textContent}'s activity` : `查看 ${name.textContent} 的活动`; + row.setAttribute("aria-label", `${name.textContent}, ${subagentStatusLabel(status)}`); + row.addEventListener("click", () => focusSubagentActivity(threadId)); + } + return row; + } + + function deriveSubagentsFromMessages(messages) { + if (!Array.isArray(messages)) return []; + deriveStructuredTurnKeys(messages); + const entries = new Map(); + let anonymousIndex = 0; + const upsert = (item, threadId = "", itemIndex = -1) => { + const agent = isRecord(item.agent) ? item.agent : {}; + const displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, agent.displayName, agent.name); + const key = threadId || displayName || firstString(item.agentPath, item.action, item.activityKind) || `anonymous-${anonymousIndex++}`; + const existing = entries.get(key) || { threadId: threadId || "", displayName: null, prompt: null, objective: null, status: "working", statusMessage: null, canInteract: false }; + const activityKind = firstString(item.activityKind, item.kind, agent.activityKind); + const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status) + || (activityKind === "completed" ? "completed" : activityKind === "interrupted" ? "interrupted" : "working"); + const normalizedStatus = normalizeSubagentStatus(rawStatus); + existing.threadId = existing.threadId || threadId; + existing.displayName = displayName || existing.displayName; + existing.agentPath = firstString(item.agentPath, agent.agentPath, existing.agentPath); + existing.prompt = firstString(item.prompt, agent.prompt, existing.prompt) || null; + existing.objective = firstString(item.objective, item.statusMessage, item.prompt, agent.objective, existing.objective) || null; + existing.statusMessage = firstString(item.statusMessage, agent.statusMessage, existing.statusMessage) || null; + existing.status = normalizedStatus; + existing.canInteract = item.canInteract !== undefined ? item.canInteract !== false : existing.canInteract; + const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs, agent.startedAtMs); + const completedAt = timestampMs( + item.completedAtMs, + item.completedAt, + item.finishedAtMs, + item.finishedAt, + item.lastAssistantMessageAtMs, + item.recencyAtMs, + item.recencyAt, + agent.completedAtMs, + agent.lastAssistantMessageAtMs, + agent.recencyAtMs, + ); + if (startedAt !== null) existing.startedAtMs = existing.startedAtMs ?? startedAt; + if (completedAt !== null) existing.completedAtMs = completedAt; + if (existing.completedAtMs === undefined && normalizedStatus === "done" && itemIndex >= 0) { + const turnKey = structuredMessageTurn(item, itemIndex); + for (let cursor = itemIndex + 1; cursor < messages.length; cursor += 1) { + const next = messages[cursor]; + if (!isRecord(next)) continue; + if (structuredMessageTurn(next, cursor) !== turnKey) break; + const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs); + if (nextStartedAt !== null) { + existing.completedAtMs = nextStartedAt; + break; + } + } + } + if (item.model !== undefined || agent.model !== undefined) existing.model = firstString(item.model, agent.model, existing.model) || null; + entries.set(key, existing); + }; + messages.forEach((item, index) => { + if (!isRecord(item) || historyKind(item) !== "subagent") return; + const receivers = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.map(String) + : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : []; + const states = isRecord(item.agentsStates) ? Object.keys(item.agentsStates) : []; + const ids = [...new Set([ + firstString(item.agentThreadId, item.childThreadId), + ...receivers, + ...states, + ].filter(Boolean))]; + if (!ids.length) upsert(item, "", index); + else ids.forEach((id) => upsert(item, id, index)); + }); + return [...entries.values()]; + } + + function subagentIdentity(entry) { + if (!isRecord(entry)) return ""; + return firstString( + entry.threadId, + entry.agentThreadId, + entry.childThreadId, + entry.displayName, + entry.agentNickname, + entry.name, + entry.agentPath, + ); + } + + // A snapshot can carry a rich top-level subagent projection while the + // message list only contains the compact activity item. Merge matching + // identities so timestamps, model names, and interaction flags survive the + // transcript re-render without carrying stale agents across threads. + function mergeSubagentProjections(derived, supplied) { + const rich = Array.isArray(supplied) ? supplied.filter(isRecord) : []; + if (!rich.length) return derived; + const byIdentity = new Map(rich + .map((entry) => [subagentIdentity(entry), entry]) + .filter(([identity]) => Boolean(identity))); + const matched = new Set(); + const merged = derived.map((entry) => { + const identity = subagentIdentity(entry); + const source = identity ? byIdentity.get(identity) : undefined; + if (!source) return entry; + matched.add(source); + return { ...entry, ...source }; + }); + // Keep a rich top-level entry when the transcript projection is absent + // (for example while a newly spawned agent has not emitted its first + // activity item yet), but discard unrelated entries from an older thread. + if (!merged.length) return rich; + for (const source of rich) { + if (matched.has(source)) continue; + const identity = subagentIdentity(source); + if (identity && !merged.some((entry) => subagentIdentity(entry) === identity) + && normalizeSubagentStatus(source.status) !== "done") merged.push(source); + } + return merged; + } + + function refreshSubagentElapsed() { + for (const row of document.querySelectorAll(".subagent-row[data-started-at]")) { + const startedAt = timestampMs(row.dataset.startedAt); + if (startedAt === null) continue; + const completedAt = timestampMs(row.dataset.completedAt); + const status = normalizeSubagentStatus(row.dataset.status); + const end = status === "working" || status === "waiting" ? Date.now() : completedAt; + const label = row.querySelector(".subagent-elapsed"); + if (label) label.textContent = end === null || end === undefined ? "" : elapsedDuration(Math.max(0, end - startedAt)); + } + } + + function renderSubagents() { + const panel = $("subagentsPanel"); + const list = $("subagentsList"); + const count = $("subagentsCount"); + if (!panel || !list || !count) return; + const entries = Array.isArray(state.subagents) ? state.subagents.filter(isRecord) : []; + const visibleEntries = entries.filter((entry) => firstString( + entry.displayName, + entry.agentNickname, + entry.name, + entry.agentPath, + entry.threadId, + entry.agentThreadId, + entry.childThreadId, + entry.objective, + )); + panel.hidden = visibleEntries.length === 0; + if (!visibleEntries.length) { + count.textContent = ""; + if (isRecord(state.subagentsExpanded)) { + state.subagentsExpanded.active = false; + state.subagentsExpanded.done = false; + } + list.replaceChildren(); + return; + } + const normalizedEntries = visibleEntries.map((entry) => ({ entry, status: normalizeSubagentStatus(entry.status) })); + // The official composer uses one compact disclosure row. It does not + // split the list into active/done sections or show per-agent wall-clock + // durations; status is rendered inline beside each display name. + const title = $("subagentsToggle")?.querySelector(".subagents-title"); + if (title) title.textContent = uiLocale() === "en-US" + ? `${normalizedEntries.length} background agents${state.subagentsCollapsed ? "" : " · @ to mention agents"}` + : `${normalizedEntries.length} 个后台代理${state.subagentsCollapsed ? "" : " · @ 可标记代理"}`; + count.textContent = ""; + panel.dataset.collapsed = String(state.subagentsCollapsed); + const toggle = $("subagentsToggle"); + if (toggle) toggle.setAttribute("aria-expanded", String(!state.subagentsCollapsed)); + list.setAttribute("aria-hidden", String(state.subagentsCollapsed)); + list.inert = state.subagentsCollapsed; + list.replaceChildren(); + const rows = document.createElement("div"); + rows.className = "subagent-section-rows"; + for (const { entry } of normalizedEntries) rows.append(createSubagentPanelRow(entry)); + list.append(rows); + } + + function normalizeSandboxName(value) { + const normalized = String(value || "") + .replace(/^:+/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, "-") + .toLowerCase(); + if (normalized === "dangerfullaccess" || normalized === "full-access" || normalized === "fullaccess") return "danger-full-access"; + if (normalized === "workspacewrite") return "workspace-write"; + if (normalized === "workspace" || normalized === "write") return "workspace-write"; + if (normalized === "readonly" || normalized === "read") return "read-only"; + return normalized; + } + + function sandboxFromPermissions(value) { + if (typeof value === "string") { + const normalized = normalizeSandboxName(value); + if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized; + return ""; + } + if (!isRecord(value)) return ""; + const explicit = firstString(value.sandboxPolicy, value.sandbox, value.mode, value.profile, value.type); + if (explicit) { + const normalized = normalizeSandboxName(explicit); + if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized; + } + if (value.dangerFullAccess === true || value.fullAccess === true || value.full_access === true) return "danger-full-access"; + const fileSystem = isRecord(value.fileSystem) ? value.fileSystem : isRecord(value.file_system) ? value.file_system : value; + if (fileSystem.write === true || fileSystem.workspaceWrite === true || fileSystem.workspace_write === true) return "workspace-write"; + if (fileSystem.readOnly === true || fileSystem.read_only === true || fileSystem.read === true) return "read-only"; + return ""; + } + + function normalizeApprovalPolicy(value) { + const candidate = isRecord(value) + ? firstString(value.policy, value.mode, value.type, value.approvalPolicy, value.approval_policy) + : firstString(value); + const normalized = String(candidate || "").replace(/[\s_]+/g, "-").toLowerCase(); + return ["on-request", "never", "untrusted"].includes(normalized) ? normalized : ""; + } + + // Model metadata belongs to the attached thread. Clear it before applying + // a snapshot for a different thread so an older catalog cannot leak into a + // newly selected conversation that does not expose model data. + function resetSessionModelMetadata() { + state.currentModel = ""; + state.currentEffort = ""; + state.availableModels = []; + state.modelUpdatePending = false; + state.tokenUsage = null; + state.workedDurationMs = null; + state.lastWorkedDurationMs = null; + state.turnWorkStartedAt = null; + state.finalAssistantStartedAt = null; + state.sandboxPolicy = "workspace-write"; + state.approvalPolicy = "on-request"; + const modelInput = $("modelInput"); + if (modelInput) modelInput.value = ""; + const label = $("modelLabel"); + if (label) { + label.textContent = ""; + label.hidden = true; + } + setModelMenu(false); + renderModelPicker(); + renderPermissionMenu(); + renderUsage(); + } + + function prepareForSessionSnapshot(threadId) { + const incoming = typeof threadId === "string" ? threadId : ""; + const previous = state.syncedThreadId !== null ? state.syncedThreadId : state.threadId || ""; + if (previous !== incoming && (previous || incoming)) { + resetSessionModelMetadata(); + state.turnExpansion.clear(); + } + if (incoming) syncSessionActive(incoming); + return previous !== incoming; + } + + function snapshotHistoryComplete(...sources) { + const seen = new Set(); + const visit = (value, depth = 0) => { + if (!isRecord(value) || seen.has(value) || depth > 3) return undefined; + seen.add(value); + if (typeof value.historyComplete === "boolean") return value.historyComplete; + for (const key of ["metadata", "sessionMetadata", "state", "snapshot"]) { + const nested = visit(value[key], depth + 1); + if (typeof nested === "boolean") return nested; + } + return undefined; + }; + for (const source of sources) { + const result = visit(source); + if (typeof result === "boolean") return result; + } + return undefined; + } + + function projectionTargetThreadId() { + const switchingTarget = firstString(state.sessionSwitchContext?.targetThreadId); + if (switchingTarget) return switchingTarget; + if (state.sessionSelectedThreadId && state.sessionSelectedThreadId !== state.syncedThreadId) { + return state.sessionSelectedThreadId; + } + return firstString(state.threadId, state.syncedThreadId); + } + + function outputProjectionAllowed(threadId) { + const incoming = String(threadId || ""); + const expected = projectionTargetThreadId(); + return !expected || !incoming || incoming === expected; + } + + function hasVisibleOutputProjection() { + const output = $("output"); + return Boolean( + state.structuredMessages.length + || output?.dataset.outputTail + || output?.querySelector(".message") + ); + } + + function finishSessionSnapshotCommit(threadId, authoritativeSnapshot = false) { + const incoming = String(threadId || ""); + if (!incoming) return; + const context = state.sessionSwitchContext; + if (authoritativeSnapshot && context?.targetThreadId === incoming) { + context.targetSnapshotReady = true; + finishSessionSwitchIfReady(); + } + syncSessionActive(incoming); + } + + // Transcript, active-thread identity, and switch completion are one commit. + // This prevents a metadata-only/placeholder reconnect snapshot from clearing + // a fully rendered thread or completing a switch before its history arrives. + function commitOutputProjection(threadId, text, structuredMessages, options = {}) { + const incoming = String(threadId || ""); + if (!outputProjectionAllowed(incoming)) return false; + const hasContent = Boolean( + (typeof text === "string" && text.length) + || (Array.isArray(structuredMessages) && structuredMessages.length) + ); + const historyComplete = options.historyComplete; + if (!hasContent && historyComplete !== true) { + // All relay control snapshots have projection-shaped placeholder fields. + // Until the host explicitly says history loading is complete, an empty + // projection is not authoritative—even when the current DOM is empty. + return false; + } + const changedThread = state.syncedThreadId !== null && state.syncedThreadId !== incoming; + prepareForSessionSnapshot(incoming); + if (changedThread) { + state.outputSynced = false; + state.snapshotNoticeShown = false; + } + replaceOutput(typeof text === "string" ? text : "", structuredMessages); + state.syncedThreadId = incoming; + if (incoming) state.threadId = incoming; + finishSessionSnapshotCommit(incoming, options.authoritativeSnapshot === true); + return true; + } + + function applySessionMetadata(metadata, snapshotState = {}) { + const meta = isRecord(metadata) ? metadata : {}; + const stateSnapshot = isRecord(snapshotState) ? snapshotState : {}; + const thread = isRecord(meta.thread) ? meta.thread : isRecord(stateSnapshot.thread) ? stateSnapshot.thread : {}; + const settings = isRecord(meta.threadSettings) + ? meta.threadSettings + : isRecord(meta.latestThreadSettings) + ? meta.latestThreadSettings + : isRecord(meta.settings) + ? meta.settings + : isRecord(stateSnapshot.threadSettings) ? stateSnapshot.threadSettings : {}; + const title = firstString(meta.title, meta.threadTitle, meta.name, thread.title, thread.name, thread.preview, stateSnapshot.title, stateSnapshot.threadTitle); + if (title) $("threadTitle").textContent = title; + + const cwd = firstString(meta.cwd, settings.cwd, stateSnapshot.cwd); + if (cwd) $("cwdInput").value = cwd; + const modelValue = meta.latestModel ?? meta.model ?? meta.modelName ?? meta.modelId + ?? settings.model ?? settings.modelName ?? stateSnapshot.latestModel ?? stateSnapshot.model ?? stateSnapshot.modelName; + const model = typeof modelValue === "object" && modelValue !== null + ? firstString(modelValue.name, modelValue.id, modelValue.slug) + : firstString(modelValue); + if (model) { + $("modelInput").value = model; + const modelLabel = $("modelLabel"); + const effortValue = firstDefined( + meta.latestReasoningEffort, + meta.effort, + settings.effort, + stateSnapshot.latestReasoningEffort, + stateSnapshot.effort, + ); + state.currentModel = model; + if (effortValue !== undefined) { + state.currentEffort = effortValue === null ? null : firstString(effortValue); + } + if (modelLabel) { modelLabel.textContent = modelDisplayName(model); modelLabel.hidden = false; } + } else if (modelValue !== undefined) { + const modelLabel = $("modelLabel"); + if (modelLabel) modelLabel.hidden = true; + } + const modelsValue = meta.availableModels ?? meta.models ?? stateSnapshot.availableModels ?? stateSnapshot.models; + if (Array.isArray(modelsValue)) state.availableModels = modelsValue.filter((entry) => typeof entry === "string" || isRecord(entry)); + const subagentsValue = meta.subagents ?? stateSnapshot.subagents; + if (Array.isArray(subagentsValue)) state.subagents = subagentsValue; + const usageValue = meta.tokenUsage ?? meta.latestTokenUsageInfo ?? meta.contextUsage ?? meta.usage + ?? stateSnapshot.tokenUsage ?? stateSnapshot.latestTokenUsageInfo ?? stateSnapshot.contextUsage ?? stateSnapshot.usage; + if (usageValue !== undefined) state.tokenUsage = usageValue; + const metadataWorkedDuration = finiteNumber( + meta.workedDurationMs, + meta.workDurationMs, + meta.workedForMs, + meta.workedFor?.durationMs, + settings.workedDurationMs, + stateSnapshot.workedDurationMs, + stateSnapshot.workDurationMs, + ); + if (metadataWorkedDuration !== null) { + state.workedDurationMs = Math.max(0, metadataWorkedDuration); + state.lastWorkedDurationMs = Math.max(0, metadataWorkedDuration); + } + const metadataWorkStart = timestampMs( + meta.firstTurnWorkItemStartedAtMs, + meta.firstWorkItemStartedAtMs, + meta.workStartedAtMs, + meta.workedFor?.startedAtMs, + settings.firstTurnWorkItemStartedAtMs, + stateSnapshot.firstTurnWorkItemStartedAtMs, + stateSnapshot.workStartedAtMs, + ); + if (metadataWorkStart !== null) state.turnWorkStartedAt = metadataWorkStart; + const metadataFinalStart = timestampMs( + meta.finalAssistantStartedAtMs, + meta.workedFor?.completedAtMs, + settings.finalAssistantStartedAtMs, + stateSnapshot.finalAssistantStartedAtMs, + ); + if (metadataFinalStart !== null) state.finalAssistantStartedAt = metadataFinalStart; + renderModelPicker(); + renderSubagents(); + renderUsage(); + + const permissionsValue = meta.permissions ?? meta.currentPermissions ?? settings.permissions + ?? stateSnapshot.permissions ?? stateSnapshot.currentPermissions; + const sandboxValue = meta.sandboxPolicy ?? meta.sandbox ?? settings.sandboxPolicy ?? settings.sandbox + ?? stateSnapshot.sandboxPolicy ?? stateSnapshot.sandbox ?? sandboxFromPermissions(permissionsValue); + const sandbox = typeof sandboxValue === "object" && sandboxValue !== null + ? firstString(sandboxValue.type, sandboxValue.mode, sandboxValue.policy) + : firstString(sandboxValue); + if (sandbox) { + const normalizedSandbox = normalizeSandboxName(sandbox); + state.sandboxPolicy = normalizedSandbox; + const sandboxInput = $("sandboxInput"); + if (sandboxInput && [...sandboxInput.options].some((option) => option.value === normalizedSandbox)) sandboxInput.value = normalizedSandbox; + const permission = $("permissionChip"); + const permissionLabel = $("permissionLabel"); + if (permission && permissionLabel) { + permissionLabel.textContent = permissionSandboxLabel(normalizedSandbox); + permission.hidden = false; + } + } else { + const permission = $("permissionChip"); + if (permission) permission.hidden = false; + } + const approval = normalizeApprovalPolicy( + meta.approvalPolicy !== undefined ? meta.approvalPolicy + : settings.approvalPolicy !== undefined ? settings.approvalPolicy + : stateSnapshot.approvalPolicy, + ); + if (approval) { + state.approvalPolicy = approval; + const approvalInput = $("approvalInput"); + if (approvalInput && [...approvalInput.options].some((option) => option.value === approval)) approvalInput.value = approval; + } + renderPermissionMenu(); + const mode = firstString(meta.mode, stateSnapshot.mode); + const modeLabel = $("modeLabel"); + if (modeLabel && mode) modeLabel.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式"); + const popoverCwd = $("popoverCwd"); + const popoverMode = $("popoverMode"); + if (popoverCwd && cwd) popoverCwd.textContent = cwd; + if (popoverMode && mode) popoverMode.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式"); + } + + const setConnection = (kind, text) => { + $("connectionDot").className = `dot ${kind}`; + $("connectionText").textContent = t(text); + if (embeddedInAether) embedBridge.reportState(kind, { message: String(text || "") }); + }; + + function setAuthRequired(value) { + if (typeof value !== "boolean") return; + state.authRequired = value; + document.body.classList.toggle("local-no-auth", !value); + const label = $("tokenLabel"); + const input = $("tokenInput"); + if (!label || !input) return; + label.textContent = t(value ? "访问 token(认证模式)" : "本机连接(无需 token)"); + input.placeholder = t(value ? "粘贴 relay 启动时打印的 token" : "本机模式无需填写;认证模式再填写"); + input.setAttribute("aria-label", t(value ? "relay access token" : "本地连接无需 token")); + } + + const authHeaders = () => ({ Authorization: `Bearer ${state.token}`, "Content-Type": "application/json" }); + + function attachPendingUserToTurn(turnId) { + const key = String(turnId || ""); + if (!key) return; + const article = state.pendingUserArticle; + if (article && article.isConnected) article.dataset.turnId = key; + state.pendingUserArticle = null; + } + + function sendFrame(frame) { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) throw new Error(t("WebSocket 未连接")); + state.ws.send(JSON.stringify(frame)); + } + + function command(method, params) { + const commandId = `web-${crypto.randomUUID()}`; + sendFrame({ type: "command", commandId, method, params }); + if (method === "turn/start" || method === "turn/steer") { + const text = (params?.input || []) + .map((item) => typeof item === "string" ? item : item?.text) + .filter(Boolean) + .join("\n"); + finishAssistantStream(); + state.pendingUserText = text; + appendDateSeparator(Date.now(), { role: "user", turnId: state.turnId || params?.expectedTurnId || "", turnStart: true }); + const userMessage = appendMessage(text || "(空消息)", "user", "text", "", { + kind: "user", + turnId: state.turnId || params?.expectedTurnId || "", + timestamp: Date.now(), + showTimestamp: true, + }); + if (method === "turn/start") state.pendingUserArticle = userMessage?.article || null; + } else if (method === "thread/settings/update") { + // Keep settings changes quiet in the transcript. The model picker has + // its own pending state and the authoritative snapshot will update the + // label once the official follower accepts the request. + setConversationStatus("正在更新模型设置", "active"); + } else if (["session/list", "session/select", "control/mode/set"].includes(sessionCommandMethod(method))) { + // Session navigation is shell UI state. It must not appear as a Codex + // message or alter the current turn's activity timeline. + } else { + appendOutput(`${method} (${commandId})`, "meta"); + } + return commandId; + } + + function composerText() { + const editor = $("messageInput"); + if (!editor) return ""; + return String(editor.innerText || editor.textContent || "") + .replace(/\u00a0/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function clearComposer() { + const editor = $("messageInput"); + if (!editor) return; + editor.replaceChildren(); + editor.style.height = ""; + resizeComposer(); + updateIds(); + } + + function resizeComposer() { + const editor = $("messageInput"); + if (!editor) return; + editor.style.height = "auto"; + const maxHeight = Math.max(40, Math.round(window.innerHeight * 0.25)); + const nextHeight = Math.min(Math.max(editor.scrollHeight, 40), maxHeight); + editor.style.height = `${nextHeight}px`; + updateScrollPadding(); + } + + function defaultResponse(request) { + const method = request.method; + // Rendering a request must never imply consent. The operator still has + // to press an explicit action button. + if (method === "item/commandExecution/requestApproval") return { decision: "decline" }; + if (method === "item/fileChange/requestApproval") return { decision: "decline" }; + if (method === "item/permissions/requestApproval") { + return normalizePermissionResponse({}); + } + if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: { denied: { rejection: "默认拒绝,请明确允许" } } }; + if (method === "item/tool/requestUserInput") { + const answers = {}; + for (const question of request.params?.questions || []) answers[question.id] = { answers: [""] }; + return { answers }; + } + if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; + return {}; + } + + function normalizePermissionResponse(rawPermissions, scope, strictAutoReview) { + const permissions = {}; + if (rawPermissions && typeof rawPermissions === "object" && !Array.isArray(rawPermissions)) { + for (const [key, value] of Object.entries(rawPermissions)) { + if (value && typeof value === "object" && !Array.isArray(value)) permissions[key] = value; + } + } + const response = { permissions, scope: scope === "session" ? "session" : "turn" }; + if (typeof strictAutoReview === "boolean") response.strictAutoReview = strictAutoReview; + return response; + } + + function requestSummary(request) { + const params = request.params || {}; + if (Array.isArray(params.commandActions)) { + const commands = params.commandActions + .map((action) => action && typeof action === "object" ? action.command || action.description : "") + .filter(Boolean); + if (commands.length) return commands.join("\n"); + } + if (request.summary) return request.summary; + if (Array.isArray(params.command)) return params.command.join(" "); + if (params.command) return params.command; + if (params.reason) return params.reason; + if (Array.isArray(params.questions)) return params.questions.map((q) => q.question).join(" / "); + if (params.message) return params.message; + return t("需要远程确认或输入"); + } + + function requestTitle(request) { + const method = String(request.method || ""); + if (method === "item/commandExecution/requestApproval" || method === "execCommandApproval") return t("允许运行命令?"); + if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") return t("允许修改文件?"); + if (method === "item/permissions/requestApproval") return t("需要扩大权限"); + if (method === "item/tool/requestUserInput") return t("Codex 需要你的回答"); + if (method === "mcpServer/elicitation/request") return t("需要外部服务确认"); + return t("Codex 请求确认"); + } + + function requestRisk(request) { + const risk = String(request.risk || "medium").toLowerCase(); + return t(risk === "high" ? "高风险" : risk === "low" ? "低风险" : "需确认"); + } + + function requestCommand(request) { + const params = request.params || {}; + if (Array.isArray(params.commandActions)) { + return params.commandActions + .map((action) => action && typeof action === "object" ? action.command || action.description : "") + .filter(Boolean) + .join("\n"); + } + if (Array.isArray(params.command)) return params.command.join(" "); + return typeof params.command === "string" ? params.command : ""; + } + + function questionOptions(question) { + const options = question?.options || question?.choices || question?.enum; + if (!Array.isArray(options)) return []; + return options.map((option) => { + if (typeof option === "string") return { label: option, value: option }; + if (option && typeof option === "object") { + const value = option.value ?? option.id ?? option.label ?? option.name; + const label = option.label ?? option.name ?? value; + return { label: String(label ?? ""), value: String(value ?? "") }; + } + return null; + }).filter((option) => option && option.value); + } + + function renderQuestionFields(container, request) { + const questions = Array.isArray(request.params?.questions) ? request.params.questions : []; + container.replaceChildren(); + if (!questions.length) { + container.hidden = true; + return; + } + container.hidden = false; + questions.forEach((question, index) => { + if (!question || typeof question !== "object") return; + const field = document.createElement("label"); + field.className = "request-question"; + field.dataset.questionId = String(question.id ?? question.key ?? index); + const prompt = document.createElement("span"); + const questionPrompt = question.question ?? question.prompt ?? question.label; + prompt.textContent = questionPrompt === undefined || questionPrompt === null ? t("请输入") : String(questionPrompt); + field.append(prompt); + const options = questionOptions(question); + let control; + if (options.length) { + control = document.createElement("select"); + options.forEach((option) => { + const item = document.createElement("option"); + item.value = option.value; + item.textContent = option.label; + control.append(item); + }); + } else { + control = document.createElement("input"); + control.type = question.secret ? "password" : "text"; + control.placeholder = String(question.placeholder ?? ""); + } + control.className = "request-answer"; + control.dataset.answerId = field.dataset.questionId; + field.append(control); + container.append(field); + }); + } + + function inputResponseFromCard(article) { + const answers = {}; + for (const field of article.querySelectorAll(".request-question")) { + const id = field.dataset.questionId; + const value = field.querySelector(".request-answer")?.value ?? ""; + answers[id] = { answers: [value] }; + } + return { answers }; + } + + function requestResponseFromCard(request, article, responseBox, action) { + if (request.method === "item/tool/requestUserInput" && action === "allow") { + // Prefer explicit edits in the JSON editor; otherwise collect the + // first-class answer controls rendered for each question. + if (responseBox.value !== article.dataset.defaultResponse) { + try { + const parsed = JSON.parse(responseBox.value); + if (parsed && typeof parsed === "object") return parsed; + } catch { + return null; + } + } + return inputResponseFromCard(article); + } + if (action === "allow") { + const response = allowResponse(request, responseBox.value); + const scope = article.querySelector(".request-scope")?.value; + if (request.method === "item/permissions/requestApproval" && response && typeof response === "object" && scope) response.scope = scope; + return response; + } + if (action === "deny") return denyResponse(request); + try { + const parsed = JSON.parse(responseBox.value); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return null; + } + } + + function buildRequestCard(request) { + const fragment = $("requestTemplate").content.cloneNode(true); + const article = fragment.querySelector(".request"); + const method = String(request.method || "unknown"); + const risk = requestRisk(request); + article.dataset.requestKey = requestKey(request.requestId); + article.dataset.risk = String(request.risk || "medium").toLowerCase(); + fragment.querySelector(".request-method").textContent = requestTitle(request); + fragment.querySelector(".request-id").textContent = `#${request.requestId}`; + const riskNode = fragment.querySelector(".request-risk"); + riskNode.textContent = risk; + riskNode.dataset.risk = article.dataset.risk; + fragment.querySelector(".request-summary").textContent = requestSummary(request); + const commandNode = fragment.querySelector(".request-command"); + const commandText = requestCommand(request); + commandNode.textContent = commandText; + commandNode.hidden = !commandText; + fragment.querySelector(".request-json").textContent = JSON.stringify(request.params || {}, null, 2); + const responseBox = fragment.querySelector(".request-response"); + responseBox.value = JSON.stringify(defaultResponse(request), null, 2); + article.dataset.defaultResponse = responseBox.value; + const scopeWrap = fragment.querySelector(".request-scope-wrap"); + if (request.method === "item/permissions/requestApproval") scopeWrap.hidden = false; + renderQuestionFields(fragment.querySelector(".request-questions"), request); + const allow = fragment.querySelector(".request-allow"); + const deny = fragment.querySelector(".request-deny"); + const send = fragment.querySelector(".request-send"); + allow.textContent = t(method === "item/tool/requestUserInput" ? "提交回答" : method === "item/permissions/requestApproval" ? "允许" : "允许一次"); + send.textContent = t("发送自定义响应"); + allow.addEventListener("click", () => { + const result = requestResponseFromCard(request, article, responseBox, "allow"); + if (result) respond(request.requestId, JSON.stringify(result)); + else appendOutput("自定义响应不是有效 JSON", "error"); + }); + deny.addEventListener("click", () => respond(request.requestId, JSON.stringify(requestResponseFromCard(request, article, responseBox, "deny")))); + send.addEventListener("click", () => { + const result = requestResponseFromCard(request, article, responseBox, "custom"); + if (result) respond(request.requestId, JSON.stringify(result)); + else appendOutput("响应不是有效 JSON", "error"); + }); + if (state.sessionSwitching || state.modeSwitching || state.role !== "operator" || !RESPONDABLE_METHODS.has(method) || state.responding.has(requestKey(request.requestId))) { + for (const button of fragment.querySelectorAll("button")) button.disabled = true; + responseBox.disabled = true; + for (const control of fragment.querySelectorAll("input, select")) control.disabled = true; + } + return fragment; + } + + function renderRequests() { + const container = $("inlineRequests"); + const legacyContainer = $("requests"); + container.replaceChildren(); + if (legacyContainer) legacyContainer.replaceChildren(); + const requests = [...state.requests.values()]; + $("requestCount").textContent = String(requests.length); + $("factRequests").textContent = String(requests.length); + renderControlMode(); + const panel = $("requestsPanel"); + if (!requests.length) { + container.className = "inline-requests empty"; + if (legacyContainer) { + legacyContainer.className = "requests empty"; + legacyContainer.textContent = t("暂无待处理请求"); + } + if (panel) panel.open = false; + return; + } + container.className = "inline-requests"; + for (const request of requests) { + const fragment = buildRequestCard(request); + container.append(fragment); + } + if (panel) panel.open = false; + } + + function denyResponse(request) { + if (request.method === "item/commandExecution/requestApproval" || request.method === "item/fileChange/requestApproval") return { decision: "decline" }; + if (request.method === "item/permissions/requestApproval") return normalizePermissionResponse({}); + if (request.method === "applyPatchApproval" || request.method === "execCommandApproval") return { decision: { denied: { rejection: "远程参与者拒绝" } } }; + if (request.method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; + if (request.method === "item/tool/requestUserInput") return { answers: {} }; + return { decision: "decline" }; + } + + function allowResponse(request, raw) { + const method = request.method; + if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "accept" }; + if (method === "item/permissions/requestApproval") { + if (raw === undefined) return normalizePermissionResponse(request.params?.permissions); + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? normalizePermissionResponse(parsed.permissions, parsed.scope, parsed.strictAutoReview) + : normalizePermissionResponse({}); + } catch { + return normalizePermissionResponse(request.params?.permissions); + } + } + if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved" }; + if (method === "mcpServer/elicitation/request") return { action: "accept", content: null, _meta: null }; + // User-input requests need the operator's edited answers. Keep the JSON + // editor as the source of truth and fail closed if it is malformed. + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : { answers: {} }; + } catch { + return { answers: {} }; + } + } + + function respond(requestId, raw) { + if (state.sessionSwitching) return; + const key = requestKey(requestId); + if (state.responding.has(key)) return; + let result; + try { result = JSON.parse(raw); } catch { appendOutput("响应不是有效 JSON", "error"); return; } + try { sendFrame({ type: "respond", requestId, result }); } catch (error) { appendOutput(error.message, "error"); return; } + state.responding.add(key); + renderRequests(); + } + + function updateIds() { + $("threadId").textContent = state.threadId || "-"; + $("turnId").textContent = state.turnId || "-"; + const popoverThread = $("popoverThread"); + if (popoverThread) popoverThread.textContent = state.threadId || "-"; + const hasThread = Boolean(state.threadId); + const hasTurn = Boolean(state.turnId); + const hasComposerText = Boolean(composerText()); + const switching = Boolean(state.sessionSwitching || state.modeSwitching); + document.body.classList.toggle("turn-active", hasTurn); + $("startThreadButton").disabled = state.attachMode || !state.appReady || state.role !== "operator"; + const newSessionButton = $("newSessionButton"); + if (newSessionButton) { + newSessionButton.disabled = !sessionControlAllowed("sessionCreate") || switching || !state.appReady + || Boolean(state.newSessionCommandId) + || !["operator", "owner", "host"].includes(String(state.role || "")); + newSessionButton.setAttribute("aria-busy", String(Boolean(state.newSessionCommandId))); + } + $("startTurnButton").disabled = switching || !state.appReady || !hasThread || hasTurn || !hasComposerText || state.role !== "operator"; + $("steerButton").disabled = switching || !state.appReady || !hasTurn || !hasComposerText || state.role !== "operator"; + $("interruptButton").disabled = switching || !state.appReady || !hasTurn || state.role !== "operator"; + const messageInput = $("messageInput"); + if (messageInput) { + messageInput.contentEditable = switching ? "false" : "true"; + messageInput.setAttribute("aria-disabled", String(switching)); + } + for (const id of ["modelPickerButton", "permissionChip", "composerPlusButton"]) { + const control = $(id); + if (control) control.disabled = switching; + } + const send = $("startTurnButton"); + if (send) { + send.title = t(hasTurn ? "发送 Steer" : "发送消息"); + send.setAttribute("aria-label", t(hasTurn ? "发送 Steer" : "发送消息")); + } + const settingsModelValue = $("settingsModelValue"); + if (settingsModelValue) settingsModelValue.textContent = state.currentModel ? modelDisplayName(state.currentModel, state.currentEffort) : t("默认"); + const settingsPermissionValue = $("settingsPermissionValue"); + if (settingsPermissionValue) settingsPermissionValue.textContent = permissionSandboxLabel(state.sandboxPolicy); + renderControlMode(); + } + + function protocolMethodForEvent(event, payload) { + if (typeof payload?.method === "string") return payload.method; + if (typeof payload?.params?.method === "string") return payload.params.method; + const type = String(event?.type || ""); + if (type === "item.started") return "item/started"; + if (type === "item.completed") return "item/completed"; + if (type === "thread.started") return "thread/started"; + if (type === "turn.started") return "turn/started"; + if (type === "turn.completed") return "turn/completed"; + if (type === "turn.plan.updated") return "turn/plan/updated"; + if (type === "turn.diff.updated") return "turn/diff/updated"; + return ""; + } + + function handleProtocolNotification(method, payload, eventType = "") { + const params = eventParams(payload); + if (method === "item/started" || eventType === "item.started") { + if (!eventBelongsToCurrentTurn(payload)) return true; + handleItemLifecycle("started", params); + return true; + } + if (method === "item/completed" || eventType === "item.completed") { + if (!eventBelongsToCurrentTurn(payload)) return true; + handleItemLifecycle("completed", params); + return true; + } + if (method === "turn/plan/updated" || method === "turn/plan/update" || eventType === "turn.plan.updated") { + if (!eventBelongsToCurrentTurn(payload)) return true; + handlePlanUpdate(params); + return true; + } + if (method === "turn/diff/updated" || method === "turn/diff/update" || eventType === "turn.diff.updated") { + if (!eventBelongsToCurrentTurn(payload)) return true; + handleDiffUpdate(params); + return true; + } + if (/^(?:item\/)?fileChange\/outputDelta$/.test(method) + || method === "item/fileChange/delta") { + if (!eventBelongsToCurrentTurn(payload)) return true; + appendFileChangeChunk(payload, textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk)); + return true; + } + if (/^(?:item\/)?(?:fileRead|readFile|fileReadOutput)\/(?:outputDelta|delta|textDelta)$/.test(method) + || method === "item/fileRead/outputDelta" + || method === "item/fileRead/delta") { + if (!eventBelongsToCurrentTurn(payload)) return true; + appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk ?? params.content), "read", { + kind: "read", + itemId: params.itemId, + turnId: eventTurnId(payload), + }); + return true; + } + if (method === "item/commandExecution/outputDelta" + || method === "command/exec/outputDelta" + || method === "process/outputDelta") { + if (!eventBelongsToCurrentTurn(payload)) return true; + const stream = params.stream || params.channel || (params.stderr ? "stderr" : "stdout"); + appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk), stream, { + kind: "tool", + itemId: params.itemId, + turnId: eventTurnId(payload), + }); + return true; + } + if (method === "item/reasoning/summaryTextDelta" + || method === "item/reasoning/textDelta" + || method === "item/plan/delta") { + if (!eventBelongsToCurrentTurn(payload)) return true; + appendOutputChunk(textFromValue(params.delta ?? params.text), "reasoning", { + kind: method === "item/plan/delta" ? "plan" : "reasoning", + itemId: params.itemId, + turnId: eventTurnId(payload), + }); + return true; + } + if (method === "turn/started") { + const turn = isRecord(params.turn) ? params.turn : params; + const startedTurnId = turn.id || params.turnId; + const workStart = timestampMs( + turn.firstTurnWorkItemStartedAtMs, + turn.workStartedAtMs, + params.firstTurnWorkItemStartedAtMs, + params.workStartedAtMs, + ); + if (workStart !== null) state.turnWorkStartedAt = workStart; + startTurnClock(startedTurnId, turn.startedAtMs || params.startedAtMs, turn.elapsedMs || params.elapsedMs); + attachPendingUserToTurn(startedTurnId); + return true; + } + if (method === "turn/completed") { + const turn = isRecord(params.turn) ? params.turn : params; + const turnId = turn.id || params.turnId || ""; + if (!eventBelongsToCurrentTurn(payload)) return true; + const status = normalizeActivityStatus(turn.status || params.status, "completed"); + const duration = turn.durationMs || params.durationMs; + const worked = workedDurationFor(turn, workedDurationFor(params, null)); + const finalAssistantStart = timestampMs(turn.finalAssistantStartedAtMs, params.finalAssistantStartedAtMs); + if (finalAssistantStart !== null) state.finalAssistantStartedAt = finalAssistantStart; + finishActivitiesForTurn(turnId, status); + finishAssistantStream(); + const completedTurnId = turnId || state.turnId || ""; + stopTurnClock(status, duration, turn.completedAtMs || params.completedAtMs, worked); + appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? duration ?? state.lastTurnDurationMs); + reconcileTurnDividers(); + if (completedTurnId) { + state.retiredTurnIds.add(completedTurnId); + if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value); + } + state.pendingUserText = ""; + state.turnId = ""; + updateIds(); + return true; + } + return false; + } + + function handleEvent(event) { + const eventSeq = finiteNumber(event.seq); + // The relay sends a buffered event stream followed by one control + // `session.snapshot`. Treat that control frame as the only baseline during + // the handshake; an older host `session.snapshot` event in the buffer is + // just historical data and may describe a turn that already ended. + if (state.awaitingSnapshot) return; + // `subscribe` may replay events that are already represented by the + // following authoritative snapshot. Ignore those frames entirely so a + // stale task.started/task.status cannot reopen the composer state. + if (eventSeq !== null && state.lastSnapshotSeq > 0 && eventSeq <= state.lastSnapshotSeq) return; + if (eventSeq !== null && eventSeq > state.lastSeq) state.lastSeq = eventSeq; + $("latestSeq").textContent = `seq ${state.lastSeq}`; + $("lastEvent").textContent = `${event.seq || "-"} / ${event.type || "event"}`; + const payload = isRecord(event.payload) ? { ...event.payload } : {}; + // Older bridge versions put identity/status fields on the event envelope + // instead of inside payload. Normalize both shapes before routing so a + // terminal event cannot be attributed to the wrong turn. + for (const key of [ + "threadId", "turnId", "requestId", "method", "params", "status", "executionStatus", "activity", "turnStatus", "activeFlags", + "controlMode", "mode", "targetMode", "modeEpoch", "capabilities", + "startedAtMs", "durationMs", "completedAtMs", "workedDurationMs", "workDurationMs", "workedForMs", + "firstTurnWorkItemStartedAtMs", "firstWorkItemStartedAtMs", "workStartedAtMs", "finalAssistantStartedAtMs", + ]) { + if (payload[key] === undefined && event[key] !== undefined) payload[key] = event[key]; + } + const usagePayload = payload.tokenUsage ?? payload.latestTokenUsageInfo ?? payload.contextUsage ?? payload.usage + ?? payload.params?.tokenUsage ?? payload.params?.latestTokenUsageInfo ?? payload.params?.contextUsage; + if (usagePayload !== undefined) { + state.tokenUsage = usagePayload; + renderUsage(); + } + const protocolMethod = protocolMethodForEvent(event, payload); + // Do not let a replayed terminal event from an older turn overwrite the + // timer/status of a newer turn. The protocol handler performs the same + // check, but status is normally applied before routing the event. + const staleTerminalEvent = (event.type === "task.finished" + || event.type === "task.cancelled" + || protocolMethod === "turn/completed") + && !eventBelongsToCurrentTurn(payload); + // Status is carried both as a typed relay field and inside the payload for + // older clients. Apply it before routing the event so a normal output + // delta cannot hide an active thinking/editing/approval state. + const hasExecutionStatus = isRecord(payload.executionStatus) + || isRecord(event.status) + || isRecord(payload.status) + || typeof payload.activity === "string" + || typeof payload.turnStatus === "string" + || Array.isArray(payload.activeFlags) + || payload.startedAtMs !== undefined + || payload.durationMs !== undefined; + if (hasExecutionStatus && !staleTerminalEvent) { + applyStatusSnapshot({ + ...payload, + status: payload.executionStatus || event.status || payload.status, + }, { allowTerminal: true, showIdle: false }); + } + if (protocolMethod && handleProtocolNotification(protocolMethod, payload, event.type)) { + if (event.type === "item.started" + || event.type === "item.completed" + || event.type === "task.status" + || protocolMethod === "turn/completed") updateIds(); + return; + } + if (event.type === "control.mode.switching" || event.type === "control.mode.changed") { + const requestedMode = normalizeControlMode(firstString(payload.controlMode, payload.mode, payload.targetMode)); + if (requestedMode && (requestedMode !== state.controlMode || state.modeSwitching)) { + state.modeSwitching = true; + state.requestedControlMode = requestedMode; + if (state.modeRequestEpoch < 0) state.modeRequestEpoch = state.modeEpoch; + setConversationStatus("正在切换控制模式", "active"); + updateIds(); + } + return; + } + if (event.type === "session.switching") { + state.sessionSwitching = true; + const targetThreadId = firstString(payload.targetThreadId, payload.threadId); + let switchContext = state.sessionSwitchContext; + if (targetThreadId) { + state.sessionSelectedThreadId = targetThreadId; + switchContext = beginSessionSwitchContext(targetThreadId); + // Route subsequent target events against the requested owner while + // the previous transcript remains mounted as the visual fallback. + state.threadId = targetThreadId; + if (switchContext?.targetTitle) { + setConversationStatus( + uiLocale() === "en-US" + ? `Switching to “${switchContext.targetTitle}”` + : `正在切换到「${switchContext.targetTitle}」`, + "active", + ); + } + } + finishAssistantStream(); + // Keep the previous transcript mounted until the target's authoritative + // snapshot arrives. The bridge may emit `session.switching` before + // owner discovery/follow completes; clearing here made a failed switch + // look like an empty conversation and left the user with no way to tell + // whether the target had actually loaded. + setSessionSwitchingVisual(true); + if (!switchContext?.targetTitle) setConversationStatus("正在切换会话", "active"); + renderSessionPicker(); + return; + } + if (event.type === "session.selected") { + const selectedThreadId = firstString(payload.threadId, payload.activeThreadId); + const switchContext = state.sessionSwitchContext; + if (payload.failed === true) { + // VS Code-driven attachment changes have no browser command result. + // The adapter therefore publishes an explicit failed selection for + // the previous thread. Roll routing/title back even if a target + // snapshot was already rendered while the adapter validated its owner. + const previousThreadId = firstString(switchContext?.previousThreadId, selectedThreadId); + restoreSessionSwitchContext(); + if (previousThreadId) { + state.threadId = previousThreadId; + state.sessionSelectedThreadId = previousThreadId; + syncSessionActive(previousThreadId); + } + state.sessionListError = sessionErrorMessage(payload, "会话切换失败,已恢复原会话"); + setConversationStatus(state.sessionListError, "warning"); + renderSessionPicker(); + return; + } + + const matchesTarget = Boolean(switchContext + && selectedThreadId + && switchContext.targetThreadId === selectedThreadId); + if (selectedThreadId && (!switchContext || matchesTarget)) { + state.sessionSelectedThreadId = selectedThreadId; + syncSessionActive(selectedThreadId); + } + // This is only one half of the switch commit. An acknowledgement for a + // superseded target is ignored, and a matching acknowledgement keeps all + // input disabled until the target's authoritative snapshot is committed. + if (matchesTarget) { + switchContext.selectedAckReady = true; + if (finishSessionSwitchIfReady()) { + setConversationStatus("会话已切换", "ready"); + } else { + state.sessionSwitching = true; + setSessionSwitchingVisual(true); + setConversationStatus("正在加载会话", "active"); + } + } else if (!switchContext) { + state.sessionSwitching = false; + finishSessionSwitchContext(); + } + renderSessionPicker(); + return; + } + if (/error|warning/i.test(String(event.type || "")) + || ["error", "warning"].includes(String(payload.method || "").toLowerCase())) { + finishAssistantStream(); + appendOutput(eventMessage(payload), /warning/i.test(String(event.type || "")) ? "meta" : "error"); + return; + } + if (event.type === "connection.opened" || event.type === "app.ready") { + state.appReady = true; + $("appState").textContent = appStatusLabel("ready"); + $("factApp").textContent = appStatusLabel("ready"); + } + if (event.type === "connection.closed" || event.type === "host.disconnected" || event.type === "app.exited") { + finishAssistantStream(); + const disconnectedTurnId = state.turnId; + if (disconnectedTurnId) finishActivitiesForTurn(disconnectedTurnId, "interrupted"); + state.pendingUserText = ""; + state.appReady = false; + state.snapshotNoticeShown = false; + state.turnId = ""; + state.turnStartedAt = null; + state.turnWorkStartedAt = null; + state.finalAssistantStartedAt = null; + state.workedDurationMs = null; + state.lastWorkedDurationMs = null; + state.currentActivity = "idle"; + state.currentActivityStartedAt = null; + state.currentActivityDurationMs = null; + state.currentActivityTurnId = ""; + state.subagents = []; + renderSubagents(); + updateLiveActivity("idle"); + state.sessionListLoading = false; + state.sessionListCommandId = ""; + state.newSessionCommandId = ""; + state.sessions = []; + state.sessionFocusedId = ""; + if (state.sessionSwitching) { + // An explicit host disconnect aborts an in-flight hand-off. Restore + // the previous thread identity while keeping its transcript mounted. + restoreSessionSwitchContext(); + } + state.sessionSelectedThreadId = ""; + state.sessionListError = "VS Code 主机未连接"; + renderSessionPicker(); + $("appState").textContent = appStatusLabel("offline"); + $("factApp").textContent = appStatusLabel("offline"); + updateIds(); + updateScrollToBottom($("output")); + } + if (event.type === "output.snapshot") { + const snapshotThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId); + if (!outputProjectionAllowed(snapshotThreadId)) return; + const committed = commitOutputProjection(snapshotThreadId, typeof payload.text === "string" ? payload.text : "", payload.messages, { + historyComplete: snapshotHistoryComplete(payload), + authoritativeSnapshot: true, + }); + if (!committed) return; + if (Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } + return; + } + if ((event.type === "output.delta" || event.type === "output.chunk") + && (payload.text || Array.isArray(payload.messages) || isRecord(payload.messagesPatch))) { + const projectionThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId); + if (!outputProjectionAllowed(projectionThreadId)) return; + if (!eventBelongsToCurrentTurn(payload)) return; + const projectionMatches = !projectionThreadId || projectionThreadId === state.syncedThreadId; + // Attach-mode adapters include the complete role-aware projection on a + // delta. Re-rendering that projection keeps reasoning, tools, edits and + // assistant text in their canonical item boundaries while retaining the + // legacy append-only text field for older bridges. + if (Array.isArray(payload.messages)) { + if (!projectionMatches) { + const committed = commitOutputProjection( + projectionThreadId, + typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "", + payload.messages, + { historyComplete: snapshotHistoryComplete(payload) }, + ); + if (committed && Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } + return; + } + if (Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } + if (payload.structureChanged === false && reconcileStructuredOutput( + typeof payload.outputTail === "string" ? payload.outputTail : payload.text, + payload.messages, + )) return; + replaceOutput( + typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "", + payload.messages, + ); + return; + } + if (isRecord(payload.messagesPatch)) { + // A suffix patch has meaning only relative to the same thread's + // authoritative baseline. Never apply it to the transcript retained + // while another session is still loading. + if (!projectionMatches) return; + if (Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } + const patchedMessages = applyStructuredMessagesPatch(payload.messagesPatch); + if (patchedMessages) { + const outputTail = typeof payload.outputTail === "string" + ? payload.outputTail + : typeof payload.text === "string" + ? `${$("output")?.dataset.outputTail || ""}${payload.text}`.slice(-32_000) + : $("output")?.dataset.outputTail || ""; + if (reconcileStructuredOutput(outputTail, patchedMessages)) return; + replaceOutput(outputTail, patchedMessages); + return; + } + } + if (!projectionMatches) return; + if (Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } + appendOutputChunk(payload.text, payload.stream, { + kind: payload.kind, + itemId: payload.itemId, + turnId: eventTurnId(payload), + timestamp: payload.timestamp || payload.startedAtMs, + }); + return; + } + if (event.type === "app.stderr") { + finishAssistantStream(); + appendOutput(payload.text, "meta"); + return; + } + if (event.type === "approval.requested" || event.type === "input.requested" || event.type === "server.requested" || event.type === "server.request") { + state.requests.set(requestKey(payload.requestId), { + requestId: payload.requestId, + method: payload.method, + params: payload.params || payload, + ...(payload.risk ? { risk: payload.risk } : {}), + ...(payload.summary ? { summary: payload.summary } : {}), + ...(payload.commandHash ? { commandHash: payload.commandHash } : {}), + ...(payload.createdAt ? { createdAt: payload.createdAt } : {}), + ...(payload.expiresAt ? { expiresAt: payload.expiresAt } : {}), + }); + renderRequests(); + const inlineRequests = $("inlineRequests"); + if (inlineRequests) inlineRequests.scrollTop = inlineRequests.scrollHeight; + finishAssistantStream(); + // The inline request card is the canonical representation. A separate + // plain-text transcript line would duplicate the approval/input prompt + // and could be mistaken for Codex output. + const waitingActivity = payload.method === "item/tool/requestUserInput" + || payload.method === "mcpServer/elicitation/request" + ? "waiting_input" + : "waiting_approval"; + state.currentActivity = waitingActivity; + setConversationStatus(statusActivityLabel(waitingActivity), "warning"); + return; + } + if (event.type === "server.responded" || event.type === "approval.resolved" || event.type === "input.resolved" || event.type === "approval.expired" || event.type === "input.expired") { + const key = resolveRequestKey(payload.requestId); + state.responding.delete(key); + state.requests.delete(key); + renderRequests(); + return; + } + if (event.type === "task.status") { + applyStatusSnapshot(payload, { allowTerminal: true, showIdle: false }); + updateIds(); + return; + } + if (event.type === "task.started") { + finishAssistantStream(); + if (payload.turnId) state.turnId = payload.turnId; + if (payload.threadId) state.threadId = payload.threadId; + attachPendingUserToTurn(payload.turnId || state.turnId); + applyStatusSnapshot(payload, { allowTerminal: false }); + if (!state.currentActivity || state.currentActivity === "idle") state.currentActivity = "running"; + updateLiveActivity(state.currentActivity, state.turnStartedAt, null, payload.activeFlags || [], payload.turnId || state.turnId); + updateIds(); + return; + } + if (event.type === "task.finished" || event.type === "task.cancelled") { + if (!eventBelongsToCurrentTurn(payload)) return; + finishAssistantStream(); + const reportedStatus = payload.turnStatus + ?? payload.status?.turnStatus + ?? payload.status?.status + ?? payload.status + ?? payload.executionStatus?.turnStatus + ?? payload.executionStatus?.status + ?? payload.executionStatus; + const finalStatus = event.type === "task.cancelled" + ? "interrupted" + : normalizeActivityStatus(reportedStatus, "completed"); + const finishedTurnId = eventTurnId(payload) || state.turnId || ""; + applyStatusSnapshot({ ...payload, turnStatus: finalStatus }, { allowTerminal: false }); + state.pendingUserText = ""; + const worked = workedDurationFor(payload, null); + stopTurnClock(finalStatus, payload.durationMs, payload.completedAtMs, worked); + appendCompletedTurnDivider(finishedTurnId, finalStatus, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs); + reconcileTurnDividers(); + if (finishedTurnId) state.retiredTurnIds.add(finishedTurnId); + if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value); + if (payload.threadId) state.threadId = payload.threadId; + state.pendingUserText = ""; + state.turnId = ""; + updateIds(); + return; + } + if (event.type === "session.created" && payload.thread?.id) { + state.threadId = payload.thread.id; + if (state.syncedThreadId === null || state.syncedThreadId === payload.thread.id) { + prepareForSessionSnapshot(payload.thread.id); + applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload); + } else { + setConversationStatus("正在加载会话", "active"); + } + updateIds(); + return; + } + if (event.type === "session.snapshot") { + state.awaitingSnapshot = false; + state.appReady = true; + const snapshotThreadId = payload.threadId || ""; + applyControlModeSnapshot(payload.metadata); + const waitingForSession = payload.state === "waiting_for_host" + || (isRecord(payload.metadata) && payload.metadata.waitingForSession === true); + const eventSnapshotSeq = finiteNumber(payload.latestSeq, event.seq); + if (eventSnapshotSeq !== null) state.lastSnapshotSeq = eventSnapshotSeq; + const adapterName = payload.metadata && payload.metadata.adapter; + if (adapterName === "codex-ipc-follower") setAttachMode(true); + else if (adapterName) setAttachMode(false); + const hasProjection = typeof payload.outputTail === "string" || Array.isArray(payload.messages); + const projectionCommitted = hasProjection && commitOutputProjection( + snapshotThreadId, + typeof payload.outputTail === "string" ? payload.outputTail : "", + payload.messages, + { + historyComplete: snapshotHistoryComplete(payload, payload.state), + authoritativeSnapshot: true, + }, + ); + const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId; + if (!appliesToView) { + // Keep the retained transcript and loading state until a matching, + // non-placeholder snapshot is available for the selected thread. + updateIds(); + return; + } + prepareForSessionSnapshot(snapshotThreadId); + applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload.state); + if (Array.isArray(payload.subagents)) { + state.subagents = payload.subagents; + renderSubagents(); + } else if (isRecord(payload.state) && Array.isArray(payload.state.subagents)) { + state.subagents = payload.state.subagents; + renderSubagents(); + } + if (payload.threadId !== undefined) state.threadId = payload.threadId || ""; + if (payload.turnId !== undefined) state.turnId = payload.turnId || ""; + applyStatusSnapshot({ + ...payload, + ...(payload.executionStatus ? { status: payload.executionStatus } : {}), + turnId: payload.turnId !== undefined ? payload.turnId : state.turnId, + }, { allowTerminal: true, showIdle: false }); + if (payload.metadata && typeof payload.metadata.cwd === "string") $("cwdInput").value = payload.metadata.cwd; + reconcileSnapshotTerminalState(payload, payload.state); + if (Array.isArray(payload.pendingRequests)) { + state.requests = new Map(payload.pendingRequests + .filter((request) => request && request.requestId !== undefined) + .map((request) => [requestKey(request.requestId), request])); + state.responding.clear(); + renderRequests(); + } + if (waitingForSession) { + setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active"); + state.snapshotNoticeShown = false; + } else if (!state.snapshotNoticeShown && state.turnStartedAt === null && (!state.currentActivity || state.currentActivity === "idle")) { + setConversationStatus("ready"); + state.snapshotNoticeShown = true; + } + updateIds(); + return; + } + if (event.type === "session.closed") { + finishAssistantStream(); + state.pendingUserText = ""; + state.appReady = false; + state.sessions = []; + state.sessionFocusedId = ""; + state.sessionSelectedThreadId = ""; + state.threadId = ""; + state.turnId = ""; + const closedTitle = $("threadTitle"); + if (closedTitle) closedTitle.textContent = "Codex"; + state.turnStartedAt = null; + state.turnWorkStartedAt = null; + state.finalAssistantStartedAt = null; + state.workedDurationMs = null; + state.lastWorkedDurationMs = null; + state.currentActivity = "idle"; + state.currentActivityStartedAt = null; + state.subagents = []; + resetSessionModelMetadata(); + renderSubagents(); + updateLiveActivity("idle"); + state.sessionSwitching = false; + state.sessionSelectCommandId = ""; + finishSessionSwitchContext(); + setConversationStatus("会话已关闭", "warning"); + state.sessionListError = "VS Code 会话已关闭"; + renderSessionPicker(); + updateIds(); + return; + } + if (event.type === "app.notification") { + if (payload.method === "thread/started" && payload.params?.thread?.id) state.threadId = payload.params.thread.id; + if (payload.method === "turn/started" && payload.params?.turn?.id) { + state.turnId = payload.params.turn.id; + attachPendingUserToTurn(state.turnId); + } + if (payload.method === "turn/completed") { + state.turnId = ""; + finishAssistantStream(); + } + if (payload.text) { + finishAssistantStream(); + appendOutput(payload.text); + } + else if (["thread/started", "turn/started", "turn/completed", "thread/status/changed"].includes(payload.method)) appendOutput(`${payload.method}`, "meta"); + updateIds(); + return; + } + if (event.type === "thread.started" && payload.params?.thread?.id) { + state.threadId = payload.params.thread.id; + updateIds(); + return; + } + if (event.type === "turn.started" && payload.params?.turn?.id) { + state.turnId = payload.params.turn.id; + attachPendingUserToTurn(state.turnId); + updateIds(); + return; + } + if (event.type === "turn.completed") { + finishAssistantStream(); + const completedTurnId = eventTurnId(payload) || state.turnId || ""; + const status = normalizeActivityStatus(payload.status, "completed"); + const worked = workedDurationFor(payload, null); + stopTurnClock(status, payload.durationMs, payload.completedAtMs, worked); + appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs); + reconcileTurnDividers(); + state.turnId = ""; + updateIds(); + return; + } + if (event.type === "command.result") { + const commandId = payload.commandId; + // The relay sends a sequenced event to every subscriber and a direct + // acknowledgement to the originating browser. Render a result once. + if (commandId) { + const key = String(commandId); + if (state.commandResults.has(key)) return; + state.commandResults.add(key); + if (state.commandResults.size > 2_000) state.commandResults.delete(state.commandResults.values().next().value); + } + const commandMethodName = sessionCommandMethod(payload.method); + if (commandMethodName === "control/mode/set") { + state.modeCommandId = ""; + if (!payload.ok) { + clearControlModeRequest(); + setConversationStatus(sessionErrorMessage(payload, "控制模式切换失败"), "warning"); + } else if (state.modeSwitching) { + // The command result is only an acknowledgement. Keep the requested + // segment pending until a newer authoritative snapshot supplies the + // resulting modeEpoch and capabilities. + setConversationStatus("正在切换控制模式", "active"); + } + updateIds(); + return; + } + if (commandMethodName === "session/list" || commandMethodName === "thread/list") { + if (!payload.ok) { + state.sessionListLoading = false; + state.sessionListError = eventMessage(payload); + state.sessionListError = sessionErrorMessage(payload, state.sessionListError); + renderSessionPicker(); + } else { + applySessionListResult(payload.result ?? payload); + } + return; + } + if (commandMethodName === "session/select" || commandMethodName === "thread/select") { + if (!payload.ok) { + state.sessionListError = failSessionSwitch(payload, eventMessage(payload)); + renderSessionPicker(); + setConversationStatus(state.sessionListError, "warning"); + requestRefresh(); + } else { + applySessionSelectResult(payload.result ?? payload); + } + return; + } + if (commandMethodName === "session/new" || commandMethodName === "thread/new") { + state.newSessionCommandId = ""; + if (!payload.ok) { + const detail = eventMessage(payload); + setConversationStatus( + uiLocale() === "en-US" + ? `Unable to create a new conversation: ${detail}` + : `新会话创建失败:${detail}`, + "warning", + ); + } else { + setConversationStatus("新会话已在 VS Code 中打开", "ready"); + // The official command opens the new panel asynchronously. Give the + // host a short window to publish its rollout/owner, then refresh the + // same history menu so it can be selected without leaving the web UI. + openSessionHistory(); + let refreshAttempts = 0; + const refreshNewSession = () => { + if (!state.sessionPickerOpen || refreshAttempts >= 4) return; + refreshAttempts += 1; + if (!state.sessionListLoading) requestSessionList(); + setTimeout(refreshNewSession, 700); + }; + setTimeout(refreshNewSession, 500); + } + updateIds(); + return; + } + if (payload.method === "thread/settings/update") { + state.modelUpdatePending = false; + if (!payload.ok) { + const detail = eventMessage(payload); + appendOutput( + uiLocale() === "en-US" + ? `Unable to update model settings: ${detail}` + : `模型设置更新失败:${detail}`, + "error", + ); + } else setConversationStatus(t("模型设置已更新"), "ready"); + renderModelPicker(); + return; + } + if (!payload.ok) { + const methodLabel = payload.method || t("命令"); + const uncertainty = payload.uncertain + ? uiText("(执行状态未知,请等待主机恢复)", " (execution status unknown; wait for the host to recover)") + : ""; + appendOutput(`${methodLabel}: ${JSON.stringify(payload.error)}${uncertainty}`, "error"); + } + else { + const result = payload.result || {}; + if (payload.method === "thread/start" && result.thread?.id) state.threadId = result.thread.id; + if (payload.method === "turn/start" && result.turn?.id) state.turnId = result.turn.id; + if (payload.method === "turn/start" && result.turn?.id) attachPendingUserToTurn(result.turn.id); + if (payload.method === "thread/start") { + applySessionMetadata({ ...result, ...(isRecord(result.thread) ? result.thread : {}) }, result); + } + finishAssistantStream(); + appendOutput(`${payload.method || "命令"} 完成`, "meta"); + updateIds(); + } + } + } + + function handleMessage(message) { + if (message.type === "auth.ok") { + state.role = message.role; + setAuthRequired(message.authRequired); + $("roleBadge").textContent = message.role; + $("roleBadge").className = `badge ${message.role === "operator" ? "" : "warning"}`; + setConnection("pending", "同步中"); + state.awaitingSnapshot = true; + sendFrame({ type: "subscribe", fromSeq: state.lastSeq }); + return; + } + // A host event can legitimately have the same type as a relay control + // frame (notably `session.snapshot`). Route the envelope by `kind` first + // so its payload is not mistaken for the compact control shape below. + if (message.kind === "event") { + handleEvent(message); + return; + } + if (message.type === "session.snapshot") { + state.awaitingSnapshot = false; + const snapshot = message.snapshot || {}; + const appState = snapshot.state || {}; + const snapshotThreadId = appState.activeThreadId || ""; + const controlMetadata = { + ...(isRecord(snapshot.metadata) ? snapshot.metadata : {}), + ...(isRecord(appState.sessionMetadata) ? appState.sessionMetadata : {}), + }; + applyControlModeSnapshot(controlMetadata); + const waitingForSession = controlMetadata.waitingForSession === true + || (!snapshotThreadId && controlMetadata.attachReady === false); + $("appState").textContent = appStatusLabel(appState.app); + $("factApp").textContent = appState.app ? appStatusLabel(appState.app) : "-"; + $("factClients").textContent = String((snapshot.clients || []).length); + state.appReady = appState.app === "ready" || appState.initialized === true; + if (appState.mode === "host") setAttachMode(true); + if (appState.mode === "embedded") setAttachMode(false); + const snapshotSeq = finiteNumber(snapshot.latestSeq); + if (snapshotSeq !== null) { + state.lastSeq = snapshotSeq; + state.lastSnapshotSeq = snapshotSeq; + } + // The control snapshot is authoritative for routing, but its transcript + // fields can still be placeholders while VS Code is loading history. + // Route target events immediately without replacing the retained view. + if (snapshotThreadId || state.syncedThreadId === null) state.threadId = snapshotThreadId; + state.turnId = appState.activeTurnId || ""; + state.requests = new Map((snapshot.pendingRequests || []).map((request) => [requestKey(request.requestId), request])); + state.responding.clear(); + // `subscribe` replays buffered events before sending this control + // snapshot. The snapshot is authoritative, so reconcile once at the + // end of the replay instead of leaving transient duplicate bubbles. + const hasProjection = typeof snapshot.outputTail === "string" || Array.isArray(snapshot.messages); + const projectionCommitted = hasProjection && commitOutputProjection( + snapshotThreadId, + typeof snapshot.outputTail === "string" ? snapshot.outputTail : "", + snapshot.messages, + { + historyComplete: snapshotHistoryComplete(snapshot, snapshot.metadata, appState), + authoritativeSnapshot: true, + }, + ); + const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId; + if (appliesToView) { + prepareForSessionSnapshot(snapshotThreadId); + applySessionMetadata({ + ...controlMetadata, + ...appState, + }, appState); + if (Array.isArray(snapshot.subagents)) { + state.subagents = snapshot.subagents; + renderSubagents(); + } else if (Array.isArray(appState.subagents)) { + state.subagents = appState.subagents; + renderSubagents(); + } + applyStatusSnapshot({ + ...(snapshot.status ? { status: snapshot.status } : {}), + ...(snapshot.executionStatus ? { status: snapshot.executionStatus } : {}), + ...(snapshot.state && typeof snapshot.state === "object" ? snapshot.state : {}), + turnId: state.turnId, + }, { allowTerminal: true, showIdle: false }); + reconcileSnapshotTerminalState(snapshot, appState); + } else if (state.sessionSwitching || hasVisibleOutputProjection()) { + setConversationStatus("正在加载会话", "active"); + } + renderRequests(); + updateIds(); + setConnection("online", "已连接"); + if (waitingForSession) { + setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active"); + state.snapshotNoticeShown = false; + } else { + $("outputHint").textContent = `${appStatusLabel(appState.app || "app-server")} / ${state.role}`; + } + if (state.sessionPickerOpen && state.appReady) requestSessionList(); + return; + } + if (message.type === "resync.required") { + appendOutput("事件窗口已过期,请以当前快照为准", "error"); + return; + } + if (message.type === "response.accepted") { + const key = resolveRequestKey(message.requestId); + state.responding.delete(key); + state.requests.delete(key); + renderRequests(); + appendOutput(`请求 #${message.requestId} 已提交`, "meta"); + return; + } + if (message.type === "response.pending") { + appendOutput(`请求 #${message.requestId} 已发送,等待 VS Code 主机确认`, "meta"); + return; + } + if (message.type === "command.accepted") return; + if (message.type === "command.rejected" || message.type === "response.rejected") { + if (message.requestId !== undefined) state.responding.delete(resolveRequestKey(message.requestId)); + const rejectedId = String(message.commandId || ""); + if (rejectedId && rejectedId === String(state.modeCommandId || "")) { + clearControlModeRequest(); + setConversationStatus(sessionErrorMessage(message, "控制模式切换失败"), "warning"); + updateIds(); + return; + } + if (rejectedId && rejectedId === String(state.newSessionCommandId || "")) { + state.newSessionCommandId = ""; + const detail = message.message || message.code || t("未知错误"); + setConversationStatus( + uiLocale() === "en-US" + ? `Unable to create a new conversation: ${detail}` + : `新会话创建失败:${detail}`, + "warning", + ); + updateIds(); + return; + } + if (rejectedId && rejectedId === String(state.sessionListCommandId || "")) { + state.sessionListLoading = false; + state.sessionListCommandId = ""; + state.sessionListError = sessionErrorMessage(message, "无法读取会话"); + renderSessionPicker(); + return; + } + if (rejectedId && rejectedId === String(state.sessionSelectCommandId || "")) { + state.sessionListError = failSessionSwitch(message, "会话切换失败"); + renderSessionPicker(); + setConversationStatus(state.sessionListError, "warning"); + requestRefresh(); + return; + } + appendOutput(`${message.code}: ${message.message}`, "error"); + renderRequests(); + return; + } + if (message.type === "command.result") { + handleEvent({ type: "command.result", seq: message.seq, payload: message }); + return; + } + if (message.type === "error") appendOutput(message.message || "relay error", "error"); + } + + function embeddedSocketUrl(value) { + if (!embeddedInAether) return ""; + const raw = String(value || "").trim(); + if (!raw) return ""; + try { + const url = new URL(raw, location.href); + const expectedProtocol = location.protocol === "https:" ? "wss:" : "ws:"; + if (url.origin !== `${location.protocol}//${location.host}` && url.origin !== `${expectedProtocol}//${location.host}`) { + throw new Error(t("云端连接地址必须与当前页面同源")); + } + if (url.protocol === "http:") url.protocol = "ws:"; + if (url.protocol === "https:") url.protocol = "wss:"; + if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new Error(t("云端连接配置无效")); + return url.href; + } catch (error) { + appendOutput(error?.message || t("云端连接配置无效"), "error"); + embedBridge.reportState("error", { code: "invalid_ws_url", message: error?.message || "invalid ws url" }); + return ""; + } + } + + function requestEmbedTicket(reason = "missing") { + if (!embeddedInAether || state.embedStopped || state.embedTicketRequested) return; + state.embedTicketRequested = true; + setConversationStatus(t("正在获取新的连接凭证"), "active"); + embedBridge.requestTicket({ reason, deviceId: state.embedDeviceId || undefined }); + } + + function disconnectEmbedded(reason = "parent") { + if (!embeddedInAether) return; + state.embedStopped = true; + state.embedTicket = ""; + state.embedTicketRequested = false; + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + const socket = state.ws; + state.ws = null; + if (socket && socket.readyState <= WebSocket.OPEN) socket.close(1000, reason); + setConnection("offline", "云端连接已断开"); + setConversationStatus(t("父页面已断开连接"), "warning"); + } + + function applyEmbedConnection(message) { + if (!embeddedInAether) return; + if (message.locale) i18n?.setLocale?.(message.locale, { persist: false }); + const ticket = typeof message.ticket === "string" ? message.ticket.trim() : ""; + const wsUrl = embeddedSocketUrl(message.wsUrl || "/api/vscodex/ws"); + if (!ticket || !wsUrl) { + appendOutput(t("云端连接配置无效"), "error"); + embedBridge.reportState("error", { code: "invalid_connection_config", message: "ticket and wsUrl are required" }); + requestEmbedTicket("invalid"); + return; + } + state.embedStopped = false; + state.embedTicketRequested = false; + state.embedTicket = ticket; + state.embedWsUrl = wsUrl; + state.embedDeviceId = typeof message.deviceId === "string" ? message.deviceId : state.embedDeviceId; + setAuthRequired(true); + document.body.classList.add("embed-aether"); + setConversationStatus(t("正在连接云端会话"), "active"); + if (state.ws && state.ws.readyState <= WebSocket.OPEN) { + const socket = state.ws; + state.ws = null; + socket.close(1000, "connection replaced"); + } + connect(); + } + + function connect() { + if (state.ws && state.ws.readyState <= WebSocket.OPEN) return; + if (embeddedInAether) { + if (state.embedStopped) return; + if (!state.embedTicket || !state.embedWsUrl) { requestEmbedTicket("missing"); return; } + state.token = state.embedTicket; + } else state.token = $("tokenInput").value.trim(); + if (!state.token && state.authRequired === true) { appendOutput("当前 relay 需要 token", "error"); return; } + setConnection("pending", "连接中"); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + let socket; + try { + socket = new WebSocket(embeddedInAether ? state.embedWsUrl : `${protocol}//${location.host}/ws`); + } catch (error) { + if (embeddedInAether) { + state.embedTicket = ""; + requestEmbedTicket("socket-error"); + } + appendOutput(error?.message || "WebSocket 未连接", "error"); + return; + } + const connectionTicket = embeddedInAether ? state.embedTicket : ""; + if (embeddedInAether) { + state.embedTicket = ""; + state.token = ""; + } + state.ws = socket; + socket.addEventListener("open", () => { + if (state.ws !== socket) return; + // Send a hello even when local auth is disabled so the relay can assign + // the browser role without requiring a dummy password. + socket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 })); + // A loopback relay authenticates on hello. Do not send a stale token as + // a second frame after that handshake, because it is already complete. + const token = embeddedInAether ? connectionTicket : state.token; + if (token && state.authRequired !== false) socket.send(JSON.stringify({ type: "auth", token })); + }); + socket.addEventListener("message", (event) => { + if (state.ws !== socket) return; + try { handleMessage(JSON.parse(event.data)); } catch { appendOutput("收到无法解析的 relay 消息", "error"); } + }); + socket.addEventListener("close", (event) => { + if (state.ws !== socket) return; + setConnection("offline", event.code === 1008 ? "认证失败,准备重连" : "准备重连"); + state.appReady = false; + state.turnId = ""; + state.sessionListLoading = false; + state.sessionListCommandId = ""; + state.newSessionCommandId = ""; + clearControlModeRequest(); + state.sessions = []; + state.sessionFocusedId = ""; + // A transport reconnect may resume the same owner hand-off. Preserve + // its previous/target identities so the next control placeholder cannot + // be mistaken for an authoritative empty target transcript. + if (!state.sessionSwitching) state.sessionSelectedThreadId = ""; + state.sessionListError = "等待 relay 连接"; + updateIds(); + state.ws = null; + renderSessionPicker(); + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + if (embeddedInAether) { + if (!state.embedStopped) requestEmbedTicket(event.code === 1008 ? "ticket-rejected" : "disconnected"); + } else state.reconnectTimer = setTimeout(connect, 3000); + }); + socket.addEventListener("error", () => { + // The close handler owns retrying. Keep transient socket errors in the + // connection indicator instead of adding noisy messages to the turn. + if (state.ws === socket) setConnection("offline", "重连中"); + }); + } + + function closePopovers() { + for (const id of ["panelMenu", "detailsPopover", "sessionPicker", "composerPlusMenu"]) { + const element = $(id); + if (element) element.hidden = true; + } + state.sessionPickerOpen = false; + state.sessionSearch = ""; + state.sessionFocusedId = ""; + $("sessionPickerButton")?.setAttribute("aria-expanded", "false"); + const sessionSearchInput = $("sessionSearchInput"); + if (sessionSearchInput) { + sessionSearchInput.value = ""; + sessionSearchInput.setAttribute("aria-expanded", "false"); + sessionSearchInput.setAttribute("aria-activedescendant", ""); + } + $("sessionSearchClear")?.setAttribute("hidden", ""); + setModelMenu(false); + setPermissionMenu(false); + setUsageMenu(false); + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + const confirm = $("permissionConfirm"); + if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } + } + + function requestRefresh() { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + try { sendFrame({ type: "subscribe", fromSeq: state.lastSeq }); } catch { connect(); } + } else connect(); + } + + document.querySelectorAll("[data-panel-action]").forEach((button) => { + button.addEventListener("click", () => { + const action = button.dataset.panelAction; + if (action === "back" || action === "history") { + setSessionPicker(!state.sessionPickerOpen); + return; + } + if (action === "new-session") { + requestNewSession(); + return; + } + if (action === "expand") { + document.body.classList.toggle("panel-expanded"); + return; + } + if (action === "close") { + document.body.classList.add("panel-hidden"); + const restore = $("restorePanel"); + if (restore) restore.hidden = false; + closePopovers(); + return; + } + if (action === "refresh") { closePopovers(); requestRefresh(); return; } + if (action === "menu") { + const menu = $("panelMenu"); + const details = $("detailsPopover"); + if (details) details.hidden = true; + if (menu) menu.hidden = !menu.hidden; + return; + } + if (action === "settings") { + const details = $("detailsPopover"); + const menu = $("panelMenu"); + if (menu) menu.hidden = true; + if (details) details.hidden = !details.hidden; + updateIds(); + } + }); + }); + $("sessionPickerButton")?.addEventListener("click", () => { + setSessionPicker(!state.sessionPickerOpen); + }); + $("controlModeSwitch")?.querySelectorAll("[data-control-mode]").forEach((button) => { + button.addEventListener("click", () => requestControlMode(button.dataset.controlMode)); + }); + $("sessionPickerRefresh")?.addEventListener("click", (event) => { + event.stopPropagation(); + requestSessionList(); + }); + $("sessionSearchInput")?.addEventListener("input", (event) => { + const input = event.currentTarget; + if (!(input instanceof HTMLInputElement)) return; + state.sessionSearch = input.value; + state.sessionFocusedId = ""; + renderSessionPicker(); + }); + $("sessionSearchInput")?.addEventListener("keydown", handleSessionPickerKeydown); + $("sessionList")?.addEventListener("keydown", handleSessionPickerKeydown); + $("sessionSearchClear")?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + state.sessionSearch = ""; + state.sessionFocusedId = ""; + renderSessionPicker(); + $("sessionSearchInput")?.focus(); + }); + $("restorePanel")?.addEventListener("click", () => { + document.body.classList.remove("panel-hidden"); + $("restorePanel").hidden = true; + }); + $("panelMenu")?.querySelectorAll("[data-menu-action]").forEach((button) => { + button.addEventListener("click", (event) => { + if (button.dataset.menuAction === "sessions") { + // The document-level outside-click handler runs in the same bubble + // phase. Keep the picker open when it is launched from this menu. + event.stopPropagation(); + closePopovers(); + setSessionPicker(true); + return; + } else if (button.dataset.menuAction === "clear") { + renderEmptyOutput(); + state.outputSynced = false; + } else if (button.dataset.menuAction === "refresh") requestRefresh(); + else if (button.dataset.menuAction === "expand") document.body.classList.toggle("panel-expanded"); + else if (button.dataset.menuAction === "close") { + document.body.classList.add("panel-hidden"); + const restore = $("restorePanel"); + if (restore) restore.hidden = false; + } + closePopovers(); + }); + }); + $("detailsPopover")?.querySelectorAll("[data-settings-action]").forEach((button) => { + button.addEventListener("click", (event) => { + event.stopPropagation(); + const action = button.dataset.settingsAction; + closePopovers(); + if (action === "model") { + setModelMenu(true); + $("modelPickerButton")?.focus(); + } else if (action === "permission") { + setPermissionMenu(true); + $("permissionChip")?.focus(); + } + }); + }); + document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (!target.closest(".model-picker")) setModelMenu(false); + if (!target.closest(".permission-menu, #permissionChip")) setPermissionMenu(false); + if (!target.closest(".usage-menu, #usageButton")) setUsageMenu(false); + if (!target.closest(".composer-plus-menu, #composerPlusButton")) { + const menu = $("composerPlusMenu"); + if (menu) menu.hidden = true; + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + } + if (!target.closest("[data-panel-action], .panel-popover, .composer-popover, .composer-icon-button, .permission-chip, .usage-button, #sessionPickerButton")) closePopovers(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.sessionPickerOpen) { + event.preventDefault(); + closePopovers(); + $("sessionPickerButton")?.focus(); + } + }); + + $("tokenInput").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + connect(); + } + }); + $("tokenInput").addEventListener("change", connect); + $("localeSelect")?.addEventListener("change", (event) => { + if (!embeddedInAether) i18n?.setLocale?.(event.target.value, { persist: true }); + }); + window.addEventListener("aether-vscodex:locale", () => { + for (const activity of state.activities.values()) { + renderActivityText(activity); + refreshActivity(activity); + } + for (const [turnId, divider] of state.turnDividers) { + const label = divider.querySelector(".turn-divider-label"); + if (!label) continue; + const duration = finiteNumber(divider.dataset.durationMs); + label.textContent = turnDividerLabel(divider.dataset.status || "completed", duration); + } + setAttachMode(state.attachMode); + setAuthRequired(state.authRequired); + renderSessionPicker(); + renderModelPicker(); + renderPermissionMenu(); + renderUsage(); + renderSubagents(); + updateIds(); + renderRequests(); + for (const checkbox of document.querySelectorAll(".task-list-item input[type=checkbox]")) { + checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成")); + } + }); + $("clearOutputButton").addEventListener("click", () => { + renderEmptyOutput(); + state.outputSynced = false; + }); + $("startThreadButton").addEventListener("click", () => { + if (state.attachMode) return; + const params = { cwd: $("cwdInput").value.trim() || undefined, sandbox: $("sandboxInput").value, approvalPolicy: $("approvalInput").value }; + if ($("modelInput").value.trim()) params.model = $("modelInput").value.trim(); + command("thread/start", params); + }); + $("startTurnButton").addEventListener("click", () => { + const text = composerText(); + if (!text || !state.threadId) return; + command("turn/start", { + threadId: state.threadId, + input: [{ type: "text", text, text_elements: [] }], + ...(state.currentModel ? { model: state.currentModel } : {}), + ...currentEffortParams(), + }); + clearComposer(); + }); + $("steerButton").addEventListener("click", () => { + const text = composerText(); + if (!text || !state.threadId || !state.turnId) return; + command("turn/steer", { + threadId: state.threadId, + expectedTurnId: state.turnId, + input: [{ type: "text", text, text_elements: [] }], + ...(state.currentModel ? { model: state.currentModel } : {}), + ...currentEffortParams(), + }); + clearComposer(); + }); + $("interruptButton").addEventListener("click", () => { + if (state.threadId && state.turnId) command("turn/interrupt", { threadId: state.threadId, turnId: state.turnId }); + }); + $("messageInput").addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; + event.preventDefault(); + const button = state.turnId ? $("steerButton") : $("startTurnButton"); + if (button && !button.disabled) button.click(); + }); + $("messageInput").addEventListener("input", () => { + resizeComposer(); + updateIds(); + }); + $("messageInput").addEventListener("paste", (event) => { + event.preventDefault(); + const text = event.clipboardData?.getData("text/plain") || ""; + if (!text) return; + const editor = $("messageInput"); + const selection = window.getSelection(); + if (editor && selection && selection.rangeCount) { + const range = selection.getRangeAt(0); + if (editor.contains(range.commonAncestorContainer)) { + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else editor.append(document.createTextNode(text)); + } else editor?.append(document.createTextNode(text)); + resizeComposer(); + updateIds(); + }); + $("modelPickerButton")?.addEventListener("click", (event) => { + event.stopPropagation(); + const menu = $("modelMenu"); + setPermissionMenu(false); + setUsageMenu(false); + const plus = $("composerPlusMenu"); + if (plus) plus.hidden = true; + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + setModelMenu(Boolean(menu?.hidden)); + }); + $("modelAdvancedToggle")?.addEventListener("click", (event) => { + event.stopPropagation(); + state.modelAdvancedOpen = !state.modelAdvancedOpen; + renderModelPicker(); + if (state.modelAdvancedOpen) $("modelAdvancedBack")?.focus(); + else $("modelPowerSlider")?.focus(); + }); + $("modelAdvancedBack")?.addEventListener("click", (event) => { + event.stopPropagation(); + state.modelAdvancedOpen = false; + renderModelPicker(); + $("modelPowerSlider")?.focus(); + }); + $("modelPowerSlider")?.addEventListener("input", (event) => { + selectPowerIndex(event.currentTarget.value); + }); + $("composerPlusButton")?.addEventListener("click", (event) => { + event.stopPropagation(); + const menu = $("composerPlusMenu"); + if (!menu) return; + const next = menu.hidden; + menu.hidden = !next; + $("composerPlusButton").setAttribute("aria-expanded", String(next)); + if (next) { + setPermissionMenu(false); + setUsageMenu(false); + setModelMenu(false); + } + }); + $("permissionChip")?.addEventListener("click", (event) => { + event.stopPropagation(); + const menu = $("permissionMenu"); + setPermissionMenu(Boolean(menu?.hidden)); + if (!menu?.hidden) { + const plus = $("composerPlusMenu"); + if (plus) plus.hidden = true; + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + setUsageMenu(false); + setModelMenu(false); + } + }); + $("usageButton")?.addEventListener("click", (event) => { + event.stopPropagation(); + const menu = $("usageMenu"); + setPermissionMenu(false); + setModelMenu(false); + const plus = $("composerPlusMenu"); + if (plus) plus.hidden = true; + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + setUsageMenu(Boolean(menu?.hidden)); + if (!menu?.hidden) { + setPermissionMenu(false); + setModelMenu(false); + } + }); + const insertComposerText = (text) => { + const editor = $("messageInput"); + if (!editor || !text) return; + editor.focus(); + const selection = window.getSelection(); + if (selection && selection.rangeCount) { + const range = selection.getRangeAt(0); + if (editor.contains(range.commonAncestorContainer)) { + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else editor.append(document.createTextNode(text)); + } else editor.append(document.createTextNode(text)); + resizeComposer(); + updateIds(); + }; + $("composerPlusMenu")?.querySelectorAll("[data-composer-action]").forEach((button) => { + button.addEventListener("click", () => { + const action = button.dataset.composerAction; + if (action === "attach") { + const input = $("attachmentInput"); + if (input) { input.accept = ".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h"; input.click(); } + } else if (action === "photo") { + const input = $("attachmentInput"); + if (input) { input.accept = "image/*"; input.click(); } + } else if (action === "workspace") { + insertComposerText("\n\n@workspace "); + setConversationStatus("已添加工作区上下文", "ready"); + } else if (action === "web-search") { + insertComposerText("\n\n/web-search "); + setConversationStatus("已添加网页搜索", "ready"); + } + const menu = $("composerPlusMenu"); + if (menu) menu.hidden = true; + $("composerPlusButton")?.setAttribute("aria-expanded", "false"); + }); + }); + $("attachmentInput")?.addEventListener("change", async (event) => { + const files = [...(event.target?.files || [])]; + for (const file of files) { + try { + if (file.type.startsWith("image/")) { + insertComposerText(`\n\n[图片附件:${file.name}]\n`); + continue; + } + const text = await file.text(); + const clipped = text.length > 80_000 ? `${text.slice(0, 80_000)}\n${t("…(文件已截断)")}` : text; + const fence = "```"; + insertComposerText(`\n\n### ${file.name}\n\n${fence}\n${clipped}\n${fence}\n`); + } catch { + setConversationStatus(`无法读取 ${file.name}`, "warning"); + } + } + event.target.value = ""; + }); + $("permissionMenu")?.querySelectorAll("[data-permission-mode], [data-sandbox], [data-approval]").forEach((button) => { + button.addEventListener("click", () => { + if (button.dataset.permissionMode) selectPermissionMode(button.dataset.permissionMode); + else if (button.dataset.sandbox) selectPermissionSetting("sandbox", button.dataset.sandbox); + else if (button.dataset.approval) selectPermissionSetting("approval", button.dataset.approval); + }); + }); + $("permissionConfirmCancel")?.addEventListener("click", () => { + const confirm = $("permissionConfirm"); + if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } + }); + $("permissionConfirmAccept")?.addEventListener("click", () => { + const confirm = $("permissionConfirm"); + const mode = confirm?.dataset.pendingMode || "full"; + if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } + applyPermissionMode(mode); + }); + $("subagentsToggle")?.addEventListener("click", () => { + state.subagentsCollapsed = !state.subagentsCollapsed; + renderSubagents(); + }); + $("output").addEventListener("scroll", () => updateScrollToBottom($("output")), { passive: true }); + $("scrollToBottom")?.addEventListener("click", () => { + const output = $("output"); + if (output) output.scrollTo({ top: output.scrollHeight, behavior: "smooth" }); + }); + if (typeof ResizeObserver === "function") { + const output = $("output"); + const observedContent = new Set(); + const observeTranscriptContent = () => { + const children = new Set(output ? [...output.children] : []); + for (const child of observedContent) { + if (children.has(child)) continue; + layoutObserver.unobserve(child); + observedContent.delete(child); + } + for (const child of children) { + if (observedContent.has(child)) continue; + observedContent.add(child); + layoutObserver.observe(child); + } + }; + const layoutObserver = new ResizeObserver(() => { + const distance = state.outputDistanceFromBottom; + const following = distance <= 24; + const anchorLocked = motionClock() < (state.timelineAnchorLockUntil || 0); + updateScrollPadding(); + // Preserve the reader's distance from the bottom when a streamed item or + // the composer changes height. The official thread layout uses the same + // bottom-relative anchor instead of allowing content to jump. + if (output && !anchorLocked) { + if (following) scrollOutput(output, true); + else output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - distance); + updateScrollToBottom(output); + } else if (output) updateScrollToBottom(output); + observeTranscriptContent(); + }); + layoutObserver.observe($("messageForm")); + layoutObserver.observe($("inlineRequests")); + if (output) layoutObserver.observe(output); + observeTranscriptContent(); + if (typeof MutationObserver === "function" && output) { + const childObserver = new MutationObserver(observeTranscriptContent); + childObserver.observe(output, { childList: true }); + } + } + window.addEventListener("resize", updateScrollPadding, { passive: true }); + $("cwdInput").value = location.pathname === "/" ? "" : ""; + renderPermissionMenu(); + renderUsage(); + renderSessionPicker(); + resizeComposer(); + updateScrollPadding(); + updateScrollToBottom($("output")); + updateIds(); + + if (embeddedInAether) { + document.body.classList.add("embed-aether"); + setAuthRequired(true); + setConversationStatus(t("正在等待云端连接"), "active"); + embedBridge.on("connect", applyEmbedConnection); + embedBridge.on("context", (message) => { + if (message.locale) i18n?.setLocale?.(message.locale, { persist: false }); + }); + embedBridge.on("disconnect", () => disconnectEmbedded("parent disconnect")); + embedBridge.on("error", (message) => { + const detail = typeof message.message === "string" && message.message ? message.message : t("云端连接已断开"); + appendOutput(detail, "error"); + setConversationStatus(detail, "warning"); + }); + } else { + // /api/health is intentionally public and only reports capability metadata. + // Probe it first so a local relay can connect automatically without a token; + // Authenticated deployments wait until a token is entered in the field. + fetch("./api/health", { cache: "no-store" }).then(async (response) => { + let health; + try { health = await response.json(); } catch { return; } + setAuthRequired(health.authRequired); + if (health.authRequired === false || (health.authRequired === true && $("tokenInput").value.trim())) connect(); + }).catch(() => undefined); + } +})(); diff --git a/aether-vscodex/public/embed-bridge.js b/aether-vscodex/public/embed-bridge.js new file mode 100644 index 000000000..0d0a7416b --- /dev/null +++ b/aether-vscodex/public/embed-bridge.js @@ -0,0 +1,112 @@ +(function (root, factory) { + "use strict"; + + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + if (!root || !root.document) return; + + const bridge = api.createAetherEmbedBridge(root); + root.AetherVscodexEmbed = bridge; + if (bridge.active) bridge.start(); +})(typeof window === "object" ? window : undefined, function () { + "use strict"; + + const VERSION = 1; + const PREFIX = "aether-vscodex/"; + const INBOUND_TYPES = new Set(["connect", "context", "disconnect", "error"]); + + function isAetherEmbed(locationLike) { + try { + return new URLSearchParams(locationLike?.search || "").get("embed") === "aether"; + } catch { + return false; + } + } + + function normalizeTheme(value) { + const theme = String(value || "").trim().toLowerCase(); + return theme === "dark" || theme === "light" ? theme : "system"; + } + + function createAetherEmbedBridge(windowLike) { + const active = isAetherEmbed(windowLike.location); + const listeners = new Map(); + const pending = new Map(); + let started = false; + + const emit = (name, payload) => { + for (const listener of listeners.get(name) || []) listener(payload); + }; + + const post = (type, payload = {}) => { + if (!active || windowLike.parent === windowLike) return false; + windowLike.parent.postMessage({ v: VERSION, type: `${PREFIX}${type}`, ...payload }, windowLike.location.origin); + return true; + }; + + const applyContext = (payload) => { + if (payload.locale && windowLike.VscodexI18n?.setLocale) { + windowLike.VscodexI18n.setLocale(payload.locale, { persist: false }); + } + const theme = normalizeTheme(payload.theme); + const documentElement = windowLike.document?.documentElement; + if (documentElement) { + if (theme === "system") delete documentElement.dataset.theme; + else documentElement.dataset.theme = theme; + documentElement.style.colorScheme = theme === "system" ? "" : theme; + } + }; + + const handleMessage = (event) => { + if (!active || event.origin !== windowLike.location.origin || event.source !== windowLike.parent) return; + const message = event.data; + if (!message || typeof message !== "object" || message.v !== VERSION || typeof message.type !== "string") return; + if (!message.type.startsWith(PREFIX)) return; + const name = message.type.slice(PREFIX.length); + if (!INBOUND_TYPES.has(name)) return; + if (name === "connect" || name === "context") applyContext(message); + if (!(listeners.get(name)?.size)) pending.set(name, message); + emit(name, message); + }; + + return { + active, + version: VERSION, + start() { + if (!active || started) return; + started = true; + windowLike.document.body?.classList.add("embed-aether"); + windowLike.addEventListener("message", handleMessage); + post("ready"); + }, + stop() { + if (!started) return; + started = false; + windowLike.removeEventListener("message", handleMessage); + listeners.clear(); + pending.clear(); + }, + on(name, listener) { + if (!INBOUND_TYPES.has(name) || typeof listener !== "function") return () => undefined; + if (!listeners.has(name)) listeners.set(name, new Set()); + listeners.get(name).add(listener); + if (pending.has(name)) { + const message = pending.get(name); + pending.delete(name); + listener(message); + } + return () => listeners.get(name)?.delete(listener); + }, + post, + requestTicket(payload = {}) { + return post("request-ticket", payload); + }, + reportState(state, payload = {}) { + return post("state", { state, ...payload }); + }, + _handleMessage: handleMessage, + }; + } + + return { createAetherEmbedBridge, isAetherEmbed, normalizeTheme }; +}); diff --git a/aether-vscodex/public/i18n.js b/aether-vscodex/public/i18n.js new file mode 100644 index 000000000..8c16b8f3b --- /dev/null +++ b/aether-vscodex/public/i18n.js @@ -0,0 +1,541 @@ +(function (root, factory) { + "use strict"; + + const api = factory(root); + if (typeof module === "object" && module.exports) module.exports = api; + if (root?.document) root.VscodexI18n = api; +})(typeof window === "object" ? window : undefined, function (root) { + "use strict"; + + const STORAGE_KEY = "aether-vscodex.locale"; + const SUPPORTED = new Set(["zh-CN", "en-US"]); + const EN = Object.freeze({ + "本地模式": "Local mode", + "独立模式": "Standalone mode", + "云端模式": "Cloud mode", + "控制模式": "Control mode", + "同步": "Sync", + "异步": "Async", + "同步模式跟随 VS Code 当前会话": "Sync mode follows the current VS Code conversation", + "异步模式可独立管理会话": "Async mode manages conversations independently", + "正在切换控制模式": "Switching control mode", + "控制模式已切换": "Control mode switched", + "控制模式切换失败": "Unable to switch control mode", + "当前任务或请求完成后才能切换控制模式": "The control mode can be changed after the current task or request finishes", + "同步模式下会话管理由 VS Code 控制": "VS Code controls conversation navigation in sync mode", + "当前模式不支持修改会话设置": "The current mode does not support changing conversation settings", + "本机连接(无需 token)": "Local connection (no token required)", + "本机模式无需填写;认证模式再填写": "No token is needed locally; enter one only for authenticated mode", + "访问 token(认证模式)": "Access token (authenticated mode)", + "粘贴 relay 启动时打印的 token": "Paste the token printed when the relay started", + "本地连接无需 token": "No token is needed for a local connection", + "编辑外部文件和联网时始终询问": "Always ask before editing external files or using the network", + "不限制联网或文件访问": "Allow unrestricted network and file access", + "查看请求数据": "View request data", + "查看上下文用量": "View context usage", + "创建新会话": "New conversation", + "打开会话历史": "Open conversation history", + "待处理的 Codex 请求": "Pending Codex requests", + "当前会话": "Current conversation", + "当前模型": "Current model", + "切换模型": "Change model", + "等待 VS Code 主机": "Waiting for VS Code host", + "等待连接": "Waiting for connection", + "对话内容": "Conversation", + "发送 JSON": "Send JSON", + "发送后续指令": "Send follow-up", + "发送消息": "Send message", + "返回会话列表": "Back to conversations", + "返回模型强度": "Back to model effort", + "高级": "Advanced", + "简洁": "Simple", + "更多操作": "More actions", + "更高效": "More efficient", + "更智能": "More capable", + "工作目录": "Working directory", + "工作区": "Workspace", + "工作区写入": "Workspace write", + "回到最新消息": "Jump to latest message", + "正在工作,回到最新消息": "Working, jump to latest message", + "会话历史": "Conversation history", + "会话设置": "Conversation settings", + "仅本次 turn": "This turn only", + "仅查看文件,不修改工作区": "View files without changing the workspace", + "仅对可能不安全的操作询问": "Ask only for potentially unsafe actions", + "拒绝": "Deny", + "可用会话": "Available conversations", + "连接设置": "Connection settings", + "留空使用默认模型": "Leave empty to use the default model", + "模式": "Mode", + "模型": "Model", + "模型与推理强度": "Model and reasoning effort", + "默认": "Default", + "启动新 thread": "Start new thread", + "强度": "Effort", + "切换模型与推理强度": "Change model and reasoning effort", + "清除搜索": "Clear search", + "清空当前输出": "Clear current output", + "清空对话": "Clear conversation", + "取消": "Cancel", + "权限设置": "Permission settings", + "确认": "Confirm", + "确认完全访问": "Confirm full access", + "沙箱": "Sandbox", + "上下文用量": "Context usage", + "设置": "Settings", + "审批策略": "Approval policy", + "使用 config.toml 中的权限": "Use permissions from config.toml", + "使用左右方向键调整强度": "Use the left and right arrow keys to adjust effort", + "授权范围": "Authorization scope", + "授权与输入": "Approvals and input", + "刷新会话列表": "Refresh conversations", + "搜索最近会话": "Search recent conversations", + "提交后续变更要求": "Ask for follow-up changes", + "添加工作区上下文": "Add workspace context", + "添加文件": "Add files", + "添加文件及更多内容": "Add files and more", + "添加照片": "Add photos", + "推理强度": "Reasoning effort", + "完全访问": "Full access", + "完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。": "Full access lets Codex run commands, use the internet, and edit files outside the workspace.", + "网页搜索": "Web search", + "未认证": "Unauthenticated", + "显示 Codex": "Show Codex", + "修改权限": "Change permissions", + "需要时询问": "Ask when needed", + "已附着当前会话": "Attached to current conversation", + "隐藏面板": "Hide panel", + "由 Codex 审批": "Let Codex decide", + "允许": "Allow", + "允许一次": "Allow once", + "暂无待处理请求": "No pending requests", + "暂无用量数据": "No usage data", + "展开面板": "Expand panel", + "正在连接": "Connecting", + "只读": "Read only", + "中断当前 turn": "Interrupt current turn", + "重新同步": "Resync", + "子代理": "Subagent", + "自定义": "Custom", + "最近会话": "Recent conversations", + "Codex 消息": "Codex messages", + "JSON 响应": "JSON response", + "语言": "Language", + "中文": "Chinese", + "跟随浏览器": "Use browser language", + "正在连接云端会话": "Connecting to cloud conversation", + "正在等待云端连接": "Waiting for cloud connection", + "云端连接已断开": "Cloud connection disconnected", + "云端连接配置无效": "Invalid cloud connection configuration", + "云端连接地址必须与当前页面同源": "The cloud connection URL must be same-origin", + "正在获取新的连接凭证": "Requesting new connection credentials", + "父页面已断开连接": "Disconnected by the parent page", + "当前 relay 需要 token": "This relay requires a token", + "WebSocket 未连接": "WebSocket is not connected", + "连接中": "Connecting", + "同步中": "Syncing", + "已连接": "Connected", + "认证失败,准备重连": "Authentication failed; preparing to reconnect", + "准备重连": "Preparing to reconnect", + "重连中": "Reconnecting", + "收到无法解析的 relay 消息": "Received an unreadable relay message", + "等待 relay 连接": "Waiting for relay connection", + "等待 VS Code 主机连接": "Waiting for VS Code host", + "VS Code 主机未连接": "VS Code host is disconnected", + "等待 VS Code 伴随扩展连接": "Waiting for the VS Code companion extension", + "VS Code 伴随扩展未连接": "VS Code companion extension is disconnected", + "等待在 VS Code 中打开 Codex 会话": "Open a Codex conversation in VS Code to continue", + "会话已关闭": "Conversation closed", + "VS Code 会话已关闭": "VS Code conversation closed", + "会话操作失败": "Conversation operation failed", + "当前任务结束或请求处理后才能切换": "You can switch after the current task or request finishes", + "目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它": "The target conversation did not return a VS Code snapshot. Open it in the official Codex panel first.", + "当前 relay 版本不支持此会话操作,请重启 relay": "This relay version does not support the conversation action. Restart the relay.", + "正在读取会话…": "Loading conversations...", + "正在切换会话…": "Switching conversation...", + "无法读取会话": "Unable to load conversations", + "没有匹配的会话": "No matching conversations", + "没有可附加的会话": "No attachable conversations", + "没有可控制的会话": "No controllable conversations", + "正在切换": "Switching", + "未打开": "Not open", + "当前": "Current", + "可切换": "Available", + "会话": "Conversation", + "当前角色不能创建会话": "Your current role cannot create conversations", + "正在创建新会话": "Creating a new conversation", + "无法创建新会话": "Unable to create a new conversation", + "当前任务仍在运行或等待授权,暂不能切换": "The current task is running or awaiting approval, so it cannot be switched yet", + "会话切换失败": "Conversation switch failed", + "正在确认会话": "Confirming conversation", + "正在加载会话": "Loading conversation", + "会话已切换": "Conversation switched", + "正在更新模型设置": "Updating model settings", + "模型设置已更新": "Model settings updated", + "无法更新模型设置": "Unable to update model settings", + "已停止": "Stopped", + "成功": "Succeeded", + "无输出": "No output", + "等待输出…": "Waiting for output...", + "执行步骤": "Action", + "正在读取文件": "Reading files", + "读取完成": "Finished reading", + "已读取文件运行了命令": "Read files and ran a command", + "已读取文件": "Read files", + "编辑了文件": "Edited files", + "已完成计划": "Completed plan", + "读取文件失败": "Failed to read files", + "已停止读取文件": "Stopped reading files", + "读取文件": "Read files", + "已运行命令": "Ran command", + "正在运行命令": "Running command", + "正在思考": "Thinking", + "正在制定计划": "Creating a plan", + "正在编辑文件": "Editing files", + "正在处理": "Working", + "已完成思考": "Finished thinking", + "计划完成": "Plan completed", + "文件编辑完成": "Finished editing files", + "工作说明": "Progress update", + "计划": "Plan", + "文件变更": "File changes", + "等待授权": "Waiting for approval", + "正在生成": "Generating", + "已中断": "Interrupted", + "失败": "Failed", + "已完成": "Completed", + "正在工作": "Working", + "正在等待你的回答": "Waiting for your answer", + "正在搜索网页": "Searching the web", + "执行失败": "Action failed", + "处理中": "Working", + "思考": "Reasoning", + "编辑文件": "Edit files", + "思考中": "Thinking", + "编辑中": "Editing", + "进行中": "In progress", + "异常": "Error", + "未读": "Unread", + "本地会话": "Local conversation", + "默认拒绝,请明确允许": "Denied by default; allow explicitly", + "需要远程确认或输入": "Remote confirmation or input is required", + "允许运行命令?": "Allow this command?", + "允许修改文件?": "Allow file changes?", + "需要扩大权限": "Additional permissions required", + "Codex 需要你的回答": "Codex needs your answer", + "需要外部服务确认": "External service confirmation required", + "Codex 请求确认": "Codex requests confirmation", + "高风险": "High risk", + "低风险": "Low risk", + "需确认": "Confirmation required", + "请输入": "Enter a response", + "提交回答": "Submit answer", + "发送自定义响应": "Send custom response", + "自定义响应不是有效 JSON": "The custom response is not valid JSON", + "响应不是有效 JSON": "The response is not valid JSON", + "远程参与者拒绝": "Denied by remote participant", + "状态": "Status", + "命令": "Command", + "详情": "Details", + "复制消息": "Copy message", + "复制命令": "Copy command", + "复制输出": "Copy output", + "未知": "Unknown", + "未知错误": "Unknown error", + "已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。": "Attached to the current VS Code Codex conversation. Messages, output, and approvals all return to that conversation.", + "当前为独立 app-server 模式。": "Currently using standalone app-server mode.", + "已附着现有会话": "Attached to existing conversation", + "通用 Codex 模型": "General-purpose Codex model", + "平衡速度与推理": "Balanced speed and reasoning", + "可用模型": "Available model", + "极低": "Minimal", + "轻度": "Low", + "标准": "Medium", + "深度": "High", + "极高": "Extra high", + "最大": "Maximum", + "此模型使用默认推理强度": "This model uses its default reasoning effort", + "返回简洁模型选择": "Return to simple model selection", + "显示高级模型选项": "Show advanced model options", + "自定义权限由 config.toml 管理": "Custom permissions are managed by config.toml", + "正在等待指示": "Waiting for instructions", + "正在工作": "Working", + "命令输出": "Command output", + "工具输出": "Tool output", + "发送 Steer": "Send steer", + "会话切换失败,已恢复原会话": "Conversation switch failed; restored the previous conversation", + "(空消息)": "(empty message)", + "今天": "Today", + "昨天": "Yesterday", + "未完成": "Not completed", + "步骤": "Step", + "查看图像": "View image", + "等待输入": "Waiting for input", + "读取文件运行命令失败": "Failed to read files and run a command", + "发送输入": "Send input", + "工具": "Tool", + "工具失败": "Tool failed", + "正在搜索": "Searching", + "你停止了工作": "You stopped working", + "关闭子代理": "Close subagent", + "恢复子代理": "Resume subagent", + "启动子代理": "Start subagent", + "搜索": "Search", + "文件": "File", + "新会话已在 VS Code 中打开": "The new conversation opened in VS Code", + "事件窗口已过期,请以当前快照为准": "The event window expired; the current snapshot is authoritative", + "执行状态未知,请等待主机恢复": "Execution status is unknown; wait for the host to recover", + "文件已截断": "File truncated", + "已拒绝": "Denied", + "已开始工作": "Started working", + "已添加工作区上下文": "Added workspace context", + "已添加网页搜索": "Added web search", + "运行命令": "Run command", + "整理上下文": "Compacting context", + "正在切换会话": "Switching conversation", + "MCP 工具": "MCP tool", + " · @ 可标记代理": " · @ to mention agents", + }); + + const EN_PATTERNS = Object.freeze([ + [/^用时 1分钟(\d+)秒$/, "Worked for 1m{1}s"], + [/^用时 (\d+)分(\d+)秒$/, "Worked for {1}m{2}s"], + [/^用时 1分钟$/, "Worked for 1m"], + [/^用时 (\d+)分$/, "Worked for {1}m"], + [/^用时 (\d+)秒$/, "Worked for {1}s"], + [/^用时 (\d+)毫秒$/, "Worked for {1}ms"], + [/^用时\s+(.+)$/, "Worked for {1}"], + [/^已思考 1分钟(\d+)秒$/, "Thought for 1m{1}s"], + [/^已思考 (\d+)分(\d+)秒$/, "Thought for {1}m{2}s"], + [/^已思考 (\d+)秒$/, "Thought for {1}s"], + [/^已思考\s+(.+)$/, "Thought for {1}"], + [/^退出码\s+(.+)$/, "Exit code {1}"], + [/^正在读取\s+(.+)$/, "Reading {1}", [1]], + [/^已读取\s+(.+)$/, "Read {1}", [1]], + [/^读取失败\s*·\s*(.+)$/, "Failed to read {1}", [1]], + [/^已停止读取\s+(.+)$/, "Stopped reading {1}", [1]], + [/^读取\s+(.+)$/, "Read {1}", [1]], + [/^已读取这些内容\s*·\s*(\d+)\s*个文件(.*)$/, "Read these items · {1} files{2}"], + [/^已在\s+(.+)\s+内运行\s+(.+)$/, "Ran {2} in {1}", [2]], + [/^命令运行失败\s*·\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Command failed · {1} · {2}", [1]], + [/^命令运行失败\s*·\s*(.+)$/, "Command failed · {1}", [1]], + // Renderer-owned disclosure labels. Keep the captured command/model text + // intact; only the surrounding UI words are localized. + [/^命令\s*·\s*(.+)$/, "Command · {1}", [1]], + [/^已工具\s*·\s*(.+)$/, "Tool completed · {1}"], + [/^当前模型\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大),切换模型$/, "Current model: {1} {2}. Change model", [1]], + [/^已停止\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Stopped {1} · {2}", [1]], + [/^已运行\s*(.+)$/, "Ran {1}", [1]], + [/^命令运行失败\s*(.*)$/, "Command failed{1}", [1]], + [/^命令:\s*(.+?)(执行状态未知,请等待主机恢复)$/, "Command: {1} (execution status unknown; wait for the host to recover)", [1]], + [/^命令:\s*(.+)$/, "Command: {1}", [1]], + [/^已停止\s*(.+)$/, "Stopped {1}", [1]], + [/^正在运行\s+(.+)$/, "Running {1}", [1]], + [/^(.+?)\s*·\s*失败$/, "{1} · Failed"], + [/^(.+?)\s*·\s*已中断$/, "{1} · Interrupted"], + [/^(.+)\s+失败$/, "{1} failed"], + [/^编辑了文件\s*·\s*(.+)$/, "Edited files · {1}"], + [/^已完成计划\s*·\s*(.+)$/, "Completed plan · {1}"], + [/^(\d+)\/(\d+)\s*个会话$/, "{1}/{2} conversations"], + [/^(\d+)\s*个会话$/, "{1} conversations"], + [/^会话\s+(.+)$/, "Conversation {1}", [1]], + [/^工作区\s*·\s*(.+)$/, "Workspace · {1}", [1]], + [/^昨天\s+(.+)$/, "Yesterday {1}", [1]], + [/^正在切换到「(.+)」…?$/, "Switching to “{1}”...", [1]], + [/^你在\s+(.+)\s+后停止了$/, "You stopped after {1}"], + [/^执行失败\s*·\s*(.+)$/, "Action failed · {1}"], + [/^新会话创建失败:(.+)$/, "Unable to create a new conversation: {1}", [1]], + [/^模型设置更新失败:(.+)$/, "Unable to update model settings: {1}", [1]], + [/^(.+)(执行状态未知,请等待主机恢复)$/, "{1} (execution status unknown; wait for the host to recover)", [1]], + [/^(.+) 完成$/, "{1} completed", [1]], + [/^请求 #(.+) 已提交$/, "Request #{1} submitted"], + [/^请求 #(.+) 已发送,等待 VS Code 主机确认$/, "Request #{1} sent; waiting for the VS Code host"], + [/^无法读取 (.+)$/, "Unable to read {1}", [1]], + [/^\[图片附件:(.+)\]$/, "[Image attachment: {1}]", [1]], + [/^(.+) 已开始工作$/, "{1} started working", [1]], + [/^(.+) 已完成$/, "{1} completed", [1]], + [/^(.+) 已中断$/, "{1} interrupted", [1]], + [/^…(文件已截断)$/, "... (file truncated)"], + [/^当前模型\s+(.+),切换模型$/, "Current model: {1}. Change model", [1]], + [/^切换模型(当前\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大))$/, "Change model (current: {1} {2})", [1]], + [/^切换模型(当前\s+(.+))$/, "Change model (current: {1})", [1]], + [/^修改权限,当前为(.+)$/, "Change permissions. Current: {1}"], + [/^修改权限(当前:(.+))$/, "Change permissions (current: {1})"], + [/^上下文已使用\s*(\d+)%(剩余\s*(\d+)%)$/, "Context used: {1}% ({2}% remaining)"], + [/^(\d+)%\s*已使用$/, "{1}% used"], + [/^剩余\s+(.+)\s+tokens$/, "{1} tokens remaining"], + [/^当前上下文\s+(.+)\s+tokens$/, "Current context: {1} tokens"], + [/^最近请求\s+(.+)\s+tokens$/, "Latest request: {1} tokens"], + [/^累计\s+(.+)\s+tokens$/, "Total: {1} tokens"], + [/^使用\s+(.+)$/, "Using {1}", [1]], + [/^已用时\s+(.+)$/, "Elapsed: {1}"], + [/^(\d+)\s*个后台代理(.*)$/, "{1} background agents{2}"], + [/^(\d+)毫秒$/, "{1}ms"], + [/^(\d+)秒$/, "{1}s"], + [/^1分钟(\d+)秒$/, "1m{1}s"], + [/^(\d+)分(\d+)秒$/, "{1}m{2}s"], + [/^1分钟(\d+秒)?$/, "1m{1}"], + [/^(\d+)分(\d+秒)?$/, "{1}m{2}"], + ]); + const ZH = Object.freeze(Object.fromEntries(Object.entries(EN).map(([source, translated]) => [translated, source]))); + + let currentLocale = "zh-CN"; + let observer = null; + const textSources = new WeakMap(); + const textRendered = new WeakMap(); + const attributeSources = new WeakMap(); + const attributeRendered = new WeakMap(); + + function normalizeLocale(value) { + const locale = String(value || "").trim().replace("_", "-").toLowerCase(); + return locale.startsWith("zh") ? "zh-CN" : "en-US"; + } + + function embeddedMode() { + if (root?.AetherVscodexEmbed?.active) return true; + try { return new URLSearchParams(root?.location?.search || "").get("embed") === "aether"; } + catch { return false; } + } + + function interpolate(template, values) { + return String(template).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? ""); + } + + function translate(value, locale = currentLocale, depth = 0) { + const source = String(value ?? ""); + if (!source) return source; + if (normalizeLocale(locale) === "zh-CN") return ZH[source] || source; + if (Object.prototype.hasOwnProperty.call(EN, source)) return EN[source]; + for (const [pattern, template, rawIndexes] of EN_PATTERNS) { + const match = source.match(pattern); + if (match) { + const translatedMatch = match.map((part, index) => index === 0 + ? part + : rawIndexes?.includes(index) ? part + : depth < 6 ? translate(part, locale, depth + 1) : (EN[part] || part)); + return interpolate(template, translatedMatch); + } + } + return source; + } + + function shouldSkipTextNode(node) { + const parent = node?.parentElement; + return Boolean(parent?.closest?.("code, pre, .message-body, .request-summary, .request-questions, .request-json, .request-command, .diff-output, .terminal-output, .session-option-title, .subagent-name, .subagent-summary-label")); + } + + function translateTextNode(node) { + if (!node || shouldSkipTextNode(node)) return; + const current = node.nodeValue; + const previousRendered = textRendered.get(node); + if (!textSources.has(node) || current !== previousRendered) textSources.set(node, current); + const source = textSources.get(node); + const leading = source.match(/^\s*/)?.[0] || ""; + const trailing = source.match(/\s*$/)?.[0] || ""; + const core = source.slice(leading.length, source.length - trailing.length); + if (!core) return; + const translated = translate(core); + const rendered = `${leading}${translated}${trailing}`; + textRendered.set(node, rendered); + if (rendered !== current) node.nodeValue = rendered; + } + + function translateAttributes(element) { + if (!element?.getAttribute || element.closest?.(".message-body, pre, code")) return; + let sources = attributeSources.get(element); + let renderedValues = attributeRendered.get(element); + if (!sources) { sources = new Map(); attributeSources.set(element, sources); } + if (!renderedValues) { renderedValues = new Map(); attributeRendered.set(element, renderedValues); } + for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) { + if (!element.hasAttribute(attribute)) continue; + const current = element.getAttribute(attribute); + if (!sources.has(attribute) || current !== renderedValues.get(attribute)) sources.set(attribute, current); + const source = sources.get(attribute); + const translated = translate(source); + renderedValues.set(attribute, translated); + if (translated !== current) element.setAttribute(attribute, translated); + } + } + + function translateTree(node) { + if (!root?.document || !node) return; + if (node.nodeType === 3) { + translateTextNode(node); + return; + } + if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return; + if (node.nodeType === 1) translateAttributes(node); + const walker = root.document.createTreeWalker(node, root.NodeFilter.SHOW_ELEMENT | root.NodeFilter.SHOW_TEXT); + for (let current = walker.nextNode(); current; current = walker.nextNode()) { + if (current.nodeType === 3) translateTextNode(current); + else translateAttributes(current); + } + } + + function applyDocument() { + if (!root?.document) return; + root.document.documentElement.lang = currentLocale; + translateTree(root.document.body); + const selector = root.document.getElementById("localeSelect"); + if (selector && selector.value !== currentLocale) selector.value = currentLocale; + } + + function setLocale(value, options = {}) { + currentLocale = SUPPORTED.has(value) ? value : normalizeLocale(value); + if (options.persist !== false && root?.localStorage && !embeddedMode()) { + try { root.localStorage.setItem(STORAGE_KEY, currentLocale); } catch { /* storage may be disabled */ } + } + applyDocument(); + if (root?.CustomEvent) root.dispatchEvent?.(new root.CustomEvent("aether-vscodex:locale", { detail: { locale: currentLocale } })); + return currentLocale; + } + + function initialLocale() { + if (embeddedMode()) return normalizeLocale(root?.navigator?.language); + try { + const saved = root?.localStorage?.getItem(STORAGE_KEY); + if (SUPPORTED.has(saved)) return saved; + } catch { /* storage may be disabled */ } + return normalizeLocale(root?.navigator?.language); + } + + function start() { + if (!root?.document) return; + currentLocale = initialLocale(); + applyDocument(); + if (typeof root.MutationObserver === "function" && !observer) { + observer = new root.MutationObserver((records) => { + if (currentLocale === "zh-CN") return; + for (const record of records) { + if (record.type === "characterData") translateTextNode(record.target); + else if (record.type === "attributes") translateAttributes(record.target); + else for (const node of record.addedNodes) translateTree(node); + } + }); + observer.observe(root.document.documentElement, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["title", "aria-label", "placeholder", "data-placeholder"], + }); + } + } + + const api = { + locale: () => currentLocale, + normalizeLocale, + setLocale, + start, + t: (value) => translate(value), + translate, + translateTree, + messages: { "zh-CN": Object.freeze({}), "en-US": EN }, + }; + + if (root?.document) { + if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", start, { once: true }); + else start(); + } + return api; +}); diff --git a/aether-vscodex/public/index.html b/aether-vscodex/public/index.html new file mode 100644 index 000000000..9137c092d --- /dev/null +++ b/aether-vscodex/public/index.html @@ -0,0 +1,303 @@ + + + + + + + Codex + + + +
+ +
+
+
+ + + 等待 VS Code 主机 +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ +
+ + +
+
+ +
+
+ + + 本地模式 + +
+ + +
+
+
+
+
+ + + + + + + + + + + diff --git a/aether-vscodex/public/style.css b/aether-vscodex/public/style.css new file mode 100644 index 000000000..961bf7203 --- /dev/null +++ b/aether-vscodex/public/style.css @@ -0,0 +1,1817 @@ +:root { + color-scheme: dark light; + --color-background: var(--vscode-editor-background, #181818); + --color-background-under: var(--vscode-sideBar-background, #141414); + --color-surface: var(--vscode-sideBar-background, #1b1b1b); + --color-surface-secondary: var(--vscode-input-background, #202020); + --color-surface-tertiary: var(--vscode-titleBar-activeBackground, #1f1f1f); + --color-surface-hover: var(--vscode-list-hoverBackground, #2a2d2e); + --color-terminal-surface: var(--vscode-textCodeBlock-background, #242526); + --color-terminal-border: var(--vscode-input-border, #373738); + --color-border: var(--vscode-panel-border, #303030); + --color-border-strong: var(--vscode-input-border, #454545); + --color-text: var(--vscode-foreground, #d4d4d4); + --color-text-secondary: var(--vscode-descriptionForeground, #9d9d9d); + --color-text-tertiary: color-mix(in srgb, var(--color-text-secondary) 72%, transparent); + --color-info: var(--vscode-textLink-foreground, #75beff); + --color-warning: var(--vscode-editorWarning-foreground, #d7ba7d); + --color-danger: var(--vscode-editorError-foreground, #f48771); + --color-success: var(--vscode-testing-iconPassed, #4ec9a0); + --color-user-message: color-mix(in srgb, var(--color-text) 5%, transparent); + --font-ui: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + --font-mono: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + --font-size: var(--vscode-font-size, 13px); + --code-size: var(--vscode-editor-font-size, 12px); + --item-gap: 16px; + --panel-width: 500px; + --composer-shadow: 0 4px 16px rgba(0, 0, 0, .13); + font-family: var(--font-ui); +} + +:root[data-theme="light"] { + color-scheme: light; + --color-background: #ffffff; + --color-background-under: #f3f3f3; + --color-surface: #f8f8f8; + --color-surface-secondary: #f3f3f3; + --color-surface-tertiary: #eeeeee; + --color-surface-hover: #e8e8e8; + --color-terminal-surface: #f5f5f5; + --color-terminal-border: #d6d6d6; + --color-border: #dddddd; + --color-border-strong: #c8c8c8; + --color-text: #242424; + --color-text-secondary: #616161; + --color-info: #006ab1; + --color-warning: #8a6100; + --color-danger: #b42318; + --color-success: #267a3e; + --color-user-message: rgba(0, 0, 0, .045); + --composer-shadow: 0 4px 16px rgba(0, 0, 0, .08); +} + +:root[data-theme="dark"] { color-scheme: dark; } + +* { box-sizing: border-box; } +html, body { height: 100%; } +html { background: var(--color-background-under); } +body { + margin: 0; + min-width: 280px; + overflow: hidden; + background: var(--color-background-under); + color: var(--color-text); + font: var(--font-size)/1.45 var(--font-ui); +} + +button, input, textarea, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .42; } +button:focus-visible, summary:focus-visible { + outline: 1px solid var(--color-info); + outline-offset: 1px; +} + +.codex-panel { + width: min(100%, var(--panel-width)); + height: 100dvh; + min-height: 0; + margin: 0 auto; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--color-background); + border-inline: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent); +} + +.connection { display: none; } + +.icon-button { + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + display: inline-flex; + color: var(--color-text-secondary); + background: transparent; + border: 0; + border-radius: 5px; +} +.icon-button:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); } +.icon-button svg, .composer svg, .mode-icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.35; +} + +.chat-shell { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.chat-header { + align-items: center; + flex: 0 0 46px; + min-height: 46px; + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 12px; + background: var(--color-background); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); +} +.thread-heading { align-items: center; display: flex; flex: 1 1 auto; min-width: 0; gap: 2px; } +.header-back-button { + flex: 0 0 24px; + width: 24px; + height: 24px; + color: var(--color-text-secondary); + opacity: .76; +} +.header-back-button:hover:not(:disabled) { background: transparent; opacity: 1; } +.header-back-button svg { width: 12px; height: 12px; } +.thread-picker-button { + min-width: 0; + max-width: min(360px, 62vw); + min-height: 28px; + padding: 2px 4px; + display: inline-flex; + align-items: center; + flex: 0 1 auto; + color: var(--color-text); + text-align: left; + background: transparent; + border: 0; + border-radius: 6px; +} +.thread-picker-button:hover, +.thread-picker-button[aria-expanded="true"] { background: transparent; opacity: .8; } +.thread-picker-button h2 { + margin: 0; + min-width: 0; + overflow: hidden; + color: var(--color-text); + font-size: 13px; + font-weight: 500; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} +.status-text { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} +.thread-actions { align-items: center; display: flex; flex: 0 0 auto; gap: 2px; } +.header-history-button { margin-right: 1px; } +.new-session-button { margin-left: 1px; color: var(--color-text-secondary); } +.panel-popover { + position: absolute; + z-index: 30; + top: 53px; + right: 10px; + min-width: 150px; + padding: 5px; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .28); +} +.panel-popover[hidden] { display: none; } +.panel-menu { display: grid; gap: 2px; } +.panel-menu button { + min-height: 28px; + padding: 5px 8px; + color: var(--color-text); + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; + font-size: 11px; +} +.panel-menu button:hover { background: var(--color-surface-hover); } +.details-popover { width: min(320px, calc(100vw - 20px)); min-width: 220px; padding: 10px 12px; } +.popover-title { margin-bottom: 8px; font-size: 11px; font-weight: 600; } +.settings-shortcuts { display: grid; gap: 2px; } +.settings-shortcuts button { + min-height: 30px; + padding: 5px 7px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--color-text-secondary); + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; + font-size: 10px; +} +.settings-shortcuts button:hover { color: var(--color-text); background: var(--color-surface-hover); } +.settings-locale { + min-height: 30px; + padding: 5px 7px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--color-text-secondary); + font-size: 10px; +} +.settings-locale select { + max-width: 124px; + padding: 2px 5px; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 4px; +} +body.embed-aether #localeSetting { display: none; } +.settings-shortcuts button span:last-child { + max-width: 110px; + overflow: hidden; + color: var(--color-text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-divider { height: 1px; margin: 7px 0; background: var(--color-border); } +.popover-subtitle { margin-bottom: 5px; color: var(--color-text-tertiary); font-size: 9px; } +.details-popover dl { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 5px 8px; margin: 0; font-size: 10px; } +.details-popover dt { color: var(--color-text-secondary); } +.details-popover dd { min-width: 0; margin: 0; overflow: hidden; color: var(--color-text); text-overflow: ellipsis; white-space: nowrap; } +.session-picker { + right: auto; + left: 10px; + width: min(330px, calc(100vw - 20px)); + min-width: 0; + padding: 7px; +} +.session-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 1px 2px 5px 4px; } +.session-picker-header .popover-title { margin: 0; } +.session-picker-refresh { + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary); + background: transparent; + border: 0; + border-radius: 5px; +} +.session-picker-refresh:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); } +.session-picker-refresh svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; } +.session-search { + min-height: 28px; + margin: 1px 2px 5px; + padding: 0 7px; + display: flex; + align-items: center; + gap: 6px; + color: var(--color-text-tertiary); + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; +} +.session-search:focus-within { + color: var(--color-text-secondary); + border-color: var(--color-info); +} +.session-search > svg { width: 13px; height: 13px; flex: 0 0 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; } +.session-search input { + width: 100%; + min-width: 0; + height: 26px; + padding: 0; + color: var(--color-text); + background: transparent; + border: 0; + outline: 0; + font-size: 11px; +} +.session-search input::placeholder { color: var(--color-text-tertiary); } +.session-search input::-webkit-search-cancel-button { appearance: none; } +.session-search-clear { + width: 18px; + height: 18px; + min-height: 18px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 18px; + color: var(--color-text-tertiary); + background: transparent; + border: 0; + border-radius: 4px; +} +.session-search-clear:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); } +.session-search-clear svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.35; } +.session-picker-status { min-height: 16px; padding: 0 5px 4px; color: var(--color-text-tertiary); font-size: 10px; } +.session-picker-status[data-tone="warning"] { color: var(--color-warning); } +.session-list { max-height: min(58dvh, 390px); overflow-y: auto; overscroll-behavior: contain; } +.session-list:empty { display: none; } +.session-list:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-info) 72%, transparent); outline-offset: -1px; } +.session-list-empty { padding: 17px 9px; color: var(--color-text-tertiary); font-size: 11px; text-align: center; } +.session-option { + position: relative; + width: 100%; + min-height: 53px; + padding: 7px 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + color: var(--color-text); + text-align: left; + background: transparent; + border: 0; + border-radius: 6px; +} +.session-option:hover:not(:disabled), .session-option[aria-selected="true"], .session-option[data-focused="true"] { background: var(--color-surface-hover); } +.session-option[aria-selected="true"] { box-shadow: inset 2px 0 var(--color-info); } +.session-option[data-focused="true"]::after { + position: absolute; + inset: 1px; + border: 1px solid color-mix(in srgb, var(--color-info) 55%, transparent); + border-radius: 5px; + content: ""; + pointer-events: none; +} +.session-option:disabled { opacity: .55; } +.session-option-title { min-width: 0; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.session-option-time { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; } +.session-option-meta { grid-column: 1; min-width: 0; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.session-option-state { grid-column: 2; min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; color: var(--color-success); font-size: 9px; white-space: nowrap; } +.session-status-dot { width: 6px; height: 6px; flex: 0 0 6px; background: var(--color-success); border-radius: 50%; } +.session-option[data-status="working"] .session-status-dot, +.session-option[data-status="thinking"] .session-status-dot, +.session-option[data-status="editing"] .session-status-dot { background: var(--color-warning); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-warning) 18%, transparent); } +.session-option[data-status="waiting"] .session-status-dot, +.session-option[data-status="approval"] .session-status-dot, +.session-option[data-status="unread"] .session-status-dot { background: var(--color-info); } +.session-option[data-status="error"] .session-status-dot { background: var(--color-danger); } +.session-option[data-unread="true"] .session-option-title { font-weight: 600; } +.session-option[data-unread="true"] .session-option-time { color: var(--color-info); } +.session-option[data-switching="true"] .session-option-state { color: var(--color-warning); } +.session-option[data-switching="true"] .session-status-dot { background: var(--color-warning); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-warning) 18%, transparent); } +.session-option[data-available="false"] .session-option-state { color: var(--color-text-tertiary); } +.session-option[data-available="false"] .session-status-dot { background: var(--color-text-tertiary); box-shadow: none; } +.session-picker[data-switching="true"] .session-option { pointer-events: none; } +.restore-panel { + position: fixed; + right: 14px; + bottom: 14px; + z-index: 50; + min-height: 30px; + padding: 5px 10px; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 7px; + box-shadow: var(--composer-shadow); +} +.restore-panel[hidden] { display: none; } +body.panel-expanded .codex-panel { width: min(100%, 900px); } +body.panel-hidden .codex-panel { display: none; } + +.chat-panel { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.chat-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scroll-behavior: auto; + /* JS keeps an explicit timeline anchor during disclosure/stream updates. */ + overflow-anchor: none; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + scrollbar-gutter: stable; + padding: 16px 18px var(--thread-scroll-padding-bottom, 112px); + scroll-padding-bottom: var(--thread-scroll-padding-bottom, 112px); +} +.chat-scroll:hover, .chat-scroll:focus-within { scrollbar-color: var(--color-border-strong) transparent; } +.chat-scroll::-webkit-scrollbar { width: 10px; } +.chat-scroll::-webkit-scrollbar-track { background: transparent; } +.chat-scroll::-webkit-scrollbar-thumb { background: var(--color-border); border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; } +.chat-scroll:hover::-webkit-scrollbar-thumb, .chat-scroll:focus-within::-webkit-scrollbar-thumb { background: var(--color-border-strong); background-clip: padding-box; } +.output { color: var(--color-text); font-size: var(--font-size); word-break: break-word; } +.output:empty::before { content: none; } + +.message { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + margin: 0 0 var(--item-gap); +} +.message.user { align-items: flex-end; justify-content: flex-start; } +.message-content { min-width: 0; max-width: min(100%, 520px); overflow-wrap: anywhere; line-height: 1.5; } +.message.user .message-content { + max-width: min(77%, 520px); + padding: 8px 12px; + color: var(--color-text); + background: var(--color-user-message); + border: 0; + border-radius: 16px; +} +.message.assistant .message-content { max-width: 100%; } +.message.system .message-content, .message.error .message-content { color: var(--color-text-secondary); font-size: 12px; } +.message.activity .message-content { font-size: var(--font-size); } +.message.activity .message-details > summary { font-size: var(--font-size); } +.message.error .message-content { color: var(--color-danger); } +.message.tool .message-content { width: 100%; color: var(--color-text-secondary); } +.message.streaming .message-content::after { content: "▍"; margin-left: 2px; color: var(--color-text-secondary); } +.message-meta { + margin-top: 5px; + color: var(--color-text-tertiary); + font-size: 10px; + line-height: 1.35; + font-variant-numeric: tabular-nums; + opacity: 0; + transition: opacity .12s ease; +} +.message:hover .message-meta, .message:focus-within .message-meta { opacity: 1; } +.message.user .message-meta { text-align: right; } +.date-separator { + display: flex; + align-items: center; + justify-content: center; + min-height: 52px; + padding: 16px 0; + margin: 0; + color: var(--color-text-tertiary); + font-size: 13px; + font-weight: 400; + line-height: 20px; + user-select: none; + white-space: nowrap; +} +.date-separator::before, .date-separator::after { display: none; } +.date-separator time { font: inherit; color: inherit; } +.date-separator .date-label { font-weight: 500; } +.date-separator .date-time { font-weight: 400; } + +.turn-divider { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + margin: 4px 0 12px; + color: var(--color-text-secondary); +} +.turn-divider-toggle { + align-items: center; + align-self: flex-start; + display: inline-flex; + gap: 4px; + min-height: 22px; + padding: 1px 2px; + color: color-mix(in srgb, var(--color-text) 60%, transparent); + font-size: var(--font-size); + line-height: 20px; + text-align: left; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; +} +.turn-divider-toggle:hover { color: var(--color-text); background: var(--color-surface-hover); } +.turn-divider-toggle svg { + width: 12px; + height: 12px; + flex: 0 0 12px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.35; + transition: transform .12s ease; +} +.turn-divider-toggle[aria-expanded="true"] svg { transform: rotate(90deg); } +.turn-divider-rule { width: 100%; height: 1px; background: var(--color-border); } + +.markdown-body p { margin: 0; } +.markdown-body p + p, .markdown-body ul + p, .markdown-body ol + p, +.markdown-body pre + p, .markdown-body blockquote + p, .markdown-body table + p { margin-top: 11px; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + margin: 0 0 8px; + color: inherit; + font-weight: 600; + line-height: 1.3; +} +.markdown-body h1 { font-size: 1.45em; } +.markdown-body h2 { font-size: 1.25em; } +.markdown-body h3 { font-size: 1.1em; } +.markdown-body ul, .markdown-body ol { margin: 8px 0; padding-inline-start: 22px; } +.markdown-body li + li { margin-top: 4px; } +.markdown-body blockquote { margin: 10px 0; padding-inline-start: 11px; color: var(--color-text-secondary); border-inline-start: 2px solid var(--color-border-strong); } +.markdown-body hr { height: 1px; margin: 14px 0; background: var(--color-border); border: 0; } +.markdown-body a { color: var(--color-info); text-decoration: underline; text-underline-offset: 2px; } +.markdown-body code, .message code { padding: 1px 4px; color: inherit; font: var(--code-size)/1.35 var(--font-mono); background: color-mix(in srgb, var(--color-surface-hover) 85%, transparent); border-radius: 4px; } +.markdown-body pre, .message pre { + max-width: 100%; + margin: 10px 0; + padding: 10px 11px; + overflow: auto; + color: var(--color-text); + font: var(--code-size)/1.5 var(--font-mono); + white-space: pre; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 6px; +} +.markdown-body pre code, .message pre code { padding: 0; background: transparent; } +.markdown-body table { width: 100%; margin: 10px 0; border-collapse: collapse; font-size: .95em; } +.markdown-body th, .markdown-body td { padding: 5px 7px; text-align: left; border: 1px solid var(--color-border); } +.markdown-body th { color: var(--color-text); background: var(--color-surface-secondary); font-weight: 600; } +.markdown-body .task-list-item { list-style: none; margin-inline-start: -20px; } +.markdown-body .task-list-item input { width: 13px; height: 13px; margin: 0 6px 0 0; vertical-align: -2px; accent-color: var(--color-info); } + +.message-actions { + position: static; + min-height: 18px; + display: flex; + align-items: center; + gap: 2px; + margin-top: 1px; + opacity: 0; + transition: opacity .12s ease; +} +.message.user .message-actions { justify-content: flex-end; } +.message:hover .message-actions, .message:focus-within .message-actions { opacity: 1; } +.message-action { + min-height: 18px; + padding: 1px 4px; + color: var(--color-text-secondary); + font-size: 12px; + background: transparent; + border: 0; + border-radius: 4px; +} +.message-action:hover { color: var(--color-text); background: var(--color-surface-hover); } +.message-action svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; } +.message-actions .message-meta { margin: 0 3px; opacity: 1; } + +.message-details { + min-width: min(100%, 420px); + overflow: hidden; + background: transparent; + border: 0; +} +.message-details > .details-body { + /* Managed disclosures stay mounted; app.js animates this measured box. */ + display: block; + will-change: height, opacity; +} +.message-details > summary { + min-width: 0; + padding: 2px 0; + overflow: hidden; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.5; + list-style: none; + text-overflow: ellipsis; + white-space: nowrap; +} +.message-details > summary::-webkit-details-marker { display: none; } +.message-details > summary::after { + content: "›"; + display: inline-block; + width: 12px; + margin-left: 4px; + color: var(--color-text-secondary); + font-size: 15px; + opacity: 0; + transition: transform .18s cubic-bezier(.33,1,.68,1), opacity .12s ease; +} +.message-details > summary:hover::after, +.message-details > summary:focus-visible::after, +.message-details[data-expanded="true"] > summary::after { opacity: 1; } +.message-details[data-expanded="true"] > summary::after { transform: rotate(90deg); } +.message-details .details-body { + max-height: 260px; + margin-top: 5px; + padding: 0; + overflow: auto; + color: var(--color-text-secondary); + font: inherit; + line-height: 1.5; + white-space: normal; + background: transparent; + border: 0; + border-radius: 0; +} +.message.system .message-details > summary { color: var(--color-text-secondary); } +.message.system .message-details > summary::after { color: var(--color-text-secondary); } +.message.activity { margin-bottom: 4px; } +.message.turn-collapsed { + /* The outer worked-for disclosure removes its activity units from layout. */ + height: 0 !important; + min-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + visibility: hidden; + pointer-events: none; + opacity: 0; +} +.message.turn-collapsed * { margin-block: 0 !important; } +.message[data-turn-expanded="false"] .message-details > summary { pointer-events: none; } +.message.activity + .message.assistant { padding-top: 0; border-top: 0; } +.message.activity[data-kind="commentary"] .message-details > summary { display: none; } +.message.activity[data-kind="commentary"] .message-details > .details-body { margin-top: 0; } +.message.activity[data-status="inProgress"] .message-details > summary { color: var(--color-text); } +.message.activity[data-status="inProgress"][data-kind="reasoning"] .message-details > summary { + background: linear-gradient(90deg, var(--color-text-secondary), var(--color-text), var(--color-text-secondary)); + background-size: 220% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: thinking-shimmer 1.8s linear infinite; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .details-body { + max-height: none; + margin-top: 8px; + padding: 0; + overflow: visible; + color: var(--color-text-secondary); + font: inherit; + white-space: normal; + background: transparent; + border: 0; + border-radius: 0; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details { width: 100%; min-width: 0; } +.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details > summary { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + max-width: 100%; + color: var(--color-text-secondary); +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details > summary::after { + flex: 0 0 12px; + margin-left: 0; +} +.message.activity[data-kind="subagent"] .message-details { width: 100%; min-width: 0; } +.message.activity[data-kind="subagent"] .message-details > summary { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + max-width: 100%; + color: var(--color-text-secondary); +} +.message.activity[data-kind="subagent"] .message-details > summary::after { + flex: 0 0 12px; + margin-left: 0; +} +.subagent-summary-chip { + min-width: 0; + max-width: min(48%, 192px); + min-height: 24px; + padding: 1px 8px 1px 5px; + display: inline-flex; + align-items: center; + gap: 5px; + overflow: hidden; + color: var(--color-text-secondary); + vertical-align: middle; + background: transparent; + border: 1px solid color-mix(in srgb, var(--color-border-strong) 72%, transparent); + border-radius: 999px; +} +.subagent-summary-chip:hover { color: var(--color-text); border-color: var(--color-border-strong); background: var(--color-surface-hover); } +.subagent-summary-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.subagent-summary-status { + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} +.activity-summary-icon { + width: 13px; + height: 13px; + flex: 0 0 13px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.15; + opacity: .75; +} +.activity-summary-icon.subagent-summary-icon { + fill: currentColor; + stroke: none; + color: var(--color-success); + opacity: .95; +} +.subagent-summary-chip .subagent-summary-icon { width: 14px; height: 14px; flex: 0 0 14px; } +.activity-summary-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.terminal-body { width: 100%; } +.terminal-shell { + width: 100%; + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary); + background: var(--color-terminal-surface); + border: 1px solid var(--color-terminal-border); + border-radius: 10px; +} +.terminal-shell-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 25px; + overflow: hidden; + padding: 4px 8px; + color: var(--color-text-secondary); + font: 12px/17px var(--font-ui); + user-select: none; + background: transparent; +} +.terminal-shell-label { + min-width: 0; + overflow: hidden; + padding: 0; + color: inherit; + font: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} +.terminal-file-path { + min-width: 0; + overflow: hidden; + padding: 3px 8px 5px; + color: var(--color-text-secondary); + font: var(--code-size)/1.45 var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; + border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); +} +.read-body { + display: grid; + gap: 2px; + padding: 2px 0 3px; + color: var(--color-text-secondary); + font-size: 11px; +} +.read-path-list { display: grid; gap: 1px; } +.read-path-row { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + color: var(--color-text-secondary); + line-height: 1.45; +} +.read-path-row > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.read-path-row:hover { color: var(--color-text); background: var(--color-surface-hover); border-radius: 4px; } +.read-path-icon { + width: 13px; + height: 13px; + flex: 0 0 13px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.1; + opacity: .72; +} +.read-output { + max-height: 140px; + margin: 4px 7px 0; + padding: 6px 7px; + overflow: auto; + color: var(--color-text-secondary); + font: var(--code-size)/1.5 var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 5px; +} +.read-empty { padding: 3px 7px; color: var(--color-text-tertiary); } +.terminal-shell-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 1px; + padding-right: 5px; +} +.terminal-action { + width: 20px; + height: 20px; + min-height: 20px; + padding: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary); + background: transparent; + border: 0; + border-radius: 4px; + opacity: 0; + transition: opacity .12s ease, color .12s ease, background .12s ease; +} +.terminal-action:focus-visible { opacity: 1; } +.terminal-action:hover { color: var(--color-text); background: var(--color-surface-hover); } +.terminal-action[data-copied="true"] { color: var(--color-success); opacity: 1; } +.terminal-action svg { + width: 13px; + height: 13px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.15; +} +.terminal-command-line { + position: relative; + display: flex; + align-items: baseline; + gap: 1ch; + min-width: 0; + padding: 8px 28px 0 8px; + color: var(--color-text-secondary); + font: var(--code-size)/1.5 var(--font-mono); + white-space: pre-wrap; + word-break: break-word; + cursor: pointer; + will-change: height; + scrollbar-gutter: stable; +} +.terminal-prompt { + flex: 0 0 auto; + color: var(--color-text-tertiary); + user-select: none; +} +.terminal-command-line code { + min-width: 0; + max-width: 100%; + padding: 0; + overflow-wrap: anywhere; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--color-text-secondary); + font: inherit; + background: transparent; +} +.terminal-command-line[data-expanded="true"] code { + display: block; + overflow: visible; + -webkit-line-clamp: unset; +} +.terminal-command-chevron { + width: 12px; + height: 12px; + flex: 0 0 12px; + margin-left: auto; + color: var(--color-text-tertiary); + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; + transition: transform .18s cubic-bezier(.33,1,.68,1), color .12s ease; +} +.terminal-command-line[data-expanded="true"] .terminal-command-chevron { transform: rotate(180deg); color: var(--color-text-secondary); } +.terminal-command-line:hover .terminal-command-chevron, +.terminal-command-line:focus-visible .terminal-command-chevron { color: var(--color-text); } +.terminal-command-action { + position: absolute; + top: 5px; + right: 5px; +} +.terminal-command-line:hover .terminal-command-action, +.terminal-command-action:focus-visible { opacity: 1; } +.terminal-output-wrap { + position: relative; + min-height: 20px; + margin-top: 0; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + max-height: 144px; + margin: 0; + padding: 0; + box-sizing: border-box; + overflow-x: auto; + overflow-y: auto; + color: var(--color-text-secondary); + font: var(--code-size)/1.5 var(--font-mono); + font-weight: 500; + white-space: pre; + background: transparent; + border: 0; + border-radius: 0; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + scrollbar-gutter: stable; +} +.terminal-output-content { + display: block; + width: max-content; + min-width: 100%; + min-height: 18px; + margin: 0; + padding: 8px; + box-sizing: border-box; + color: inherit; + font: inherit; + white-space: inherit; +} +.terminal-output-empty { min-height: 34px; } +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output:hover { + scrollbar-color: var(--color-border-strong) transparent; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar { width: 8px; height: 8px; } +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar-track { background: transparent; } +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar-thumb { + background: var(--color-border); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output:hover::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + background-clip: padding-box; +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="true"][data-fade-bottom="true"] { + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 2rem, #000 calc(100% - 2rem), transparent); + mask-image: linear-gradient(to bottom, transparent, #000 2rem, #000 calc(100% - 2rem), transparent); +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="true"][data-fade-bottom="false"] { + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 2rem); + mask-image: linear-gradient(to bottom, transparent, #000 2rem); +} +.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="false"][data-fade-bottom="true"] { + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent); +} +.terminal-output-action { + position: absolute; + top: 0; + right: 10px; +} +.terminal-output-wrap:hover .terminal-output-action, +.terminal-output-action:focus-visible { opacity: 1; } +.terminal-no-output { + color: var(--color-text-tertiary); + font: inherit; +} +.terminal-footer { + min-height: 26px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 2px 10px 4px; + color: var(--color-text-tertiary); + font-size: var(--font-size); + line-height: 20px; +} +.terminal-status { display: inline-flex; align-items: center; gap: 4px; } +.terminal-footer[data-status="failed"], .terminal-footer[data-status="declined"] { color: var(--color-text-tertiary); } +.terminal-footer[data-status="completed"] { color: var(--color-text-tertiary); } +.terminal-status-icon { + width: 12px; + height: 12px; + flex: 0 0 12px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; +} +.terminal-output .ansi-bold { font-weight: 700; } +.terminal-output .ansi-dim { opacity: .5; } +.terminal-output .ansi-italic { font-style: italic; } +.terminal-output .ansi-underline { text-decoration: underline; text-underline-offset: 2px; } +.terminal-output .ansi-strikethrough { text-decoration: line-through; } +.terminal-output .ansi-black-fg { color: var(--color-codex-terminal-ansi-black, #000); } +.terminal-output .ansi-red-fg { color: var(--color-codex-terminal-ansi-red, #f66); } +.terminal-output .ansi-green-fg { color: var(--color-codex-terminal-ansi-green, #94f494); } +.terminal-output .ansi-yellow-fg { color: var(--color-codex-terminal-ansi-yellow, #f4f47b); } +.terminal-output .ansi-blue-fg { color: var(--color-codex-terminal-ansi-blue, #9e9eff); } +.terminal-output .ansi-magenta-fg { color: var(--color-codex-terminal-ansi-magenta, #db6bdb); } +.terminal-output .ansi-cyan-fg { color: var(--color-codex-terminal-ansi-cyan, #81eeee); } +.terminal-output .ansi-white-fg { color: var(--color-codex-terminal-ansi-white, #d6d6d6); } +.terminal-output .ansi-bright-black-fg { color: var(--color-codex-terminal-ansi-bright-black, #6e6e6e); } +.terminal-output .ansi-bright-red-fg { color: var(--color-codex-terminal-ansi-bright-red, #ffa8a8); } +.terminal-output .ansi-bright-green-fg { color: var(--color-codex-terminal-ansi-bright-green, #0f0); } +.terminal-output .ansi-bright-yellow-fg { color: var(--color-codex-terminal-ansi-bright-yellow, #ffffa8); } +.terminal-output .ansi-bright-blue-fg { color: var(--color-codex-terminal-ansi-bright-blue, #9494ff); } +.terminal-output .ansi-bright-magenta-fg { color: var(--color-codex-terminal-ansi-bright-magenta, #ffb3ff); } +.terminal-output .ansi-bright-cyan-fg { color: var(--color-codex-terminal-ansi-bright-cyan, #adffff); } +.terminal-output .ansi-bright-white-fg { color: var(--color-codex-terminal-ansi-bright-white, #fff); } +.terminal-output .ansi-black-bg { background-color: var(--color-codex-terminal-ansi-black, #000); } +.terminal-output .ansi-red-bg { background-color: var(--color-codex-terminal-ansi-red, #f66); } +.terminal-output .ansi-green-bg { background-color: var(--color-codex-terminal-ansi-green, #94f494); } +.terminal-output .ansi-yellow-bg { background-color: var(--color-codex-terminal-ansi-yellow, #f4f47b); } +.terminal-output .ansi-blue-bg { background-color: var(--color-codex-terminal-ansi-blue, #9e9eff); } +.terminal-output .ansi-magenta-bg { background-color: var(--color-codex-terminal-ansi-magenta, #db6bdb); } +.terminal-output .ansi-cyan-bg { background-color: var(--color-codex-terminal-ansi-cyan, #81eeee); } +.terminal-output .ansi-white-bg { background-color: var(--color-codex-terminal-ansi-white, #d6d6d6); } +.terminal-output .ansi-bright-black-bg { background-color: var(--color-codex-terminal-ansi-bright-black, #6e6e6e); } +.terminal-output .ansi-bright-red-bg { background-color: var(--color-codex-terminal-ansi-bright-red, #ffa8a8); } +.terminal-output .ansi-bright-green-bg { background-color: var(--color-codex-terminal-ansi-bright-green, #0f0); } +.terminal-output .ansi-bright-yellow-bg { background-color: var(--color-codex-terminal-ansi-bright-yellow, #ffffa8); } +.terminal-output .ansi-bright-blue-bg { background-color: var(--color-codex-terminal-ansi-bright-blue, #9494ff); } +.terminal-output .ansi-bright-magenta-bg { background-color: var(--color-codex-terminal-ansi-bright-magenta, #ffb3ff); } +.terminal-output .ansi-bright-cyan-bg { background-color: var(--color-codex-terminal-ansi-bright-cyan, #adffff); } +.terminal-output .ansi-bright-white-bg { background-color: var(--color-codex-terminal-ansi-bright-white, #fff); } +.message.activity[data-kind="reasoning"] .details-body, +.message.activity[data-kind="plan"] .details-body, +.message.activity[data-kind="edit"] .details-body { max-height: 140px; } +.diff-output { + margin: 5px 0 0; + padding: 7px 9px; + overflow: auto; + color: var(--color-text-secondary); + font: var(--code-size)/1.5 var(--font-mono); + white-space: pre; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 6px; +} +.diff-line { display: block; min-height: 1.5em; } +.diff-line.added { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); } +.diff-line.removed { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 10%, transparent); } +.diff-line.context { color: var(--color-text-tertiary); } + +.scroll-to-bottom { + position: absolute; + z-index: 12; + left: 50%; + right: auto; + bottom: calc(var(--thread-scroll-padding-bottom, 112px) + 4px); + width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, .24); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity .12s ease, transform .12s ease, color .12s ease; +} +.scroll-to-bottom[data-visible="true"] { opacity: 1; pointer-events: auto; transform: translate(-50%, 0); } +.scroll-to-bottom:hover { color: var(--color-text); background: var(--color-surface-hover); } +.scroll-to-bottom svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.3; } +.scroll-working-dots { display: none; align-items: center; gap: 2px; height: 12px; } +.scroll-working-dots i { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: .28; animation: activity-dot 1.2s ease-in-out infinite; } +.scroll-working-dots i:nth-child(2) { animation-delay: .16s; } +.scroll-working-dots i:nth-child(3) { animation-delay: .32s; } +.scroll-to-bottom[data-working="true"] svg { display: none; } +.scroll-to-bottom[data-working="true"] .scroll-working-dots { display: inline-flex; } + +.live-activity { + display: flex; + align-items: center; + flex: 0 0 auto; + min-height: 28px; + gap: 7px; + padding: 3px 15px 6px; + color: var(--color-warning); + font-size: 11px; +} +.live-status { + position: absolute; + width: 1px; + height: 1px; + min-height: 0; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.live-activity[hidden] { display: none; } +.live-activity[data-activity="completed"] { color: var(--color-success); } +.live-activity[data-activity="failed"], .live-activity[data-activity="interrupted"] { color: var(--color-danger); } +.activity-spinner { + width: 11px; + height: 11px; + flex: 0 0 11px; + display: inline-block; + border: 1px solid currentColor; + border-right-color: transparent; + border-radius: 50%; +} +.live-activity[data-active="true"] .activity-spinner { animation: activity-spin .8s linear infinite; } +.live-activity[data-active="false"] .activity-spinner { opacity: .45; } +.activity-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-dots { display: inline-flex; align-items: center; gap: 2px; height: 12px; } +.activity-dots i { + width: 2px; + height: 2px; + background: currentColor; + border-radius: 50%; + opacity: .28; + animation: activity-dot 1.2s ease-in-out infinite; +} +.activity-dots i:nth-child(2) { animation-delay: .16s; } +.activity-dots i:nth-child(3) { animation-delay: .32s; } +.activity-elapsed { margin-left: auto; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; } +@keyframes activity-spin { to { transform: rotate(360deg); } } +@keyframes activity-dot { 0%, 70%, 100% { opacity: .22; transform: translateY(0); } 35% { opacity: .9; transform: translateY(-2px); } } +@keyframes thinking-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } + +.inline-requests { + position: relative; + z-index: 16; + flex: 0 0 auto; + max-height: min(42vh, 360px); + display: grid; + gap: 9px; + padding: 0 12px calc(var(--thread-scroll-padding-bottom, 112px) + 10px); + background: var(--color-background); + overflow: auto; +} +.inline-requests.empty { display: none; } +.inline-requests .request { + padding: 12px 13px; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 10px; + box-shadow: 0 4px 18px rgba(0, 0, 0, .16); +} +.request-title { align-items: center; display: flex; min-width: 0; gap: 7px; } +.request-icon { + width: 18px; + height: 18px; + flex: 0 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-warning); + font-size: 11px; + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning) 42%, transparent); + border-radius: 50%; +} +.request-method { min-width: 0; overflow-wrap: anywhere; color: var(--color-text); font-size: 12px; font-weight: 600; } +.request-risk { padding: 2px 6px; color: var(--color-warning); font-size: 9px; border: 1px solid color-mix(in srgb, var(--color-warning) 42%, transparent); border-radius: 999px; white-space: nowrap; } +.request-risk[data-risk="high"] { color: var(--color-danger); border-color: color-mix(in srgb, var(--color-danger) 50%, transparent); } +.request-risk[data-risk="low"] { color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 45%, transparent); } +.request-id { margin-left: auto; color: var(--color-text-tertiary); font: 10px var(--font-mono); } +.request-summary { margin: 9px 0; color: var(--color-text-secondary); font-size: 11px; line-height: 1.5; white-space: pre-wrap; } +.request-command, .request-json { max-width: 100%; margin: 8px 0; padding: 8px 9px; overflow: auto; color: var(--color-text); font: var(--code-size)/1.45 var(--font-mono); white-space: pre-wrap; word-break: break-word; background: var(--color-background); border: 1px solid var(--color-border); border-radius: 6px; } +.request-questions { display: grid; gap: 8px; margin: 9px 0; } +.request-question { display: grid; gap: 4px; color: var(--color-text); font-size: 11px; } +.request-question input, .request-question select, .request-scope { min-height: 30px; padding: 5px 8px; color: var(--color-text); background: var(--color-background); border: 1px solid var(--color-border-strong); border-radius: 5px; } +.request-scope-wrap { max-width: 220px; display: grid; gap: 4px; margin: 9px 0; color: var(--color-text-secondary); font-size: 10px; } +.request-details { margin-top: 8px; border-top: 1px solid var(--color-border); } +.request-details > summary { padding: 7px 0 2px; color: var(--color-text-secondary); font-size: 10px; cursor: pointer; } +.request-details .request-json { margin-bottom: 7px; } +.request-response { width: 100%; min-height: 58px; margin-top: 7px; padding: 7px 8px; resize: vertical; color: var(--color-text); font: var(--code-size)/1.4 var(--font-mono); background: var(--color-background); border: 1px solid var(--color-border-strong); border-radius: 5px; } +.request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border); } +.request-actions button { min-height: 28px; padding: 4px 9px; font-size: 10px; } + +.composer { + position: absolute; + z-index: 15; + right: 10px; + bottom: 8px; + left: 16px; + pointer-events: none; + /* The transcript scrolls underneath this fixed composer. Keep a solid + reading band so expanded command output never bleeds through the input. */ + padding-top: 5px; + background: var(--color-background); +} +.composer > .live-activity { + pointer-events: none; + width: min(100%, max-content); + min-height: 26px; + padding: 2px 9px 5px; + color: var(--color-text-secondary); + font-size: 11px; +} +.composer > .live-activity .activity-elapsed { margin-left: 4px; } +.composer > .live-activity[data-activity="thinking"] .activity-spinner, +.composer > .live-activity[data-activity="editing"] .activity-spinner, +.composer > .live-activity[data-activity="running"] .activity-spinner { color: var(--color-success); } +.subagents-panel { + pointer-events: auto; + max-height: min(24dvh, 150px); + margin: 0 8px 4px; + overflow: hidden; + color: var(--color-text-secondary); +} +.subagents-panel[hidden] { display: none; } +.subagents-toggle { + width: 100%; + min-height: 24px; + padding: 1px 2px; + display: flex; + align-items: center; + gap: 5px; + color: var(--color-text-secondary); + text-align: left; + background: transparent; + border: 0; +} +.subagents-toggle:hover { color: var(--color-text); } +.subagents-title { font-size: 10px; } +.subagents-count { color: var(--color-text-tertiary); font-size: 10px; } +.subagents-toggle svg { width: 12px; height: 12px; margin-left: 1px; transition: transform .18s cubic-bezier(.33, 1, .68, 1); } +.subagents-toggle[aria-expanded="true"] svg { transform: rotate(90deg); } +.subagents-list { + display: grid; + gap: 1px; + max-height: 112px; + overflow: auto; + scrollbar-width: thin; + transition: grid-template-rows .2s cubic-bezier(.33, 1, .68, 1), opacity .16s ease; +} +.subagents-panel[data-collapsed="true"] .subagents-list { + max-height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} +.subagent-section { min-width: 0; } +.subagent-section-heading { + padding: 5px 2px 2px; + color: var(--color-text-tertiary); + font-size: 9px; + line-height: 14px; + letter-spacing: 0; +} +.subagent-empty { + padding: 4px 2px 6px; + color: var(--color-text-tertiary); + font-size: 10px; +} +.subagent-section-rows { display: grid; gap: 1px; } +.subagent-more { + min-height: 24px; + margin-top: 3px; + padding: 2px 2px; + color: var(--color-text-tertiary); + font-size: 10px; + text-align: left; + background: transparent; + border: 0; + border-radius: 4px; +} +.subagent-more:hover, +.subagent-more:focus-visible { + color: var(--color-text-secondary); + background: var(--color-surface-hover); + outline: none; +} +.subagent-row { + width: 100%; + min-width: 0; + min-height: 24px; + padding: 2px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + color: inherit; + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; + cursor: default; +} +.subagent-row:is(button):hover, +.subagent-row:is(button):focus-visible { + color: var(--color-text); + background: var(--color-surface-hover); + outline: none; +} +.subagent-icon { + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary); +} +.subagent-icon::before { display: none; } +.subagent-icon svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.15; } +.subagent-row[data-status="working"] .subagent-icon { color: var(--color-success); } +.subagent-row[data-status="failed"] .subagent-icon { color: var(--color-danger); } +.subagent-diff-stats { color: var(--color-text-tertiary); font-size: 9px; font-variant-numeric: tabular-nums; } +.subagent-copy { min-width: 0; display: block; } +.subagent-name { overflow: hidden; color: var(--color-text); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.subagent-objective, .subagent-elapsed { display: none; } +.subagent-status { display: inline-flex; align-items: center; gap: 4px; color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; } +.subagent-elapsed { min-width: 28px; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; text-align: right; } +.subagent-status-text { white-space: nowrap; } +.subagent-focus { animation: subagent-focus .9s ease-out; } +@keyframes subagent-focus { 0% { background: color-mix(in srgb, var(--color-info) 24%, transparent); } 100% { background: transparent; } } +.subagent-body { + display: grid; + gap: 7px; + padding: 2px 0 4px; + color: var(--color-text-secondary); +} +.subagent-prompt { min-width: 0; font-size: 11px; line-height: 1.5; } +.subagent-prompt p { margin: 0; } +.subagent-action-meta { display: flex; align-items: center; gap: 7px; color: var(--color-text-tertiary); font-size: 10px; } +.subagent-model { padding: 1px 5px; color: var(--color-text-secondary); background: var(--color-surface-hover); border-radius: 4px; font-family: var(--font-mono); } +.subagent-action-rows { display: grid; gap: 2px; padding-top: 2px; border-top: 1px solid var(--color-border); } +.subagent-action-row { + min-width: 0; + padding: 4px 5px; + display: grid; + grid-template-columns: 14px minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + color: var(--color-text-secondary); + font-size: 10px; + border-radius: 4px; +} +.subagent-action-icon { width: 8px; height: 8px; border: 1px solid currentColor; border-radius: 50%; opacity: .65; } +.subagent-action-row[data-status="working"] .subagent-action-icon { color: var(--color-success); border-right-color: transparent; animation: activity-spin .8s linear infinite; } +.subagent-action-row[data-status="failed"] .subagent-action-icon { color: var(--color-danger); } +.subagent-action-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.subagent-action-status { color: var(--color-text-tertiary); white-space: nowrap; } +.subagent-action-note { grid-column: 2 / -1; overflow: hidden; color: var(--color-text-tertiary); text-overflow: ellipsis; white-space: nowrap; } +.composer-surface { + pointer-events: auto; + padding: 9px 10px 7px; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 20px; + box-shadow: var(--composer-shadow); + backdrop-filter: blur(16px); +} +.composer-surface:focus-within { border-color: color-mix(in srgb, var(--color-info) 68%, var(--color-border-strong)); } +.composer-editor { + min-height: 40px; + max-height: 25dvh; + overflow: auto; + padding: 3px 2px; + color: var(--color-text); + line-height: 1.5; + outline: none; + white-space: pre-wrap; + word-break: break-word; +} +.composer-editor:empty::before { content: attr(data-placeholder); color: var(--color-text-tertiary); pointer-events: none; } +.composer-footer { align-items: center; display: flex; justify-content: space-between; gap: 8px; padding-top: 4px; } +.composer-hint { position: relative; align-items: center; display: flex; min-width: 0; gap: 7px; color: var(--color-text-secondary); } +.composer-icon-button { + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + background: transparent; + border: 0; + border-radius: 6px; +} +.composer-icon-button:hover, .composer-icon-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.composer-icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.3; } +.permission-chip { + width: auto; + min-width: 28px; + height: 28px; + min-height: 28px; + padding: 0 4px; + align-items: center; + display: inline-flex; + justify-content: flex-start; + gap: 4px; + color: var(--color-warning); + font-size: 10px; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 6px; +} +.permission-chip:hover, .permission-chip[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.permission-chip[hidden], .model-picker-button[hidden], .usage-picker[hidden] { display: none; } +.permission-chip svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.1; } +.permission-chip #permissionLabel { + min-width: 0; + max-width: 96px; + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; + white-space: nowrap; +} +.permission-chip .permission-chevron { display: inline-block; flex: 0 0 14px; } +.composer-popover { + position: absolute; + z-index: 50; + min-width: 194px; + padding: 5px; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, .34); +} +.composer-popover[hidden] { display: none; } +.composer-popover-heading { + padding: 5px 8px 4px; + color: var(--color-text-tertiary); + font-size: 9px; + line-height: 14px; +} +.composer-plus-menu { left: 0; bottom: 31px; } +.permission-menu { left: 31px; bottom: 34px; width: min(280px, calc(100vw - 28px)); } +.permission-confirm { + position: absolute; + z-index: 60; + left: 31px; + bottom: 34px; + width: min(300px, calc(100vw - 28px)); + padding: 12px; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 9px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .42); +} +.permission-confirm[hidden] { display: none; } +.permission-confirm-title { font-size: 12px; font-weight: 600; } +.permission-confirm p { margin: 7px 0 11px; color: var(--color-text-secondary); font-size: 10px; line-height: 1.5; } +.permission-confirm-actions { display: flex; justify-content: flex-end; gap: 6px; } +.permission-confirm-actions button { min-height: 27px; padding: 4px 10px; color: var(--color-text-secondary); background: transparent; border: 1px solid var(--color-border); border-radius: 5px; font-size: 10px; } +.permission-confirm-actions button:hover { color: var(--color-text); background: var(--color-surface-hover); } +.permission-confirm-actions button.primary { color: var(--color-background); background: var(--color-warning); border-color: var(--color-warning); } +.composer-popover > button { + width: 100%; + min-height: 30px; + padding: 5px 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--color-text-secondary); + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; +} +.composer-popover > button:hover, +.composer-popover > button[aria-checked="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.permission-menu > button > span { min-width: 0; font-size: 11px; } +.permission-menu > button > small { min-width: 0; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.approval-heading { margin-top: 4px; border-top: 1px solid var(--color-border); } +.usage-picker { position: relative; } +.usage-button { + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + display: inline-grid; + place-items: center; + color: var(--color-text-tertiary); + font-size: 9px; + font-variant-numeric: tabular-nums; + background: transparent; + border: 0; + border-radius: 5px; +} +.usage-button:hover, .usage-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.usage-ring { + --usage-percent: 0%; + position: relative; + width: 16px; + height: 16px; + display: inline-block; + background: conic-gradient(var(--color-info) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0); + border-radius: 50%; + transform: rotate(-90deg); +} +.usage-ring[data-level="warning"] { background: conic-gradient(var(--color-warning) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0); } +.usage-ring[data-level="critical"] { background: conic-gradient(var(--color-danger) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0); } +.usage-ring::after { + position: absolute; + inset: 3px; + content: ""; + background: var(--color-surface-secondary); + border-radius: 50%; +} +.usage-ring > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} +.usage-menu { right: 0; bottom: 31px; width: min(245px, calc(100vw - 28px)); } +.usage-summary { padding: 4px 8px 7px; color: var(--color-text-secondary); font-size: 11px; } +.usage-meter { height: 4px; margin: 0 8px 7px; overflow: hidden; background: var(--color-border); border-radius: 999px; } +.usage-meter span { display: block; width: 0; height: 100%; background: var(--color-info); border-radius: inherit; transition: width .2s ease; } +.usage-details { padding: 2px 8px 5px; color: var(--color-text-tertiary); font: 10px/1.45 var(--font-mono); white-space: pre-wrap; } +.composer-actions { align-items: center; display: flex; flex: 0 0 auto; gap: 5px; } +.model-picker { position: relative; } +.model-picker-button { + min-height: 26px; + padding: 2px 5px 2px 7px; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-text-secondary); + background: transparent; + border: 0; + border-radius: 6px; +} +.model-picker-button:hover, .model-picker-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.model-picker-button svg { width: 12px; height: 12px; } +.model-label { max-width: 110px; overflow: hidden; color: inherit; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.model-effort-label { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; } +.model-menu { + position: absolute; + z-index: 40; + right: -4px; + bottom: 34px; + width: min(280px, calc(100vw - 28px)); + max-height: min(58dvh, 440px); + padding: 7px; + overflow: auto; + color: var(--color-text); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, .32); +} +.model-menu[hidden] { display: none; } +.model-power-view { padding: 5px 4px 7px; } +.model-power-heading, +.model-advanced-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + color: var(--color-text-secondary); + font-size: 11px; +} +.model-advanced-toggle, +.model-advanced-back { + min-height: 25px; + padding: 3px 7px; + color: var(--color-text-tertiary); + font-size: 10px; + background: transparent; + border: 0; + border-radius: 5px; +} +.model-advanced-toggle:hover, +.model-advanced-toggle:focus-visible, +.model-advanced-back:hover, +.model-advanced-back:focus-visible { color: var(--color-text); background: var(--color-surface-hover); outline: none; } +.model-power-control { display: grid; grid-template-columns: auto minmax(82px, 1fr) auto; align-items: center; gap: 7px; min-height: 34px; } +.model-power-label { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; } +.model-power-slider { + width: 100%; + height: 24px; + margin: 0; + appearance: none; + background: color-mix(in srgb, var(--color-text-secondary) 10%, transparent); + border-radius: 12px; + outline: none; +} +.model-power-slider::-webkit-slider-runnable-track { height: 24px; background: transparent; border-radius: 12px; } +.model-power-slider::-webkit-slider-thumb { + width: 28px; + height: 28px; + margin-top: -2px; + appearance: none; + background: var(--color-text); + border: 1px solid var(--color-border-strong); + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, .3); +} +.model-power-slider::-moz-range-track { height: 24px; background: transparent; border-radius: 12px; } +.model-power-slider::-moz-range-thumb { + width: 26px; + height: 26px; + background: var(--color-text); + border: 1px solid var(--color-border-strong); + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, .3); +} +.model-power-slider:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-info) 65%, transparent); } +.model-power-slider:disabled { opacity: .45; } +.model-power-value { min-height: 16px; color: var(--color-text-tertiary); font-size: 10px; text-align: center; } +.model-advanced-view { padding: 2px 0 0; } +.model-advanced-toolbar { margin: 0 -1px 4px; padding: 0 3px 4px; border-bottom: 1px solid var(--color-border); } +.model-advanced-back { padding-inline: 5px; font-size: 17px; line-height: 1; } +.model-menu-heading { padding: 4px 8px; color: var(--color-text-tertiary); font-size: 10px; } +.model-options { display: grid; gap: 1px; } +.model-option { + width: 100%; + min-height: 40px; + padding: 6px 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + color: var(--color-text); + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; +} +.model-option:hover, .model-option[aria-selected="true"] { background: var(--color-surface-hover); } +.model-option-name { min-width: 0; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.model-option-description { grid-column: 1 / -1; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.model-option-check { color: var(--color-success); font-size: 11px; opacity: 0; } +.model-option[aria-selected="true"] .model-option-check { opacity: 1; } +.effort-heading { margin-top: 4px; border-top: 1px solid var(--color-border); padding-top: 7px; } +.effort-options { display: grid; gap: 1px; padding: 1px 3px 4px; } +.effort-option { + width: 100%; + min-width: 0; + min-height: 28px; + padding: 4px 7px; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--color-text-secondary); + font-size: 10px; + background: transparent; + border: 0; + border-radius: 5px; +} +.effort-option:hover, .effort-option[aria-selected="true"] { color: var(--color-text); background: var(--color-surface-hover); } +.effort-option-check { color: var(--color-success); opacity: 0; } +.effort-option[aria-selected="true"] .effort-option-check { opacity: 1; } +.model-picker[data-pending="true"] .model-picker-button { opacity: .62; } +.compact-action { width: 28px; height: 28px; min-height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; } +.compact-action svg { width: 15px; height: 15px; } +.interrupt-action { display: none; color: var(--color-danger); background: transparent; border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent); } +.interrupt-action:hover:not(:disabled) { background: color-mix(in srgb, var(--color-danger) 12%, transparent); } +.steer-action { display: none; } +.steer-action svg { stroke: currentColor; } +.send-button { + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: #1a1a1a; + background: #d4d4d4; + border: 0; + border-radius: 50%; +} +.send-button:hover:not(:disabled) { background: #fff; } +.send-button svg { width: 16px; height: 16px; stroke-width: 1.45; } +.mode-row { + display: flex; + pointer-events: auto; + align-items: center; + justify-content: space-between; + min-height: 26px; + gap: 8px; + padding: 5px 8px 0; + color: var(--color-text-secondary); + font-size: 10px; + background: var(--color-background); +} +.connection-mode-label { min-width: 0; display: inline-flex; align-items: center; gap: 5px; } +.connection-mode-label > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mode-icon { width: 15px; height: 15px; } +.control-mode-switch { + width: 82px; + height: 24px; + padding: 2px; + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + flex: 0 0 82px; + gap: 1px; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border); + border-radius: 5px; +} +.control-mode-switch button { + min-width: 0; + min-height: 18px; + padding: 0 4px; + overflow: hidden; + color: var(--color-text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 3px; + font-size: 9px; + line-height: 18px; +} +.control-mode-switch button:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); } +.control-mode-switch button[aria-pressed="true"] { + color: var(--color-text); + background: var(--color-background); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-border-strong) 72%, transparent); +} +.control-mode-switch button:focus-visible { outline: 1px solid var(--color-info); outline-offset: -1px; } +.control-mode-switch button:disabled { cursor: default; opacity: .46; } +.control-mode-switch button[aria-pressed="true"]:disabled { opacity: 1; } +.control-mode-switch[data-switching="true"] button[data-pending="true"] { color: var(--color-info); opacity: 1; } +.header-back-button[hidden], +.header-history-button[hidden], +.new-session-button[hidden], +.panel-menu button[hidden] { display: none; } +.thread-picker-button:disabled { cursor: default; opacity: 1; } +.thread-picker-button:disabled:hover { opacity: 1; } +body.turn-active .interrupt-action { display: inline-flex; } +body.turn-active .steer-action { display: inline-flex; } +body.turn-active #startTurnButton { display: none; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.compatibility-state { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@media (max-width: 640px) { + .codex-panel { border-inline: 0; } + .chat-scroll { padding-inline: 12px; } + .composer { right: 8px; bottom: 6px; left: 8px; } + .composer-surface { border-radius: 15px; } + .message.user .message-content { max-width: 77%; } + .request-id { display: none; } +} +@media (prefers-reduced-motion: reduce) { + .live-activity[data-active="true"] .activity-spinner { animation: none; } + .message.activity[data-status="inProgress"][data-kind="reasoning"] .message-details > summary { animation: none; } + .message-details > summary::after { transition: none; } + .activity-dots i, .scroll-working-dots i, .subagent-row[data-status="working"] .subagent-icon::before { animation: none; } +} diff --git a/aether-vscodex/relay/server.js b/aether-vscodex/relay/server.js new file mode 100644 index 000000000..d91c26d53 --- /dev/null +++ b/aether-vscodex/relay/server.js @@ -0,0 +1,2613 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { URL } = require("node:url"); +const { WebSocket, WebSocketServer } = require("ws"); + +const PACKAGE_ROOT = path.resolve(__dirname, ".."); +const VUE_PUBLIC_ROOT = path.join(PACKAGE_ROOT, "web", "dist"); +const PUBLIC_ROOT = fs.existsSync(path.join(VUE_PUBLIC_ROOT, "index.html")) + ? VUE_PUBLIC_ROOT + : path.join(PACKAGE_ROOT, "public"); +const MAX_JSON_BODY = 1024 * 1024; +// Complete attached-session snapshots include the structured message/tool +// projection and can easily exceed 256 KiB. This remains bounded to protect +// the local relay while allowing long Codex conversations to hydrate. +const MAX_WS_PAYLOAD = 16 * 1024 * 1024; +const DEFAULT_EVENT_LIMIT = 2_000; +// Transcript state is represented once in the authoritative control snapshot. +// Keep replay and socket buffering smaller than an unbounded count of maximum +// sized frames so one long conversation cannot amplify into gigabytes. +const DEFAULT_EVENT_BYTE_LIMIT = 16 * 1024 * 1024; +const DEFAULT_REPLAY_BYTE_LIMIT = 2 * 1024 * 1024; +const DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT = MAX_WS_PAYLOAD + 2 * 1024 * 1024; +const MAX_REPLAY_TEXT_BYTES = 64 * 1024; +const MUTATING_METHODS = new Set([ + "control/mode/set", + "thread/start", + "session/new", + "thread/settings/update", + "session/select", + "turn/start", + "turn/steer", + "turn/interrupt", +]); +const ALLOWED_METHODS = new Set(["initialize", "control/mode/get", "session/list", ...MUTATING_METHODS]); +const SERVER_REQUEST_METHODS = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "applyPatchApproval", + "execCommandApproval", +]); +const REMOTE_RESPONSE_METHODS = new Set([ + "approval.respond", + "input.respond", + "server.request.respond", +]); +const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; + +function randomToken() { + return crypto.randomBytes(24).toString("base64url"); +} + +function randomId(prefix) { + return `${prefix}_${crypto.randomBytes(9).toString("base64url")}`; +} + +// JSON-RPC treats numeric and string ids as distinct values. Keep that +// distinction in in-memory maps while retaining a small compatibility bridge +// for older browser clients that stringify numeric ids before responding. +function jsonRpcIdKey(id) { + if (typeof id === "number") { + return `number:${Object.is(id, -0) ? "-0" : String(id)}`; + } + if (typeof id === "string") return `string:${id}`; + if (id === null) return "null:"; + return `${typeof id}:${String(id)}`; +} + +function isJsonRpcId(id) { + return typeof id === "string" || typeof id === "number"; +} + +function findTypedMapKey(map, id, valueId = (value) => value?.appId, allowLegacyStringified = false) { + const exact = jsonRpcIdKey(id); + if (map.has(exact)) return exact; + // Before protocol v1, the browser console sent every request id as text. + // Allow that form only when it maps to one unambiguous pending id. If both + // `1` and `"1"` are pending, the exact typed key above wins and no cross-talk + // is possible. + if (!allowLegacyStringified || !isJsonRpcId(id)) return exact; + const text = String(id); + const candidates = []; + for (const [key, value] of map) { + const candidateId = valueId(value); + if (isJsonRpcId(candidateId) && String(candidateId) === text) candidates.push(key); + } + return candidates.length === 1 ? candidates[0] : exact; +} + +function sameJsonRpcId(left, right) { + return typeof left === typeof right && String(left) === String(right) + && (typeof left === "string" || typeof left === "number"); +} + +function responseCommandId(requestId, pendingServerRequests, pendingHostCommands) { + for (const [commandId, pending] of pendingHostCommands) { + if (pending.kind === "server-response" + && (sameJsonRpcId(pending.requestId, requestId) || sameJsonRpcId(pending.responseRequestId, requestId))) return commandId; + } + const base = `response-${String(requestId)}`; + let oppositeKey; + if (typeof requestId === "number") { + oppositeKey = jsonRpcIdKey(String(requestId)); + } else if (typeof requestId === "string") { + const numeric = Number(requestId); + if (Number.isFinite(numeric)) oppositeKey = jsonRpcIdKey(numeric); + } + if (oppositeKey && pendingServerRequests.has(oppositeKey)) return `${base}-${typeof requestId}`; + + const typed = `response-${jsonRpcIdKey(requestId)}`; + const existingBase = pendingHostCommands.get(base); + if (!existingBase || sameJsonRpcId(existingBase.requestId, requestId)) return base; + const existingTyped = pendingHostCommands.get(typed); + if (!existingTyped || sameJsonRpcId(existingTyped.requestId, requestId)) return typed; + // This is only reachable if a caller has manually occupied both stable + // names. Keep the command id deterministic and bounded while avoiding an + // accidental overwrite. + return `${typed}-${crypto.createHash("sha256").update(String(requestId)).digest("hex").slice(0, 8)}`; +} + +function secureEqual(left, right) { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function jsonResponse(response, statusCode, body, extraHeaders = {}) { + const payload = Buffer.from(JSON.stringify(body)); + response.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payload.length, + "Cache-Control": "no-store", + ...extraHeaders, + }); + response.end(payload); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + request.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_JSON_BODY) { + reject(Object.assign(new Error("request body is too large"), { statusCode: 413 })); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); + } catch { + reject(Object.assign(new Error("invalid JSON body"), { statusCode: 400 })); + } + }); + request.on("error", reject); + }); +} + +function redactString(value) { + return String(value) + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, "[REDACTED_API_KEY]") + .replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, "$1[REDACTED]") + .replace(/\b(gh[pousr]_[A-Za-z0-9]{20,})\b/g, "[REDACTED_GITHUB_TOKEN]") + .replace(/([?&](?:token|key|secret)=)[^&\s]+/gi, "$1[REDACTED]"); +} + +function redact(value, depth = 0) { + if (depth > 8) return "[TRUNCATED]"; + if (typeof value === "string") return redactString(value); + if (Array.isArray(value)) return value.map((entry) => redact(entry, depth + 1)); + if (value && typeof value === "object") { + const result = {}; + for (const [key, entry] of Object.entries(value)) { + if (/token|authorization|cookie|private.?key|secret/i.test(key)) { + result[key] = "[REDACTED]"; + } else { + result[key] = redact(entry, depth + 1); + } + } + return result; + } + return value; +} + +function applyStructuredMessagesPatch(current, patch) { + if (!Array.isArray(current) || !patch || typeof patch !== "object" || Array.isArray(patch)) return null; + const start = Number(patch.start); + const deleteCount = Number(patch.deleteCount); + if (!Number.isInteger(start) || start < 0 || start > current.length + || !Number.isInteger(deleteCount) || deleteCount < 0 || start + deleteCount > current.length + || !Array.isArray(patch.messages)) return null; + return [ + ...current.slice(0, start), + ...patch.messages, + ...current.slice(start + deleteCount), + ]; +} + +function positiveByteLimit(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function jsonByteLength(value) { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +function boundedReplayText(value) { + const encoded = Buffer.from(String(value), "utf8"); + if (encoded.length <= MAX_REPLAY_TEXT_BYTES) return String(value); + // A cut through a multi-byte code point can add one replacement character, + // which is harmless for this best-effort replay hint. The following control + // snapshot carries the exact authoritative transcript. + return encoded.subarray(encoded.length - MAX_REPLAY_TEXT_BYTES).toString("utf8"); +} + +// Every subscriber receives an authoritative control snapshot after replay. +// Keep transcript-bearing live events rich, but store only their lightweight +// form in the replay ring so streaming a long session cannot retain hundreds +// of duplicate full-history projections. +function compactTranscriptEventForReplay(event) { + if (!event || !["session.snapshot", "output.snapshot", "output.chunk"].includes(event.type)) return event; + const source = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload) + ? event.payload + : {}; + // Use an allow-list rather than deleting known large fields. In particular, + // current attach adapters send `messagesPatch` instead of `messages`, and a + // suffix replacement can itself be nearly as large as the full transcript. + const payload = { projectionInControlSnapshot: true }; + for (const key of [ + "threadId", + "turnId", + "requestId", + "source", + "sourceSeq", + "stream", + "encoding", + "state", + "structureChanged", + ]) { + const value = source[key]; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { + payload[key] = value; + } + } + if (event.type === "output.chunk" && typeof source.text === "string" && source.text) { + payload.text = boundedReplayText(source.text); + } + return { ...event, payload }; +} + +// Token usage is telemetry, not an authentication credential. The generic +// redactor intentionally treats any key containing "token" as sensitive, so +// preserve only the numeric usage projection after redacting the rest of a +// session metadata envelope. +const SAFE_USAGE_FIELDS = [ + "totalTokens", + "total_tokens", + "inputTokens", + "input_tokens", + "cachedInputTokens", + "cached_input_tokens", + "cacheWriteInputTokens", + "cache_write_input_tokens", + "outputTokens", + "output_tokens", + "reasoningOutputTokens", + "reasoning_output_tokens", +]; + +function safeUsageNumber(value) { + if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined; + if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : undefined; + } + return undefined; +} + +function safeUsageBreakdown(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + for (const field of SAFE_USAGE_FIELDS) { + const number = safeUsageNumber(value[field]); + if (number !== undefined) result[field.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())] = number; + } + return Object.keys(result).length ? result : undefined; +} + +function safeTokenUsage(value) { + if (value === null) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const source = value.info && typeof value.info === "object" ? value.info + : value.tokenUsage && typeof value.tokenUsage === "object" ? value.tokenUsage + : value.token_usage && typeof value.token_usage === "object" ? value.token_usage : value; + const result = {}; + const total = safeUsageBreakdown(source.total ?? source.total_token_usage ?? source.totalTokenUsage); + const last = safeUsageBreakdown(source.last ?? source.last_token_usage ?? source.lastTokenUsage); + const context = safeUsageNumber(source.modelContextWindow ?? source.model_context_window ?? source.contextWindow ?? source.context_window); + if (total) result.total = total; + if (last) result.last = last; + if (context !== undefined) result.modelContextWindow = context; + return Object.keys(result).length ? result : undefined; +} + +function redactSessionMetadata(value) { + const redacted = redact(value); + if (!redacted || typeof redacted !== "object" || Array.isArray(redacted) || !value || typeof value !== "object") return redacted; + for (const key of ["tokenUsage", "latestTokenUsageInfo"]) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + const usage = safeTokenUsage(value[key]); + if (usage !== undefined) redacted[key] = usage; + } + return redacted; +} + +function normalizeError(error) { + return { + code: error && error.code ? String(error.code) : "relay_error", + message: redactString(error && error.message ? error.message : String(error)), + retryable: Boolean(error && error.retryable), + }; +} + +function parsePort(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 && parsed < 65536 ? parsed : fallback; +} + +/** Parse an optional auth switch without treating an invalid value as false. */ +function parseAuthRequired(value) { + if (typeof value === "boolean") return value; + if (value === 1) return true; + if (value === 0) return false; + if (typeof value !== "string" || value.trim() === "") return undefined; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on", "required", "enabled"].includes(normalized)) return true; + if (["0", "false", "no", "off", "none", "disabled", "local"].includes(normalized)) return false; + return undefined; +} + +function hasConfiguredToken(value) { + return typeof value === "string" && value.length > 0; +} + +/** Whether the configured listen address is restricted to this machine. */ +function isLoopbackIPv4(value) { + if (net.isIP(value) !== 4) return false; + const octets = value.split(".").map(Number); + return octets.length === 4 && octets[0] === 127; +} + +function isLoopbackHost(host) { + const normalized = String(host || "").trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") return true; + if (isLoopbackIPv4(normalized)) return true; + return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length)); +} + +/** Node reports IPv4 loopback peers as both 127.x and ::ffff:127.x. */ +function isLoopbackAddress(address) { + const normalized = String(address || "").trim().toLowerCase(); + if (normalized === "::1") return true; + if (isLoopbackIPv4(normalized)) return true; + return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length)); +} + +function isLoopbackRequestHost(request) { + const rawHost = String(request.headers.host || "").trim(); + if (!rawHost) return false; + try { + return isLoopbackHost(new URL(`http://${rawHost}`).hostname); + } catch { + return false; + } +} + +/** Allow browser writes only from the relay's own origin; CLI requests omit Origin. */ +function isAllowedHttpOrigin(request) { + const origin = request.headers.origin; + if (!origin) return true; + const requestHost = String(request.headers.host || "").trim().toLowerCase(); + if (!requestHost) return false; + try { + return new URL(origin).host.toLowerCase() === requestHost; + } catch { + return false; + } +} + +function contentType(filePath) { + const extension = path.extname(filePath).toLowerCase(); + return ( + { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + }[extension] || "application/octet-stream" + ); +} + +function outputText(method, params) { + if (!params || typeof params !== "object") return ""; + const candidates = [params.delta, params.text, params.output, params.chunk, params.message]; + if (params.item && typeof params.item === "object") { + candidates.push(params.item.text, params.item.content); + } + const text = candidates.find((candidate) => typeof candidate === "string"); + if (text) return redactString(text); + if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) { + return redactString(JSON.stringify(params)); + } + return ""; +} + +function eventTypeForAppMessage(message) { + if (message.id !== undefined && message.method) { + if (message.method === "item/tool/requestUserInput" || message.method === "mcpServer/elicitation/request") { + return "input.requested"; + } + if (SERVER_REQUEST_METHODS.has(message.method)) return "approval.requested"; + return "server.requested"; + } + if (message.id !== undefined) return "app.response"; + const method = message.method || "unknown"; + if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) return "output.delta"; + if (method === "turn/started") return "turn.started"; + if (method === "turn/completed") return "turn.completed"; + if (method === "thread/started") return "thread.started"; + if (method === "error") return "app.error"; + return "app.notification"; +} + +function approvalDecisionForResult(result) { + if (result && typeof result === "object" && !Array.isArray(result)) { + const decision = result.decision; + if (typeof decision === "string") { + if (["acceptForSession", "accept", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(decision)) return "allow"; + if (["cancel", "abort"].includes(decision)) return "cancel"; + if (["decline", "denied", "timed_out"].includes(decision)) return "deny"; + } + // App-server v2 encodes policy amendments as tagged objects. Require one + // known tag with its documented shape; unknown or mixed objects fail + // closed instead of being interpreted as an approval. + const decisionKind = approvalDecisionKind(decision); + if (decisionKind) return decisionKind; + } + if (result && typeof result === "object" && !Array.isArray(result)) { + if (result.action === "accept") return "allow"; + if (result.action === "cancel") return "cancel"; + if (result.action === "decline") return "deny"; + if (result.permissions && typeof result.permissions === "object") { + return Object.keys(result.permissions).length ? "allow" : "deny"; + } + } + // A custom response is still sent to the bridge; this value is only the + // local policy hint used by RelayHost when it needs a canonical decision. + return "deny"; +} + +function approvalDecisionKind(value) { + if (typeof value === "string") { + if (["allow", "accept", "acceptForSession", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(value)) return "allow"; + if (["deny", "decline", "denied", "timed_out"].includes(value)) return "deny"; + if (["cancel", "abort"].includes(value)) return "cancel"; + return undefined; + } + const key = knownDecisionObjectKey(value); + if (key) return key === "denied" ? "deny" : "allow"; + return undefined; +} + +function responseErrorMessage(error) { + if (typeof error === "string") return redactString(error).slice(0, 1_000); + if (error && typeof error === "object" && typeof error.message === "string") return redactString(error.message).slice(0, 1_000); + return "remote response rejected"; +} + +function defaultServerResponse(method, reason = "request timed out") { + if (method === "item/permissions/requestApproval") return { permissions: {}, scope: "turn" }; + if (method === "item/tool/requestUserInput") return { answers: {} }; + if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; + if (method === "applyPatchApproval" || method === "execCommandApproval") { + return { decision: { denied: { rejection: reason } } }; + } + return { decision: "decline" }; +} + +function normalizeServerResponseForApp(method, response) { + response = normalizeApprovalResponseForApp(method, response); + if (method === "item/permissions/requestApproval") { + const source = isObjectPayload(response) ? response : {}; + const requested = isObjectPayload(source.permissions) ? source.permissions : {}; + const permissions = {}; + for (const [key, value] of Object.entries(requested)) { + if (value !== null && value !== undefined && isObjectPayload(value)) permissions[key] = value; + } + const normalized = { permissions, scope: source.scope === "session" ? "session" : "turn" }; + if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview; + return normalized; + } + if (method === "item/tool/requestUserInput") { + if (isObjectPayload(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response; + return { answers: isObjectPayload(response) ? response : {} }; + } + return response; +} + +const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]); + +function normalizeApprovalResponseForApp(method, response) { + const v2Approval = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + ]); + if (!LEGACY_APPROVAL_METHODS.has(method) && !v2Approval.has(method)) return response; + if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return response; + const decision = response.decision; + const legacy = LEGACY_APPROVAL_METHODS.has(method); + let normalized = decision; + if (typeof decision === "string") { + if (legacy) { + if (["allow", "accept", "approved"].includes(decision)) normalized = "approved"; + else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "approved_for_session"; + else if (["deny", "decline", "denied"].includes(decision)) { + normalized = { denied: { rejection: "Denied remotely" } }; + } else if (["cancel", "abort"].includes(decision)) normalized = "abort"; + else if (decision === "timed_out") normalized = "timed_out"; + } else { + if (["allow", "accept", "approved"].includes(decision)) normalized = "accept"; + else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "acceptForSession"; + else if (["deny", "decline", "denied", "timed_out"].includes(decision)) normalized = "decline"; + else if (["cancel", "abort"].includes(decision)) normalized = "cancel"; + else if (decision === "approved_mcp_policy_amendment") normalized = "accept"; + } + } else if (knownDecisionObjectKey(decision)) { + const decisionKey = knownDecisionObjectKey(decision); + if (legacy && decisionKey === "acceptWithExecpolicyAmendment") { + const value = decision.acceptWithExecpolicyAmendment; + normalized = { approved_execpolicy_amendment: { proposed_execpolicy_amendment: value?.execpolicy_amendment ?? value } }; + } else if (legacy && decisionKey === "applyNetworkPolicyAmendment") { + const value = decision.applyNetworkPolicyAmendment; + normalized = { network_policy_amendment: { network_policy_amendment: value?.network_policy_amendment ?? value } }; + } else if (!legacy && decisionKey === "approved_execpolicy_amendment") { + const value = decision.approved_execpolicy_amendment; + normalized = { acceptWithExecpolicyAmendment: { execpolicy_amendment: value?.proposed_execpolicy_amendment ?? value } }; + } else if (!legacy && decisionKey === "network_policy_amendment") { + const value = decision.network_policy_amendment; + normalized = { applyNetworkPolicyAmendment: { network_policy_amendment: value?.network_policy_amendment ?? value } }; + } else if (!legacy && decisionKey === "denied") { + normalized = "decline"; + } + } + return normalized === decision ? response : { ...response, decision: normalized }; +} + +function isValidApprovalResponse(method, response) { + const legacy = LEGACY_APPROVAL_METHODS.has(method); + const v2 = method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval"; + if (!legacy && !v2) return true; + if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return false; + const decision = response.decision; + if (typeof decision === "string") { + return legacy + ? ["approved", "approved_for_session", "approved_mcp_policy_amendment", "timed_out", "abort"].includes(decision) + : ["accept", "acceptForSession", "decline", "cancel"].includes(decision); + } + if (!decision || typeof decision !== "object" || Array.isArray(decision)) return false; + const key = knownDecisionObjectKey(decision); + return legacy + ? key === "approved_execpolicy_amendment" || key === "network_policy_amendment" || key === "denied" + : key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"; +} + +function knownDecisionObjectKey(value) { + if (!isObjectPayload(value)) return undefined; + const keys = Object.keys(value); + if (keys.length !== 1) return undefined; + const key = keys[0]; + const nested = value[key]; + if (key === "acceptWithExecpolicyAmendment") { + return isObjectPayload(nested) + && Object.keys(nested).every((field) => field === "execpolicy_amendment") + && isStringArray(nested.execpolicy_amendment) ? key : undefined; + } + if (key === "approved_execpolicy_amendment") { + return isObjectPayload(nested) + && Object.keys(nested).every((field) => field === "proposed_execpolicy_amendment") + && isStringArray(nested.proposed_execpolicy_amendment) ? key : undefined; + } + if (key === "applyNetworkPolicyAmendment") { + return isObjectPayload(nested) + && Object.keys(nested).every((field) => field === "network_policy_amendment") + && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined; + } + if (key === "network_policy_amendment") { + return isObjectPayload(nested) + && Object.keys(nested).every((field) => field === "network_policy_amendment") + && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined; + } + if (key === "denied") { + return isObjectPayload(nested) + && Object.keys(nested).every((field) => field === "rejection") + && typeof nested.rejection === "string" ? key : undefined; + } + return undefined; +} + +function isStringArray(value) { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function isNetworkPolicyAmendment(value) { + return isObjectPayload(value) + && Object.keys(value).every((field) => field === "host" || field === "action") + && typeof value.host === "string" + && (value.action === "allow" || value.action === "deny"); +} + +class CodexRelay { + constructor(options = {}) { + this.host = options.host || process.env.HOST || "127.0.0.1"; + this.port = parsePort(options.port ?? process.env.PORT, 8787); + this.eventLimit = positiveByteLimit( + options.eventLimit ?? process.env.CODEX_REMOTE_EVENT_LIMIT, + DEFAULT_EVENT_LIMIT, + ); + this.eventByteLimit = positiveByteLimit( + options.eventByteLimit ?? process.env.CODEX_REMOTE_EVENT_BYTE_LIMIT, + DEFAULT_EVENT_BYTE_LIMIT, + ); + this.replayByteLimit = positiveByteLimit( + options.replayByteLimit ?? process.env.CODEX_REMOTE_REPLAY_BYTE_LIMIT, + DEFAULT_REPLAY_BYTE_LIMIT, + ); + this.clientBufferedByteLimit = positiveByteLimit( + options.clientBufferedByteLimit ?? process.env.CODEX_REMOTE_CLIENT_BUFFERED_BYTE_LIMIT, + DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT, + ); + const tokenConfigured = hasConfiguredToken(options.operatorToken) + || hasConfiguredToken(options.viewerToken) + || hasConfiguredToken(options.hostToken) + || hasConfiguredToken(process.env.CODEX_REMOTE_TOKEN) + || hasConfiguredToken(process.env.CODEX_REMOTE_VIEW_TOKEN) + || hasConfiguredToken(process.env.CODEX_REMOTE_HOST_TOKEN); + const explicitAuthRequired = Object.prototype.hasOwnProperty.call(options, "authRequired") + ? parseAuthRequired(options.authRequired) + : parseAuthRequired(process.env.CODEX_REMOTE_AUTH) + ?? parseAuthRequired(process.env.CODEX_REMOTE_AUTH_REQUIRED); + // A loopback relay is a local development tool by default. The moment a + // token is configured, or the relay binds a non-loopback address, retain + // the authenticated behavior. `authRequired` can explicitly enable auth + // for a local relay; disabling it is intentionally limited to loopback. + this.authRequired = explicitAuthRequired ?? (tokenConfigured || !isLoopbackHost(this.host)); + if (!this.authRequired && !isLoopbackHost(this.host)) this.authRequired = true; + this.operatorToken = options.operatorToken || process.env.CODEX_REMOTE_TOKEN || randomToken(); + this.viewerToken = options.viewerToken || process.env.CODEX_REMOTE_VIEW_TOKEN || randomToken(); + this.hostToken = options.hostToken || process.env.CODEX_REMOTE_HOST_TOKEN || this.operatorToken; + const configuredApprovalTimeout = Number(options.approvalTimeoutMs ?? process.env.CODEX_REMOTE_APPROVAL_TIMEOUT_MS); + this.approvalTimeoutMs = Number.isFinite(configuredApprovalTimeout) && configuredApprovalTimeout >= 0 + ? configuredApprovalTimeout + : DEFAULT_APPROVAL_TIMEOUT_MS; + this.generatedOperatorToken = !options.operatorToken && !process.env.CODEX_REMOTE_TOKEN; + this.generatedViewerToken = !options.viewerToken && !process.env.CODEX_REMOTE_VIEW_TOKEN; + this.codexCommand = options.codexCommand || process.env.CODEX_BIN || "codex"; + this.codexArgs = options.codexArgs || this.readCodexArgs(); + this.codexCwd = options.codexCwd || process.env.CODEX_CWD || process.cwd(); + const spawnConfigured = options.spawnCodex === true + || process.env.CODEX_SPAWN === "true" + || process.env.CODEX_SPAWN === "1"; + // Attaching to the already-open VS Code Codex session is the safe default. + // Keep the standalone app-server path available only when it is explicit. + this.mode = options.mode + || process.env.CODEX_REMOTE_MODE + || (options.spawnCodex === false || process.env.CODEX_SPAWN === "false" + ? "host" + : spawnConfigured ? "embedded" : "host"); + this.spawnCodex = this.mode === "host" + ? false + : options.spawnCodex !== undefined + ? options.spawnCodex !== false + : process.env.CODEX_SPAWN !== "false"; + this.events = []; + this.eventSizes = []; + this.eventBytes = 0; + this.audit = []; + this.clients = new Set(); + // At most one outbound VS Code host is active for this MVP session. A + // host is optional: when absent, the relay can run its embedded stdio + // app-server. When present, browser commands are proxied to the host. + this.hostClient = null; + this.pendingHostCommands = new Map(); + this.pendingAppRequests = new Map(); + this.pendingServerRequests = new Map(); + this.commandResults = new Map(); + // Command idempotency is scoped to the embedded app or to one stable host + // session. Keep the scope metadata separate so it never crosses the wire. + this.commandResultScopes = new Map(); + this.hostCommandScope = null; + this.nextSeq = 0; + this.appRequestCounter = 0; + this.appBuffer = ""; + this.appProcess = null; + this.appGeneration = 0; + this.appTerminalGeneration = 0; + this.initializedResult = null; + this.state = { + app: this.spawnCodex ? "starting" : "waiting_for_host", + initialized: false, + activeThreadId: null, + activeTurnId: null, + cwd: this.codexCwd, + outputTail: "", + messages: [], + subagents: [], + // Keep non-sensitive session metadata available for browsers that join + // after the adapter's original session.snapshot event was replayed. + sessionMetadata: null, + // Typed turn/activity projection from the attached VS Code host. Keep + // this in the relay snapshot so a browser that connects after the last + // event still knows whether the conversation is thinking, editing, or + // waiting for approval. + executionStatus: null, + lastError: null, + mode: this.mode, + authRequired: this.authRequired, + hostConnected: false, + hostSessionId: null, + }; + } + + readCodexArgs() { + if (!process.env.CODEX_ARGS_JSON) return ["app-server", "--stdio"]; + try { + const args = JSON.parse(process.env.CODEX_ARGS_JSON); + if (!Array.isArray(args) || !args.every((entry) => typeof entry === "string")) throw new Error(); + return args; + } catch { + throw new Error("CODEX_ARGS_JSON must be a JSON array of strings"); + } + } + + async start() { + this.httpServer = http.createServer((request, response) => this.handleHttp(request, response)); + this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD }); + this.wsServer.on("connection", (socket, request) => this.handleConnection(socket, request)); + this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head)); + + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + this.httpServer.once("error", onError); + this.httpServer.listen(this.port, this.host, () => { + this.httpServer.off("error", onError); + resolve(); + }); + }); + + if (this.spawnCodex) this.startCodex(); + return this.address(); + } + + address() { + const address = this.httpServer.address(); + if (!address || typeof address === "string") return { host: this.host, port: this.port }; + return { host: address.address, port: address.port }; + } + + async stop() { + for (const client of this.clients) client.socket.close(1001, "relay shutting down"); + this.clients.clear(); + this.hostClient = null; + this.pendingHostCommands.clear(); + this.pendingAppRequests.clear(); + this.commandResults.clear(); + this.commandResultScopes.clear(); + this.hostCommandScope = null; + for (const pending of this.pendingServerRequests.values()) { + if (pending.timer) clearTimeout(pending.timer); + } + this.pendingServerRequests.clear(); + if (this.appProcess && !this.appProcess.killed) this.appProcess.kill("SIGTERM"); + if (this.wsServer) await new Promise((resolve) => this.wsServer.close(() => resolve())); + if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve())); + } + + startCodex() { + if (this.appProcess && this.appProcess.exitCode === null && !this.appProcess.killed) return; + this.state.app = "starting"; + const child = spawn(this.codexCommand, this.codexArgs, { + cwd: this.codexCwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const generation = ++this.appGeneration; + this.appProcess = child; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (this.appProcess !== child || this.appGeneration !== generation) return; + this.consumeAppOutput(chunk); + }); + child.stderr.on("data", (chunk) => { + if (this.appProcess !== child || this.appGeneration !== generation) return; + const text = redactString(chunk).slice(0, 32_000); + this.recordEvent("app.stderr", { stream: "stderr", text }); + }); + child.stdin.on("error", (error) => { + if (this.appProcess !== child || this.appGeneration !== generation) return; + this.handleAppProcessExit(child, generation, { error }); + }); + child.on("error", (error) => { + this.handleAppProcessExit(child, generation, { error }); + }); + child.on("exit", (code, signal) => { + this.handleAppProcessExit(child, generation, { code, signal }); + }); + + this.state.app = "initializing"; + const initId = this.nextAppRequestId("initialize"); + this.pendingAppRequests.set(jsonRpcIdKey(initId), { kind: "initialize", method: "initialize" }); + try { + this.sendToApp({ + method: "initialize", + id: initId, + params: { + clientInfo: { + name: "codex-remote-collab", + title: "Codex Remote Collab", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + }, + }); + } catch (error) { + this.handleAppProcessExit(child, generation, { error }); + } + } + + handleAppProcessExit(child, generation, details = {}) { + if (this.appProcess !== child || this.appGeneration !== generation) return; + // ChildProcess can emit both `error` and `exit`; process one terminal + // transition so pending commands/requests are settled exactly once. + if (this.appTerminalGeneration === generation) return; + this.appTerminalGeneration = generation; + this.appProcess = null; + this.appBuffer = ""; + + const previousThreadId = this.state.activeThreadId; + const error = details.error; + const code = details.code; + const signal = details.signal; + const reason = error?.message + || `Codex app-server exited (code=${String(code)}, signal=${String(signal)})`; + const terminalError = { + code: error?.code ? String(error.code) : "app_exited", + message: redactString(reason), + retryable: true, + }; + this.state.app = "offline"; + this.state.initialized = false; + this.state.activeThreadId = null; + this.state.activeTurnId = null; + this.state.lastError = terminalError; + // Results from a dead app-server cannot safely be reused after a restart: + // a command may have applied side effects before the process crashed. + this.clearCommandResults(); + this.recordEvent("app.exited", error + ? { error: terminalError } + : { code, signal, error: terminalError }); + + const pendingCommands = [...this.pendingAppRequests.values()]; + this.pendingAppRequests.clear(); + for (const pending of pendingCommands) { + if (!pending.commandId) continue; + const payload = { + commandId: pending.commandId, + method: pending.method || null, + ok: false, + uncertain: true, + retryable: true, + error: terminalError, + }; + this.cacheCommandResult(pending.commandId, payload, "embedded"); + const event = this.recordEvent("command.result", payload); + if (pending.client) { + this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq }); + } + } + + const pendingRequests = [...this.pendingServerRequests.values()]; + for (const pending of pendingRequests) { + this.clearPendingServerRequest(pending.appId); + const isInput = pending.method === "item/tool/requestUserInput" + || pending.method === "mcpServer/elicitation/request"; + this.recordEvent(isInput ? "input.expired" : "approval.expired", { + requestId: pending.appId, + method: pending.method, + reason: "Codex app-server exited", + error: terminalError, + }, { sessionId: previousThreadId || undefined }); + } + } + + nextAppRequestId(label) { + this.appRequestCounter += 1; + return `relay-${label}-${this.appRequestCounter}`; + } + + consumeAppOutput(chunk) { + this.appBuffer += chunk; + while (true) { + const newline = this.appBuffer.indexOf("\n"); + if (newline < 0) break; + const line = this.appBuffer.slice(0, newline).trim(); + this.appBuffer = this.appBuffer.slice(newline + 1); + if (!line) continue; + try { + this.handleAppMessage(JSON.parse(line)); + } catch (error) { + this.recordEvent("app.parse_error", { + error: normalizeError(error), + line: redactString(line).slice(0, 2_000), + }); + } + } + } + + handleAppMessage(rawMessage) { + const message = redact(rawMessage); + if (message.id !== undefined && message.method) { + const requestId = rawMessage.id; + this.clearPendingServerRequest(requestId); + const pending = { + appId: requestId, + method: message.method, + params: message.params || {}, + createdAt: Date.now(), + }; + this.scheduleServerRequestExpiry(requestId, pending); + this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending); + this.recordEvent(eventTypeForAppMessage(message), { + requestId, + method: message.method, + params: message.params || {}, + }); + return; + } + + if (message.id !== undefined) { + const key = jsonRpcIdKey(rawMessage.id); + const pending = this.pendingAppRequests.get(key); + this.pendingAppRequests.delete(key); + + if (pending && pending.kind === "initialize") { + if (message.result) { + this.initializedResult = message.result; + this.state.app = "ready"; + this.state.initialized = true; + // The app-server handshake is ordered: initialized follows the + // successful initialize response. Some versions reject an early + // notification or process it before capabilities are established. + try { + this.sendToApp({ method: "initialized", params: {} }); + } catch (error) { + this.state.app = "error"; + this.state.initialized = false; + this.state.lastError = normalizeError(error); + } + this.recordEvent("app.ready", { result: message.result }); + } else { + this.state.app = "error"; + this.state.lastError = message.error || { message: "Codex initialization failed" }; + this.recordEvent("app.error", { error: this.state.lastError }); + } + return; + } + + if (pending && pending.method === "thread/start" && message.result?.thread?.id) { + this.state.activeThreadId = message.result.thread.id; + this.state.cwd = message.result.cwd || this.state.cwd; + } + if (pending && pending.method === "turn/start" && message.result?.turn?.id) { + this.state.activeTurnId = message.result.turn.id; + } + + const commandPayload = { + commandId: pending?.commandId || null, + method: pending?.method || null, + ok: !message.error, + result: message.result, + error: message.error, + }; + if (pending?.commandId) { + this.cacheCommandResult(pending.commandId, commandPayload, "embedded"); + } + const event = this.recordEvent("command.result", commandPayload); + if (pending?.client) this.sendControl(pending.client, { ...commandPayload, type: "command.result", seq: event.seq }); + return; + } + + this.updateStateFromNotification(message); + this.recordEvent(eventTypeForAppMessage(message), { + method: message.method || "unknown", + params: message.params || {}, + text: outputText(message.method || "", message.params), + emittedAtMs: message.emittedAtMs, + }); + } + + updateStateFromNotification(message) { + const params = message.params || {}; + if (message.method === "thread/started" && params.thread?.id) this.state.activeThreadId = params.thread.id; + if (message.method === "turn/started" && params.turn?.id) this.state.activeTurnId = params.turn.id; + if (message.method === "turn/completed" && (!params.turn?.id || params.turn.id === this.state.activeTurnId)) { + this.state.activeTurnId = null; + } + } + + sendToApp(message) { + if (!this.appProcess + || this.appProcess.exitCode !== null + || this.appProcess.killed + || !this.appProcess.stdin + || this.appProcess.stdin.destroyed + || !this.appProcess.stdin.writable) { + throw Object.assign(new Error("Codex app-server is offline"), { + code: "app_offline", + retryable: true, + }); + } + this.appProcess.stdin.write(`${JSON.stringify(message)}\n`); + } + + recordEvent(type, payload, options = {}) { + const event = { + v: 1, + kind: "event", + id: randomId("evt"), + seq: ++this.nextSeq, + ts: new Date().toISOString(), + type, + sessionId: options.sessionId || this.state.activeThreadId, + payload: redact(payload), + }; + const replayEvent = compactTranscriptEventForReplay(event); + const replayBytes = jsonByteLength(replayEvent); + // An individual event that exceeds the entire replay budget is still + // delivered live and represented by the authoritative state snapshot. Do + // not let it make the in-memory ring exceed its configured hard bound. + if (replayBytes <= this.eventByteLimit) { + this.events.push(replayEvent); + this.eventSizes.push(replayBytes); + this.eventBytes += replayBytes; + } + while (this.events.length > this.eventLimit || this.eventBytes > this.eventByteLimit) { + this.events.shift(); + this.eventBytes -= this.eventSizes.shift() || 0; + } + for (const client of this.clients) { + if (client.authenticated && client.subscribed && client !== options.excludeClient) this.sendControl(client, event); + } + return event; + } + + auditAction(client, action, details, outcome) { + this.audit.push({ + id: randomId("audit"), + ts: new Date().toISOString(), + actor: client?.id || "http", + role: client?.role || "unknown", + action, + details: redact(details), + outcome, + }); + if (this.audit.length > 500) this.audit.splice(0, this.audit.length - 500); + } + + handleUpgrade(request, socket, head) { + let requestUrl; + try { + requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`); + } catch { + socket.destroy(); + return; + } + if (requestUrl.pathname !== "/ws" && requestUrl.pathname !== "/v1/connect") { + socket.destroy(); + return; + } + if (!this.authRequired && !isLoopbackRequestHost(request)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + const origin = request.headers.origin; + if (origin) { + try { + if (new URL(origin).host.toLowerCase() !== String(request.headers.host || "").toLowerCase()) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + } catch { + socket.destroy(); + return; + } + } + this.wsServer.handleUpgrade(request, socket, head, (webSocket) => { + this.wsServer.emit("connection", webSocket, request); + }); + } + + handleConnection(socket, request) { + const client = { + id: randomId("client"), + socket, + role: null, + authenticated: false, + subscribed: false, + clientType: null, + sessionId: null, + commandScope: null, + lastSeq: 0, + remoteAddress: request.socket.remoteAddress, + }; + this.clients.add(client); + const authTimer = setTimeout(() => { + if (!client.authenticated) socket.close(1008, "authentication required"); + }, 10_000); + authTimer.unref(); + + socket.on("message", (data, isBinary) => { + if (isBinary) { + socket.close(1003, "JSON text frames only"); + return; + } + let message; + try { + message = JSON.parse(data.toString("utf8")); + } catch { + this.sendControl(client, { type: "error", code: "invalid_json", message: "Invalid JSON frame" }); + return; + } + if (!message || typeof message !== "object" || Array.isArray(message)) { + this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" }); + return; + } + try { + this.handleClientMessage(client, message); + } catch (error) { + this.recordEvent("relay.error", { clientId: client.id, error: normalizeError(error) }); + this.sendControl(client, { type: "error", ...normalizeError(error) }); + } + }); + socket.on("close", () => { + clearTimeout(authTimer); + this.clients.delete(client); + if (this.hostClient === client) { + this.hostClient = null; + this.state.hostConnected = false; + this.state.hostSessionId = null; + this.state.app = "offline"; + this.state.initialized = false; + this.state.activeThreadId = null; + this.state.activeTurnId = null; + this.state.lastError = { code: "host_disconnected", message: "VS Code host disconnected" }; + this.recordEvent("host.disconnected", { clientId: client.id }, { excludeClient: client }); + // Commands waiting on a host cannot be completed after its socket is + // gone. Keep their ids reserved briefly so retries get a clear error. + for (const [commandId, pending] of this.pendingHostCommands) { + this.pendingHostCommands.delete(commandId); + this.cacheCommandResult(commandId, { + commandId, + method: pending.method, + ok: false, + uncertain: true, + error: { code: "host_disconnected", message: "VS Code host disconnected" }, + }, pending.commandScope || client.commandScope || this.hostCommandScope); + if (pending.client) { + if (pending.kind === "server-response") { + this.sendControl(pending.client, { + type: "response.rejected", + requestId: pending.responseRequestId ?? pending.requestId, + code: "host_disconnected", + message: "VS Code host disconnected", + retryable: true, + }); + } else { + this.sendControl(pending.client, { + type: "command.result", + commandId, + method: pending.method, + ok: false, + uncertain: true, + error: { code: "host_disconnected", message: "VS Code host disconnected" }, + }); + } + } + } + for (const pending of this.pendingServerRequests.values()) { + if (pending.source !== "host" || pending.hostClient !== client) continue; + this.clearPendingServerRequest(pending.appId); + this.recordEvent( + pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request" + ? "input.expired" + : "approval.expired", + { requestId: pending.appId, method: pending.method, reason: "VS Code host disconnected" }, + { sessionId: client.sessionId || undefined }, + ); + } + } + if (client.authenticated) this.recordEvent("presence.changed", { clientId: client.id, state: "offline" }); + }); + socket.on("error", () => {}); + } + + roleForToken(token) { + if (secureEqual(token, this.operatorToken)) return "operator"; + if (secureEqual(token, this.viewerToken)) return "viewer"; + return null; + } + + handleClientMessage(client, message) { + if (!message || typeof message !== "object" || Array.isArray(message)) { + this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" }); + return; + } + if (!client.authenticated) { + // The VS Code bridge sends a hello frame before its auth frame. Keep the + // hello unauthenticated but remember the client kind and resume cursor. + const localConnection = !this.authRequired && isLoopbackAddress(client.remoteAddress); + const localNoAuthHandshake = localConnection + && (message.kind === "hello" || message.kind === "auth" || message.type === "auth"); + let token = null; + if (message.kind === "hello") { + if (message.protocol !== undefined && Number(message.protocol) !== 1) { + this.sendControl(client, { type: "error", code: "unsupported_protocol", message: "Only protocol 1 is supported" }); + client.socket.close(1002, "unsupported protocol"); + return; + } + client.clientType = message.clientType === "host" ? "host" : "web"; + client.sessionId = typeof message.sessionId === "string" ? message.sessionId : null; + client.lastSeq = Number.isFinite(Number(message.lastSeq)) ? Number(message.lastSeq) : 0; + token = typeof message.token === "string" + ? message.token + : typeof message.accessToken === "string" + ? message.accessToken + : null; + if (!token && !localConnection) return; + } else { + token = message.type === "auth" && typeof message.token === "string" + ? message.token + : message.kind === "auth" && typeof message.accessToken === "string" + ? message.accessToken + : message.kind === "auth" && typeof message.token === "string" + ? message.token + : null; + } + if (!token && !localNoAuthHandshake) { + client.socket.close(1008, "authentication required"); + return; + } + const isHost = client.clientType === "host"; + const role = localNoAuthHandshake + ? (isHost ? "host" : "operator") + : (isHost && secureEqual(token, this.hostToken) ? "operator" : this.roleForToken(token)); + if (!role || (!localNoAuthHandshake && isHost && !secureEqual(token, this.hostToken))) { + this.auditAction(client, "authenticate", {}, "denied"); + client.socket.close(1008, "invalid token"); + return; + } + client.authenticated = true; + client.clientType = client.clientType || "web"; + client.role = isHost ? "host" : role; + if (client.clientType === "host") { + if (this.mode === "embedded") { + this.sendControl(client, { type: "error", code: "host_mode_disabled", message: "Start relay with CODEX_REMOTE_MODE=host (or CODEX_SPAWN=false) for a VS Code host" }); + client.socket.close(1008, "host mode disabled"); + return; + } + if (this.hostClient && this.hostClient !== client) { + this.sendControl(client, { type: "error", code: "host_already_connected", message: "A VS Code host is already connected" }); + client.socket.close(1008, "host already connected"); + return; + } + const hostSessionId = typeof client.sessionId === "string" && client.sessionId.length > 0 + ? client.sessionId + : null; + const commandScope = hostSessionId ? `session:${hostSessionId}` : `connection:${client.id}`; + // A command id is only idempotent within the same host session. A + // reconnect with the same stable session id may reuse the cache; + // another session must never inherit old results (including uncertain + // disconnect results). + if (this.hostCommandScope !== null && this.hostCommandScope !== commandScope) { + this.clearCommandResults(); + } + client.commandScope = commandScope; + this.hostCommandScope = commandScope; + if (this.state.hostSessionId && this.state.hostSessionId !== client.sessionId) { + this.state.activeThreadId = null; + this.state.activeTurnId = null; + } + this.hostClient = client; + this.state.hostConnected = true; + this.state.hostSessionId = client.sessionId; + this.recordEvent("host.connected", { clientId: client.id, sessionId: client.sessionId }, { excludeClient: client }); + } + this.auditAction(client, "authenticate", {}, "accepted"); + this.sendControl(client, { + type: "auth.ok", + clientId: client.id, + role: client.role, + clientType: client.clientType, + protocol: 1, + authRequired: this.authRequired, + latestSeq: this.nextSeq, + }); + return; + } + + // Host frames use the versioned relay contract; browser frames use the + // compact `type` contract. Host events are ingested and re-sequenced here + // instead of echoed back to the host. + if (message.kind === "hello") { + const announcedType = message.clientType === "host" ? "host" : "web"; + if (announcedType !== client.clientType) { + this.sendControl(client, { type: "error", code: "client_type_immutable", message: "clientType cannot change after authentication" }); + return; + } + client.sessionId = typeof message.sessionId === "string" ? message.sessionId : client.sessionId; + return; + } + if (message.kind === "auth" || message.type === "auth") { + return; + } + if (message.kind === "ack") return; + if (message.kind === "event" && client.clientType === "host") { + this.ingestHostEvent(client, message); + return; + } + + if (message.type === "subscribe") { + this.subscribe(client, Number(message.fromSeq || 0)); + return; + } + if (message.type === "ping") { + this.sendControl(client, { type: "pong", ts: new Date().toISOString(), latestSeq: this.nextSeq }); + return; + } + // Browser clients historically used compact `{type:"command", method, + // params}` / `{type:"respond", requestId, result}` frames. The bridge + // contract is versioned and uses `{kind:"command", type, payload}` (and + // approval/input response command names). Normalize both forms at this + // boundary; host clients are event producers and must not issue relay + // commands back into themselves. + if (client.clientType !== "host") { + const command = normalizeBrowserCommand(message); + if (command) { + if (REMOTE_RESPONSE_METHODS.has(command.method)) { + this.dispatchServerResponse(client, normalizeBrowserResponse(message, command.method)); + } else { + this.dispatchCommand(client, command); + } + return; + } + const response = normalizeBrowserResponse(message); + if (response) { + this.dispatchServerResponse(client, response); + return; + } + } + this.sendControl(client, { type: "error", code: "unknown_frame", message: "Unknown frame type" }); + } + + subscribe(client, fromSeq) { + const firstAvailable = this.events.length ? this.events[0].seq : this.nextSeq + 1; + const replayEvents = []; + let replayBytes = 0; + let replayTooLarge = false; + if (fromSeq + 1 >= firstAvailable) { + for (const event of this.events) { + if (event.seq <= fromSeq) continue; + const size = jsonByteLength(event); + if (replayBytes + size > this.replayByteLimit) { + replayTooLarge = true; + break; + } + replayEvents.push(event); + replayBytes += size; + } + } + if (fromSeq + 1 < firstAvailable || replayTooLarge) { + this.sendControl(client, { + type: "resync.required", + requestedFromSeq: fromSeq, + firstAvailableSeq: firstAvailable, + ...(replayTooLarge ? { reason: "replay_too_large" } : {}), + }); + } else { + for (const event of replayEvents) this.sendControl(client, event); + } + client.subscribed = true; + // Other subscribers need the presence transition, while the joining + // client receives the same fact in the clients list of its snapshot. Add + // it first so `latestSeq` covers every authoritative state transition. + this.recordEvent("presence.changed", { clientId: client.id, role: client.role, state: "online" }, { excludeClient: client }); + this.sendControl(client, { type: "session.snapshot", snapshot: this.snapshot() }); + } + + ingestHostEvent(client, frame) { + const sourceType = typeof frame.type === "string" ? frame.type : "app.notification"; + const sourcePayload = frame.payload && typeof frame.payload === "object" ? frame.payload : {}; + const sourceSeq = Number.isFinite(Number(frame.seq)) ? Number(frame.seq) : undefined; + const sessionId = client.sessionId || frame.sessionId || this.state.hostSessionId || undefined; + + const executionStatus = sourcePayload.executionStatus && typeof sourcePayload.executionStatus === "object" + ? sourcePayload.executionStatus + : frame.status && typeof frame.status === "object" + ? frame.status + : sourcePayload.status && typeof sourcePayload.status === "object" + ? sourcePayload.status + : null; + if (executionStatus) this.state.executionStatus = redact(executionStatus); + + // Keep the browser snapshot useful even though the host deliberately uses + // a normalized event vocabulary instead of raw app-server notifications. + if (sourceType === "session.created" && sourcePayload.thread && typeof sourcePayload.thread === "object") { + const id = sourcePayload.thread.id; + if (typeof id === "string") this.state.activeThreadId = id; + } + if (sourceType === "session.snapshot") { + const threadId = sourcePayload.threadId || sourcePayload.thread?.id; + const turnId = sourcePayload.turnId || sourcePayload.turn?.id; + if (typeof threadId === "string") this.state.activeThreadId = threadId; + if (typeof turnId === "string") this.state.activeTurnId = turnId; + if (sourcePayload.threadId === null || sourcePayload.thread === null) this.state.activeThreadId = null; + if (sourcePayload.turnId === null || sourcePayload.turn === null) this.state.activeTurnId = null; + this.state.app = "ready"; + this.state.initialized = true; + this.state.lastError = null; + if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail; + if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; + if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { + const metadata = sourcePayload.metadata; + this.state.sessionMetadata = redactSessionMetadata({ + ...(typeof metadata.title === "string" ? { title: metadata.title } : {}), + ...(typeof metadata.name === "string" ? { name: metadata.name } : {}), + ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), + ...(typeof metadata.mode === "string" ? { mode: metadata.mode } : {}), + ...(metadata.controlMode === "sync" || metadata.controlMode === "async" ? { controlMode: metadata.controlMode } : {}), + ...(Number.isSafeInteger(metadata.modeEpoch) && metadata.modeEpoch >= 0 ? { modeEpoch: metadata.modeEpoch } : {}), + ...(metadata.capabilities && typeof metadata.capabilities === "object" && !Array.isArray(metadata.capabilities) ? { capabilities: metadata.capabilities } : {}), + ...(typeof metadata.source === "string" ? { source: metadata.source } : {}), + ...(typeof metadata.historyComplete === "boolean" ? { historyComplete: metadata.historyComplete } : {}), + ...(typeof metadata.waitingForSession === "boolean" ? { waitingForSession: metadata.waitingForSession } : {}), + ...(typeof metadata.attachReady === "boolean" ? { attachReady: metadata.attachReady } : {}), + ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), + ...(typeof metadata.latestModel === "string" ? { latestModel: metadata.latestModel } : {}), + ...(typeof metadata.effort === "string" || metadata.effort === null ? { effort: metadata.effort } : {}), + ...(typeof metadata.latestReasoningEffort === "string" || metadata.latestReasoningEffort === null ? { latestReasoningEffort: metadata.latestReasoningEffort } : {}), + ...(typeof metadata.modelName === "string" ? { modelName: metadata.modelName } : {}), + ...(typeof metadata.modelProvider === "string" ? { modelProvider: metadata.modelProvider } : {}), + ...(typeof metadata.approvalPolicy === "string" ? { approvalPolicy: metadata.approvalPolicy } : {}), + ...(typeof metadata.approvalsReviewer === "string" ? { approvalsReviewer: metadata.approvalsReviewer } : {}), + ...(typeof metadata.sandboxPolicy === "string" ? { sandboxPolicy: metadata.sandboxPolicy } : {}), + ...(metadata.approvalPolicy && typeof metadata.approvalPolicy === "object" ? { approvalPolicy: metadata.approvalPolicy } : {}), + ...(metadata.approvalsReviewer === null ? { approvalsReviewer: null } : {}), + ...(metadata.sandboxPolicy && typeof metadata.sandboxPolicy === "object" ? { sandboxPolicy: metadata.sandboxPolicy } : {}), + ...(typeof metadata.permissions === "string" || (metadata.permissions && typeof metadata.permissions === "object") || metadata.permissions === null ? { permissions: metadata.permissions } : {}), + ...(typeof metadata.currentPermissions === "string" || (metadata.currentPermissions && typeof metadata.currentPermissions === "object") || metadata.currentPermissions === null ? { currentPermissions: metadata.currentPermissions } : {}), + ...(Array.isArray(metadata.runtimeWorkspaceRoots) ? { runtimeWorkspaceRoots: metadata.runtimeWorkspaceRoots } : {}), + ...(typeof metadata.workedDurationMs === "number" ? { workedDurationMs: metadata.workedDurationMs } : {}), + ...(typeof metadata.firstTurnWorkItemStartedAtMs === "number" ? { firstTurnWorkItemStartedAtMs: metadata.firstTurnWorkItemStartedAtMs } : {}), + ...(typeof metadata.finalAssistantStartedAtMs === "number" ? { finalAssistantStartedAtMs: metadata.finalAssistantStartedAtMs } : {}), + ...(metadata.tokenUsage && typeof metadata.tokenUsage === "object" ? { tokenUsage: metadata.tokenUsage } : metadata.tokenUsage === null ? { tokenUsage: null } : {}), + ...(metadata.latestTokenUsageInfo && typeof metadata.latestTokenUsageInfo === "object" ? { latestTokenUsageInfo: metadata.latestTokenUsageInfo } : metadata.latestTokenUsageInfo === null ? { latestTokenUsageInfo: null } : {}), + ...(metadata.threadSettings && typeof metadata.threadSettings === "object" ? { threadSettings: metadata.threadSettings } : {}), + ...(Array.isArray(metadata.availableModels) ? { availableModels: metadata.availableModels } : {}), + ...(Array.isArray(metadata.models) ? { models: metadata.models } : {}), + ...(Array.isArray(metadata.subagents) ? { subagents: metadata.subagents } : {}), + ...(typeof metadata.parentThreadId === "string" ? { parentThreadId: metadata.parentThreadId } : {}), + ...(typeof metadata.agentNickname === "string" ? { agentNickname: metadata.agentNickname } : {}), + ...(typeof metadata.agentRole === "string" ? { agentRole: metadata.agentRole } : {}), + }); + } + if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); + else if (Array.isArray(sourcePayload.metadata?.subagents)) this.state.subagents = redact(sourcePayload.metadata.subagents); + for (const request of Array.isArray(sourcePayload.pendingRequests) ? sourcePayload.pendingRequests : []) { + if (!request || typeof request !== "object" || request.requestId === undefined) continue; + const requestId = request.requestId; + this.clearPendingServerRequest(requestId); + const pending = { + appId: requestId, + method: typeof request.method === "string" ? request.method : "server.request", + params: request.params && typeof request.params === "object" ? request.params : {}, + ...(typeof request.risk === "string" ? { risk: request.risk } : {}), + ...(typeof request.summary === "string" ? { summary: request.summary } : {}), + createdAt: Number.isFinite(Number(request.createdAt)) ? Number(request.createdAt) : Date.now(), + ...(Number.isFinite(Number(request.expiresAt)) ? { expiresAt: Number(request.expiresAt) } : {}), + source: "host", + hostClient: client, + commandHash: typeof request.commandHash === "string" ? request.commandHash : undefined, + }; + if (pending.expiresAt && pending.expiresAt <= Date.now()) continue; + this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending); + } + } + if (sourceType === "session.switching") { + const targetThreadId = sourcePayload.targetThreadId || sourcePayload.threadId; + if (typeof targetThreadId === "string") this.state.activeThreadId = targetThreadId; + // The old transcript belongs to the previous thread. Clear it before + // the target's authoritative snapshot arrives so a remote picker never + // briefly renders messages from two sessions together. + this.state.activeTurnId = null; + this.state.outputTail = ""; + this.state.messages = []; + this.state.subagents = []; + this.state.sessionMetadata = null; + this.state.executionStatus = null; + } + if (sourceType === "session.selected") { + const selectedThreadId = sourcePayload.threadId || sourcePayload.activeThreadId; + if (typeof selectedThreadId === "string") this.state.activeThreadId = selectedThreadId; + } + if (sourceType === "output.snapshot") { + if (typeof sourcePayload.text === "string") this.state.outputTail = sourcePayload.text; + if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; + if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); + if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { + // Output snapshots from older hosts occasionally carry the metadata + // projection instead of a separate session.snapshot event. Preserve + // the safe projection so model, permission, and usage controls remain + // available after reconnect. + this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata); + } + } else if (sourceType === "output.chunk") { + // New attach adapters carry the complete role-aware projection alongside + // the append-only delta. Preserve both so reconnects do not flatten + // reasoning, tools, edits, or Markdown into one assistant transcript. + if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail; + else if (typeof sourcePayload.text === "string") this.state.outputTail = `${this.state.outputTail || ""}${sourcePayload.text}`.slice(-32_000); + if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; + else { + const patchedMessages = applyStructuredMessagesPatch(this.state.messages, sourcePayload.messagesPatch); + if (patchedMessages) this.state.messages = patchedMessages; + } + if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); + if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { + this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata); + } + } + if (sourceType === "task.started") { + const id = sourcePayload.turnId || (sourcePayload.turn && sourcePayload.turn.id); + if (typeof id === "string") this.state.activeTurnId = id; + if (typeof sourcePayload.threadId === "string") this.state.activeThreadId = sourcePayload.threadId; + } + if (sourceType === "task.finished" || sourceType === "task.cancelled") { + this.state.activeTurnId = null; + } + + if (sourceType === "connection.opened") { + this.state.app = "ready"; + this.state.initialized = true; + this.state.hostConnected = true; + this.state.lastError = null; + } else if (sourceType === "connection.closed") { + // A replaced host socket can have one frame already queued in the + // transport. A stale close must not mark the newly connected host + // offline; acknowledge it so the old bridge does not retry forever. + if (this.hostClient !== client) { + if (sourceSeq !== undefined) { + this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq }); + } + return; + } + this.state.app = "offline"; + this.state.initialized = false; + this.state.activeTurnId = null; + this.state.outputTail = ""; + this.state.messages = []; + this.state.subagents = []; + this.state.sessionMetadata = null; + this.state.executionStatus = null; + this.state.lastError = { + code: "app_unavailable", + message: typeof sourcePayload.message === "string" + ? redactString(sourcePayload.message) + : "VS Code host app-server disconnected", + retryable: true, + }; + // This event is emitted by the authenticated host bridge when its local + // app-server exits. The relay socket remains usable, so clean only the + // app-scoped pending work here; transport close has its own handler. + this.handleHostAppUnavailable(client, sessionId, this.state.lastError.message); + } + + // RelayHost emits normalized approval/input events and keeps the original + // app-server request id in payload. Store it centrally so exactly one + // browser response can be routed back to that host. + if (sourceType === "approval.requested" + || sourceType === "input.requested" + || sourceType === "server.requested" + || sourceType === "server.request") { + const requestId = sourcePayload.requestId; + if (requestId !== undefined) { + const key = jsonRpcIdKey(requestId); + this.clearPendingServerRequest(requestId); + const pending = { + appId: requestId, + method: typeof sourcePayload.method === "string" ? sourcePayload.method : sourceType, + params: sourcePayload.params || sourcePayload, + commandHash: typeof sourcePayload.commandHash === "string" ? sourcePayload.commandHash : undefined, + risk: typeof sourcePayload.risk === "string" ? sourcePayload.risk : undefined, + summary: typeof sourcePayload.summary === "string" ? sourcePayload.summary : undefined, + expiresAt: Number.isFinite(Number(sourcePayload.expiresAt)) ? Number(sourcePayload.expiresAt) : undefined, + createdAt: Date.now(), + source: "host", + hostClient: client, + }; + // The VS Code adapter owns its local approval timer. Keeping a second + // timer in the relay would race the adapter's JSON-RPC response. + this.pendingServerRequests.set(key, pending); + } + } + if (sourceType === "approval.expired" || sourceType === "input.expired" || sourceType === "server.expired") { + const requestId = sourcePayload.requestId; + if (requestId !== undefined) { + const key = jsonRpcIdKey(requestId); + const pending = this.pendingServerRequests.get(key); + if (pending?.source === "host" && pending.hostClient === client) { + this.clearPendingServerRequest(pending.appId); + } + } + } + if (sourceType === "approval.resolved" + || sourceType === "input.resolved" + || sourceType === "server.responded" + || sourceType === "server.resolved") { + const requestId = sourcePayload.requestId; + if (requestId !== undefined) this.clearPendingServerRequest(requestId); + } + + if (sourceType === "command.accepted" || sourceType === "command.rejected" || sourceType === "command.result") { + const commandId = sourcePayload.commandId; + const pending = commandId ? this.pendingHostCommands.get(String(commandId)) : undefined; + const ok = sourceType === "command.accepted" ? sourcePayload.ok !== false : sourcePayload.ok === true; + const resultPayload = { + commandId: commandId || null, + method: sourcePayload.method || null, + ok, + result: sourcePayload.result, + error: sourcePayload.error, + sourceSeq, + }; + if (resultPayload.result && typeof resultPayload.result === "object") { + const result = resultPayload.result; + if (result.thread && typeof result.thread.id === "string") this.state.activeThreadId = result.thread.id; + if (typeof result.threadId === "string") this.state.activeThreadId = result.threadId; + if (typeof result.activeThreadId === "string") this.state.activeThreadId = result.activeThreadId; + if (typeof result.selectedThreadId === "string") this.state.activeThreadId = result.selectedThreadId; + if (result.turn && typeof result.turn.id === "string") this.state.activeTurnId = result.turn.id; + } + if (commandId) { + this.pendingHostCommands.delete(String(commandId)); + // A late terminal frame from an app that already reported + // connection.closed must not repopulate the cache we just invalidated. + if (pending || this.state.app !== "offline") { + this.cacheCommandResult( + String(commandId), + resultPayload, + pending?.commandScope || client.commandScope || this.hostCommandScope, + ); + } + } + const event = this.recordEvent("command.result", resultPayload, { sessionId }); + if (pending?.kind === "server-response") { + const requestId = pending.responseRequestId ?? pending.requestId; + if (resultPayload.ok) { + this.clearPendingServerRequest(pending.requestId); + const responseEvent = this.recordEvent("server.responded", { + requestId, + method: pending.method, + ok: true, + }, { sessionId }); + this.sendControl(pending.client, { type: "response.accepted", requestId, seq: responseEvent.seq }); + } else { + this.sendControl(pending.client, { + type: "response.rejected", + requestId, + code: "host_rejected", + message: responseErrorMessage(resultPayload.error || "VS Code host rejected the response"), + retryable: true, + }); + } + } else if (pending?.client) { + this.sendControl(pending.client, { type: "command.result", ...resultPayload, seq: event.seq }); + } + return; + } + + const payload = { + ...sourcePayload, + ...((sourceType === "approval.requested" + || sourceType === "input.requested" + || sourceType === "server.requested" + || sourceType === "server.request") && !sourcePayload.params + ? { params: sourcePayload } + : {}), + source: "vscode-host", + ...(sourceSeq !== undefined ? { sourceSeq } : {}), + ...(frame.raw !== undefined ? { raw: redact(frame.raw) } : {}), + }; + const event = this.recordEvent(sourceType, payload, { sessionId }); + // RelayHost sends event frames to its own relay transport and expects an + // ack. Acknowledge only after the frame has been accepted into our ring. + if (sourceSeq !== undefined) { + this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq }); + } + return event; + } + + handleHostAppUnavailable(client, sessionId, reason = "VS Code host app-server unavailable") { + // A delayed frame from an older host socket must never tear down the + // pending work or cache belonging to the currently authenticated host. + if (this.hostClient !== client) return; + const terminalError = { + code: "app_unavailable", + message: redactString(reason), + retryable: true, + }; + + // A local app-server crash invalidates both completed cache entries and + // in-flight host commands. Report in-flight commands as uncertain to the + // originating browser, but do not cache them: a retry after recovery must + // be explicit rather than silently replaying an unknown operation. + this.clearCommandResults(); + for (const [commandId, pending] of [...this.pendingHostCommands]) { + if (pending.hostClient && pending.hostClient !== client) continue; + if (!pending.hostClient && pending.commandScope && pending.commandScope !== client.commandScope) continue; + this.pendingHostCommands.delete(commandId); + if (pending.kind === "server-response") { + this.sendControl(pending.client, { + type: "response.rejected", + requestId: pending.responseRequestId ?? pending.requestId, + code: terminalError.code, + message: terminalError.message, + retryable: true, + }); + continue; + } + const payload = { + commandId, + method: pending.method || null, + ok: false, + uncertain: true, + retryable: true, + error: terminalError, + }; + const event = this.recordEvent("command.result", payload, { sessionId }); + if (pending.client) this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq }); + } + + // Host approval/input requests are owned by the adapter, so the relay does + // not run a second expiry timer. Once the adapter reports its app process + // unavailable, remove every request tied to this host immediately. + for (const pending of [...this.pendingServerRequests.values()]) { + if (pending.source !== "host" || pending.hostClient !== client) continue; + this.clearPendingServerRequest(pending.appId); + const isInput = pending.method === "item/tool/requestUserInput" + || pending.method === "mcpServer/elicitation/request"; + this.recordEvent(isInput ? "input.expired" : "approval.expired", { + requestId: pending.appId, + method: pending.method, + reason: terminalError.message, + error: terminalError, + }, { sessionId }); + } + } + + dispatchCommand(client, message) { + const commandId = String(message.commandId || ""); + const method = String(message.method || ""); + if (!commandId || commandId.length > 128) { + return this.commandRejected(client, commandId, "invalid_command_id", "commandId is required"); + } + if (!ALLOWED_METHODS.has(method)) { + return this.commandRejected(client, commandId, "method_not_allowed", `Method ${method || "(empty)"} is not allowed`); + } + if (MUTATING_METHODS.has(method) && client.role !== "operator") { + this.auditAction(client, method, { commandId }, "denied"); + return this.commandRejected(client, commandId, "forbidden", "Operator token required"); + } + const cached = this.getCachedCommandResult(commandId); + if (cached) { + this.sendControl(client, { type: "command.result", ...cached, cached: true }); + return { accepted: true, cached: true }; + } + for (const pending of this.pendingHostCommands.values()) { + if (pending.commandId === commandId) { + this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true }); + return { accepted: true, duplicate: true }; + } + } + for (const pending of this.pendingAppRequests.values()) { + if (pending.commandId === commandId) { + this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true }); + return { accepted: true, duplicate: true }; + } + } + + if (method === "initialize") { + if (!this.state.initialized) { + return this.commandRejected(client, commandId, "app_initializing", "Codex is still initializing", true); + } + const result = { + commandId, + method, + ok: true, + result: this.mode === "host" + ? { protocol: 1, mode: "host", hostConnected: this.state.hostConnected, sessionId: this.state.hostSessionId } + : this.initializedResult, + cachedAt: Date.now(), + }; + this.cacheCommandResult(commandId, result, this.mode === "host" ? this.hostCommandScope : "embedded"); + this.sendControl(client, { type: "command.result", ...result, cached: true }); + return { accepted: true, cached: true }; + } + + if (!this.state.initialized) { + return this.commandRejected(client, commandId, "app_not_ready", "Codex app-server is not ready", true); + } + if (!message.params || typeof message.params !== "object" || Array.isArray(message.params)) { + return this.commandRejected(client, commandId, "invalid_params", "params must be an object"); + } + + const validationError = this.validateCommand(method, message.params); + if (validationError) return this.commandRejected(client, commandId, "invalid_params", validationError); + + // This MVP exposes one active Codex turn per relay session. Keeping the + // check at the relay boundary prevents two browser operators from racing + // a turn start or steering an outdated turn id. + const turnStartPending = [...this.pendingAppRequests.values()].some((pending) => pending.method === "turn/start") + || [...this.pendingHostCommands.values()].some((pending) => pending.method === "turn/start"); + if (method === "turn/start" && (this.state.activeTurnId || turnStartPending)) { + return this.commandRejected(client, commandId, "turn_active", "A Codex turn is already active", true); + } + if (method === "session/select" || method === "control/mode/set") { + const pendingMethod = method === "session/select" ? "session/select" : "control/mode/set"; + const sessionSwitchPending = [...this.pendingHostCommands.values()].some((pending) => pending.method === pendingMethod) + || [...this.pendingAppRequests.values()].some((pending) => pending.method === pendingMethod); + if (sessionSwitchPending) { + return this.commandRejected(client, commandId, method === "session/select" ? "session_switch_pending" : "mode_switch_pending", method === "session/select" ? "A session switch is already in progress" : "A control mode switch is already in progress", true); + } + if (this.state.activeTurnId || this.pendingServerRequests.size) { + return this.commandRejected(client, commandId, method === "session/select" ? "session_busy" : "mode_busy", "The active session has a running turn or pending request", true); + } + } + if (method === "turn/steer" && this.state.activeTurnId && message.params.expectedTurnId !== this.state.activeTurnId) { + return this.commandRejected(client, commandId, "stale_turn", "expectedTurnId does not match the active turn", true); + } + if (method === "turn/interrupt" && this.state.activeTurnId && message.params.turnId !== this.state.activeTurnId) { + return this.commandRejected(client, commandId, "stale_turn", "turnId does not match the active turn", true); + } + + // A connected VS Code bridge is the source of truth for the session. The + // relay never runs a second app-server request for the same command. + if (this.hostClient && this.hostClient.socket.readyState === WebSocket.OPEN) { + const hostFrame = { + v: 1, + kind: "command", + type: method, + commandId, + sessionId: this.hostClient.sessionId || undefined, + actor: { id: client.id || "web", role: client.role }, + payload: message.params, + }; + this.pendingHostCommands.set(commandId, { + commandId, + method, + client, + hostClient: this.hostClient, + commandScope: this.hostCommandScope || this.hostClient.commandScope || null, + createdAt: Date.now(), + }); + try { + this.hostClient.socket.send(JSON.stringify(hostFrame)); + this.auditAction(client, method, { commandId, params: message.params, target: "vscode-host" }, "forwarded"); + this.sendControl(client, { type: "command.accepted", commandId, method, target: "vscode-host" }); + return { accepted: true, commandId, method, target: "vscode-host" }; + } catch (error) { + this.pendingHostCommands.delete(commandId); + return this.commandRejected(client, commandId, "host_unavailable", error.message, true); + } + } + + const appId = this.nextAppRequestId("command"); + try { + this.pendingAppRequests.set(jsonRpcIdKey(appId), { + kind: "command", + commandId, + method, + client, + createdAt: Date.now(), + }); + this.sendToApp({ method, id: appId, params: message.params }); + this.auditAction(client, method, { commandId, params: message.params }, "forwarded"); + this.sendControl(client, { type: "command.accepted", commandId, method }); + return { accepted: true, commandId, method }; + } catch (error) { + this.pendingAppRequests.delete(jsonRpcIdKey(appId)); + return this.commandRejected(client, commandId, error.code || "app_offline", error.message, error.retryable); + } + } + + validateCommand(method, params) { + if (method === "control/mode/get") { + if (Object.keys(params).length > 0) return "control/mode/get does not accept parameters"; + } + if (method === "control/mode/set") { + if (params.mode !== "sync" && params.mode !== "async") return "mode must be sync or async"; + } + if (method === "session/list") { + if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 100)) { + return "limit must be an integer between 1 and 100"; + } + } + if (method === "session/select") { + const threadId = params.threadId ?? params.conversationId; + if (typeof threadId !== "string" || !threadId.trim()) return "threadId is required"; + if (threadId.length > 256) return "threadId is too long"; + } + if (method === "thread/start") { + const allowedSandboxes = new Set(["read-only", "workspace-write", "danger-full-access"]); + if (params.sandbox != null && !allowedSandboxes.has(params.sandbox)) { + return "sandbox must be read-only, workspace-write, or danger-full-access"; + } + if (params.cwd != null && typeof params.cwd !== "string") return "cwd must be a string"; + } + if (method === "turn/start") { + if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; + if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array"; + } + if (method === "thread/settings/update") { + if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; + const settings = params.threadSettings ?? params.settings; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return "threadSettings must be an object"; + if (settings.model !== undefined && typeof settings.model !== "string") return "threadSettings.model must be a string"; + // `null` is the official value for clearing a model's reasoning effort + // (some models do not expose a selectable effort). Preserve it through + // the relay instead of rejecting a valid next-turn update. + if (settings.effort !== undefined && settings.effort !== null && typeof settings.effort !== "string") return "threadSettings.effort must be a string or null"; + for (const key of ["sandboxPolicy", "approvalPolicy"]) { + const value = settings[key]; + if (value !== undefined && value !== null && typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) { + return `threadSettings.${key} must be a string, object, or null`; + } + } + if (settings.approvalsReviewer !== undefined && settings.approvalsReviewer !== null && typeof settings.approvalsReviewer !== "string") { + return "threadSettings.approvalsReviewer must be a string or null"; + } + if (settings.runtimeWorkspaceRoots !== undefined && settings.runtimeWorkspaceRoots !== null + && (!Array.isArray(settings.runtimeWorkspaceRoots) || !settings.runtimeWorkspaceRoots.every((entry) => typeof entry === "string"))) { + return "threadSettings.runtimeWorkspaceRoots must be an array of strings or null"; + } + if (settings.permissions !== undefined && settings.permissions !== null + && (typeof settings.permissions !== "string" && (typeof settings.permissions !== "object" || Array.isArray(settings.permissions)))) { + return "threadSettings.permissions must be a string, object, or null"; + } + } + if (method === "turn/steer") { + if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; + if (typeof params.expectedTurnId !== "string" || !params.expectedTurnId) return "expectedTurnId is required"; + if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array"; + } + if (method === "turn/interrupt") { + if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; + if (typeof params.turnId !== "string" || !params.turnId) return "turnId is required"; + } + return null; + } + + commandRejected(client, commandId, code, message, retryable = false) { + const payload = { type: "command.rejected", commandId: commandId || null, code, message, retryable: Boolean(retryable) }; + this.sendControl(client, payload); + return { accepted: false, ...payload }; + } + + scheduleServerRequestExpiry(requestId, pending) { + if (this.approvalTimeoutMs <= 0) return; + pending.timer = setTimeout(() => this.expireServerRequest(requestId), this.approvalTimeoutMs); + pending.timer.unref?.(); + } + + expireServerRequest(requestId) { + const pending = this.clearPendingServerRequest(requestId); + if (!pending) return; + const canonicalRequestId = pending.appId; + const isInput = pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"; + const reason = "Remote approval timed out"; + this.recordEvent(isInput ? "input.expired" : "approval.expired", { + requestId: canonicalRequestId, + method: pending.method, + reason, + }, { sessionId: pending.hostClient?.sessionId || undefined }); + this.auditAction({ id: "relay", role: "system" }, pending.method, { requestId: canonicalRequestId }, "expired"); + + const result = defaultServerResponse(pending.method, reason); + if (pending.source === "host") { + if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) return; + const commandMethod = isInput ? "server.request.respond" : "approval.respond"; + try { + pending.hostClient.socket.send(JSON.stringify({ + v: 1, + kind: "command", + type: commandMethod, + commandId: randomId("timeout"), + sessionId: pending.hostClient.sessionId || undefined, + actor: { id: "relay", role: "system" }, + payload: { + requestId: pending.appId, + decision: "deny", + response: result, + reason, + }, + })); + } catch { + // The local adapter also has its own expiry deny; no retry is needed. + } + return; + } + + try { + this.sendToApp({ id: pending.appId, result }); + } catch { + // The process may have exited while the approval was pending. + } + } + + clearPendingServerRequest(requestId) { + const key = jsonRpcIdKey(requestId); + const pending = this.pendingServerRequests.get(key); + if (pending?.timer) clearTimeout(pending.timer); + this.pendingServerRequests.delete(key); + return pending; + } + + dispatchServerResponse(client, message) { + const requestId = message.requestId ?? message.id ?? ""; + const requestKey = findTypedMapKey(this.pendingServerRequests, requestId, (value) => value?.appId, true); + if (client.role !== "operator") { + this.auditAction(client, "server-response", { requestId }, "denied"); + this.sendControl(client, { type: "response.rejected", requestId, code: "forbidden", message: "Operator token required" }); + return { accepted: false, code: "forbidden" }; + } + const pending = this.pendingServerRequests.get(requestKey); + if (!pending) { + this.sendControl(client, { type: "response.rejected", requestId, code: "unknown_request", message: "Request is no longer pending" }); + return { accepted: false, code: "unknown_request" }; + } + if (!("result" in message) && !("error" in message)) { + this.sendControl(client, { type: "response.rejected", requestId, code: "invalid_response", message: "result or error is required" }); + return { accepted: false, code: "invalid_response" }; + } + const remoteResponseAllowed = SERVER_REQUEST_METHODS.has(pending.method) + || pending.method === "item/tool/requestUserInput" + || pending.method === "mcpServer/elicitation/request"; + if (!remoteResponseAllowed) { + this.sendControl(client, { + type: "response.rejected", + requestId, + code: "unsupported_request", + message: "This app-server request must be handled by the host", + }); + return { accepted: false, code: "unsupported_request" }; + } + + const normalizedResult = Object.prototype.hasOwnProperty.call(message, "result") + ? normalizeServerResponseForApp(pending.method, message.result) + : undefined; + if (Object.prototype.hasOwnProperty.call(message, "result") + && !isValidApprovalResponse(pending.method, normalizedResult)) { + this.sendControl(client, { + type: "response.rejected", + requestId, + code: "invalid_response", + message: "Unsupported or malformed approval decision", + }); + return { accepted: false, code: "invalid_response" }; + } + if (Object.prototype.hasOwnProperty.call(message, "requestedDecision") + && (pending.method === "item/commandExecution/requestApproval" + || pending.method === "item/fileChange/requestApproval" + || pending.method === "applyPatchApproval" + || pending.method === "execCommandApproval")) { + const requested = approvalDecisionKind(message.requestedDecision); + const actual = approvalDecisionForResult(normalizedResult); + if (!requested || requested !== actual) { + this.sendControl(client, { + type: "response.rejected", + requestId, + code: "decision_mismatch", + message: "Outer approval decision does not match the response", + }); + return { accepted: false, code: "decision_mismatch" }; + } + } + + // Host-proxy mode uses the bridge's normalized command contract. Keep the + // original app-server request id in the payload, but do not forward an + // arbitrary JSON-RPC response as a relay command. + if (pending.source === "host") { + const result = normalizedResult; + const error = Object.prototype.hasOwnProperty.call(message, "error") ? message.error : undefined; + const method = pending.method || ""; + const isInput = method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request"; + const commandMethod = isInput ? "server.request.respond" : "approval.respond"; + const decision = error + ? "deny" + : isInput + ? "allow" + : approvalDecisionForResult(result); + // Generate the host command from the canonical app-server id. This + // preserves the legacy `response-77` shape when a browser merely + // stringified a numeric id, while still suffixing ids when both typed + // variants are pending concurrently. + const commandId = responseCommandId(pending.appId, this.pendingServerRequests, this.pendingHostCommands); + const hostFrame = { + v: 1, + kind: "command", + type: commandMethod, + commandId, + sessionId: pending.hostClient?.sessionId || undefined, + actor: { id: client.id || "web", role: client.role }, + payload: { + requestId: pending.appId, + decision, + ...(pending.commandHash ? { commandHash: pending.commandHash } : {}), + ...(result !== undefined ? { response: result } : {}), + ...(error !== undefined ? { reason: responseErrorMessage(error) } : {}), + }, + }; + const existing = this.pendingHostCommands.get(commandId); + if (existing?.kind === "server-response") { + this.sendControl(client, { type: "response.pending", requestId, commandId }); + return { accepted: true, pending: true, requestId, commandId }; + } + if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) { + this.clearPendingServerRequest(pending.appId); + this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: "VS Code host is disconnected" }); + return { accepted: false, code: "host_unavailable" }; + } + try { + this.pendingHostCommands.set(commandId, { + kind: "server-response", + commandId, + // Keep the original app-server id for exact map cleanup. The + // browser-facing id may be a legacy stringified form of that id. + requestId: pending.appId, + responseRequestId: requestId, + method: pending.method, + client, + commandScope: this.hostCommandScope || pending.hostClient?.commandScope || null, + createdAt: Date.now(), + }); + pending.hostClient.socket.send(JSON.stringify(hostFrame)); + this.auditAction(client, pending.method, { requestId, target: "vscode-host", result, error }, "forwarded"); + this.sendControl(client, { type: "response.pending", requestId, commandId }); + return { accepted: true, pending: true, requestId, commandId }; + } catch (sendError) { + this.pendingHostCommands.delete(commandId); + this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: sendError.message, retryable: true }); + return { accepted: false, code: "host_unavailable" }; + } + } + + const appMessage = { id: pending.appId }; + if ("result" in message) appMessage.result = normalizedResult; + else appMessage.error = message.error; + try { + this.sendToApp(appMessage); + this.clearPendingServerRequest(pending.appId); + this.auditAction(client, pending.method, { requestId, result: message.result, error: message.error }, "responded"); + const event = this.recordEvent("server.responded", { + requestId, + method: pending.method, + ok: !message.error, + }); + this.sendControl(client, { type: "response.accepted", requestId, seq: event.seq }); + return { accepted: true, requestId }; + } catch (error) { + this.sendControl(client, { type: "response.rejected", requestId, ...normalizeError(error) }); + return { accepted: false, ...normalizeError(error) }; + } + } + + cacheCommandResult(commandId, payload, sessionScope) { + const key = String(commandId); + const scope = this.mode === "host" + ? (sessionScope || this.hostCommandScope || null) + : "embedded"; + this.commandResults.set(key, { ...payload, cachedAt: Date.now() }); + this.commandResultScopes.set(key, scope); + this.pruneCommandResults(); + } + + getCachedCommandResult(commandId) { + const key = String(commandId); + const cached = this.commandResults.get(key); + if (!cached) return null; + // A disconnect leaves the outcome unknown. Never replay that marker as a + // completed result; remove it so a retry can be forwarded to a reconnected + // host (or receive the normal offline error). + if (cached.uncertain) { + this.commandResults.delete(key); + this.commandResultScopes.delete(key); + return null; + } + const activeScope = this.mode === "host" + ? (this.hostClient && this.state.hostConnected ? this.hostCommandScope : null) + : "embedded"; + // Host results are never replayed while disconnected. This also prevents + // an old result from leaking across a session-id change. + if (!activeScope || this.commandResultScopes.get(key) !== activeScope) return null; + return cached; + } + + clearCommandResults() { + this.commandResults.clear(); + this.commandResultScopes.clear(); + } + + pruneCommandResults() { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const [key, value] of this.commandResults) { + if (value.cachedAt < cutoff) { + this.commandResults.delete(key); + this.commandResultScopes.delete(key); + } + } + while (this.commandResults.size > 1_000) { + const [firstKey] = this.commandResults.keys(); + this.commandResults.delete(firstKey); + this.commandResultScopes.delete(firstKey); + } + } + + sendControl(client, message) { + if (client.capture) client.capture.push(message); + if (!client.socket || client.socket.readyState !== WebSocket.OPEN) return; + const serialized = JSON.stringify(message); + const frameBytes = Buffer.byteLength(serialized, "utf8"); + if (frameBytes > MAX_WS_PAYLOAD) { + client.socket.close(1009, "relay frame is too large"); + return; + } + const bufferedBytes = Number(client.socket.bufferedAmount) || 0; + if (bufferedBytes + frameBytes > this.clientBufferedByteLimit) { + client.socket.close(1013, "client is too slow"); + return; + } + client.socket.send(serialized); + } + + snapshot() { + // These projections used to be serialized both inside `state` and again + // at the top level, nearly doubling every long-history control frame. + // Keep the compact lifecycle state nested and one authoritative transcript + // projection at the stable top-level protocol fields. + const { + outputTail, + messages, + subagents, + sessionMetadata, + executionStatus, + ...state + } = this.state; + return { + protocol: 1, + latestSeq: this.nextSeq, + state, + clients: [...this.clients] + .filter((client) => client.authenticated) + .map((client) => ({ id: client.id, role: client.role })), + pendingRequests: [...this.pendingServerRequests.values()].map((request) => ({ + requestId: request.appId, + method: request.method, + params: request.params, + ...(request.commandHash ? { commandHash: request.commandHash } : {}), + ...(request.risk ? { risk: request.risk } : {}), + ...(request.summary ? { summary: request.summary } : {}), + ...(request.expiresAt ? { expiresAt: request.expiresAt } : {}), + createdAt: request.createdAt, + })), + outputTail: outputTail || "", + messages: Array.isArray(messages) ? messages : [], + subagents: Array.isArray(subagents) ? subagents : [], + ...(sessionMetadata ? { metadata: sessionMetadata } : {}), + status: executionStatus, + executionStatus, + }; + } + + tokenFromRequest(request) { + const authorization = request.headers.authorization || ""; + if (/^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, ""); + return request.headers["x-codex-token"] || ""; + } + + authenticateHttp(request) { + const role = this.roleForToken(this.tokenFromRequest(request)); + if (role) return role; + if (!this.authRequired && isLoopbackAddress(request.socket?.remoteAddress)) return "operator"; + return null; + } + + async handleHttp(request, response) { + const base = `http://${request.headers.host || "localhost"}`; + let requestUrl; + try { + requestUrl = new URL(request.url, base); + } catch { + jsonResponse(response, 400, { error: "invalid_url" }); + return; + } + + if (!this.authRequired && !isLoopbackRequestHost(request)) { + jsonResponse(response, 403, { error: "loopback_host_required" }); + return; + } + + if (request.method === "GET" && requestUrl.pathname === "/api/health") { + jsonResponse(response, this.state.app === "offline" ? 503 : 200, { + ok: this.state.app !== "offline", + app: this.state.app, + initialized: this.state.initialized, + authRequired: this.authRequired, + latestSeq: this.nextSeq, + }); + return; + } + + if (requestUrl.pathname.startsWith("/api/")) { + const role = this.authenticateHttp(request); + if (!role) { + jsonResponse(response, 401, { error: "unauthorized" }, { "WWW-Authenticate": "Bearer" }); + return; + } + + if (request.method === "POST" && !isAllowedHttpOrigin(request)) { + jsonResponse(response, 403, { error: "origin_not_allowed" }); + return; + } + + if (request.method === "GET" && requestUrl.pathname === "/api/state") { + jsonResponse(response, 200, { role, ...this.snapshot(), audit: this.audit.slice(-50) }); + return; + } + if (request.method === "GET" && requestUrl.pathname === "/api/events") { + const fromSeq = Number(requestUrl.searchParams.get("fromSeq") || 0); + jsonResponse(response, 200, { + latestSeq: this.nextSeq, + events: this.events.filter((event) => event.seq > fromSeq), + }); + return; + } + if (request.method === "POST" && requestUrl.pathname === "/api/command") { + try { + const body = await readJson(request); + const client = { id: "http", role, authenticated: true, capture: [] }; + const result = this.dispatchCommand(client, { type: "command", ...body }); + jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture }); + } catch (error) { + jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) }); + } + return; + } + if (request.method === "POST" && requestUrl.pathname === "/api/respond") { + try { + const body = await readJson(request); + const client = { id: "http", role, authenticated: true, capture: [] }; + const result = this.dispatchServerResponse(client, { type: "respond", ...body }); + jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture }); + } catch (error) { + jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) }); + } + return; + } + jsonResponse(response, 404, { error: "not_found" }); + return; + } + + this.serveStatic(request, response, requestUrl.pathname); + } + + serveStatic(request, response, pathname) { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end(); + return; + } + let relativePath; + try { + relativePath = pathname === "/" ? "index.html" : decodeURIComponent(pathname).replace(/^\/+/, ""); + } catch { + // A malformed percent escape must be an ordinary client error, not an + // uncaught exception from the HTTP request handler. + response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }); + response.end("Invalid URL"); + return; + } + const filePath = path.resolve(PUBLIC_ROOT, relativePath); + if (!filePath.startsWith(`${PUBLIC_ROOT}${path.sep}`) && filePath !== path.join(PUBLIC_ROOT, "index.html")) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + fs.stat(filePath, (error, stat) => { + if (error || !stat.isFile()) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + response.writeHead(200, { + "Content-Type": contentType(filePath), + "Content-Length": stat.size, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'self'", + }); + if (request.method === "HEAD") response.end(); + else fs.createReadStream(filePath).pipe(response); + }); + } +} + +function isObjectPayload(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function firstObject(...values) { + return values.find((value) => isObjectPayload(value)) || null; +} + +function commandMethodFromFrame(frame) { + const nested = isObjectPayload(frame.command) ? frame.command : null; + if (typeof frame.method === "string" && frame.method.trim()) return frame.method.trim(); + if (typeof nested?.type === "string" && nested.type.trim()) return nested.type.trim(); + if (typeof frame.kind === "string" && (frame.kind === "command" || frame.kind === "response") && typeof frame.type === "string") { + if (frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") return frame.type.trim(); + } + if (typeof frame.type === "string" && frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") { + return frame.type.trim(); + } + return ""; +} + +function normalizeWireMethod(method) { + const value = String(method || "").trim(); + const aliases = { + "control.mode.get": "control/mode/get", + "controlmode.get": "control/mode/get", + "mode.get": "control/mode/get", + "control.mode.set": "control/mode/set", + "controlmode.set": "control/mode/set", + "mode.set": "control/mode/set", + "thread.start": "thread/start", + "session.new": "session/new", + "sessionnew": "session/new", + "thread.new": "session/new", + "threadnew": "session/new", + "session/new": "session/new", + "thread/new": "session/new", + "thread.settings.update": "thread/settings/update", + "threadsettings.update": "thread/settings/update", + "session.list": "session/list", + "thread.list": "session/list", + "session.select": "session/select", + "session.switch": "session/select", + "thread.select": "session/select", + "thread.attach": "session/select", + "turn.start": "turn/start", + "turn.steer": "turn/steer", + "turn.interrupt": "turn/interrupt", + }; + return aliases[value.toLowerCase()] || value; +} + +function commandIdFromFrame(frame) { + const nested = isObjectPayload(frame.command) ? frame.command : null; + const value = frame.commandId ?? nested?.commandId ?? (typeof frame.id === "string" ? frame.id : undefined); + return value === undefined || value === null ? "" : String(value); +} + +function normalizeBrowserCommand(frame) { + const method = normalizeWireMethod(commandMethodFromFrame(frame)); + const type = frame.type; + const hasCommandEnvelope = frame.kind === "command" + || type === "command" + || (frame.kind === undefined && (ALLOWED_METHODS.has(method) || REMOTE_RESPONSE_METHODS.has(method))); + if (!hasCommandEnvelope || !method) return null; + + const nested = isObjectPayload(frame.command) ? frame.command : null; + const params = firstObject(frame.params, frame.payload, nested?.params, nested?.payload) || {}; + return { + type: "command", + commandId: commandIdFromFrame(frame), + method, + params, + }; +} + +function approvalWireDecision(decision) { + // Keep the caller's decision intact until dispatch knows the target + // app-server method. Legacy approval methods use `approved`/`abort`, while + // v2 methods use `accept`/`cancel`; method-aware normalization handles the + // conversion without discarding amendment tags. + return decision; +} + +function normalizeBrowserResponse(frame, hintedMethod) { + const type = typeof frame.type === "string" ? frame.type : ""; + const method = hintedMethod || commandMethodFromFrame(frame); + const isLegacy = type === "respond" || type === "server-response"; + const isResponse = isLegacy + || frame.kind === "response" + || REMOTE_RESPONSE_METHODS.has(method); + if (!isResponse) return null; + + const nested = isObjectPayload(frame.command) ? frame.command : null; + const payload = firstObject(frame.payload, frame.params, nested?.payload, nested?.params) || (isLegacy ? {} : frame); + const requestId = frame.requestId ?? payload.requestId ?? (typeof frame.id === "number" || typeof frame.id === "string" ? frame.id : ""); + const response = { type: "respond", requestId }; + if (payload.decision !== undefined) response.requestedDecision = payload.decision; + + if (Object.prototype.hasOwnProperty.call(frame, "result")) { + response.result = frame.result; + } else if (Object.prototype.hasOwnProperty.call(frame, "error")) { + response.error = frame.error; + } else if (Object.prototype.hasOwnProperty.call(payload, "result")) { + response.result = payload.result; + } else if (Object.prototype.hasOwnProperty.call(payload, "error")) { + response.error = payload.error; + } else if (payload.response !== undefined) { + response.result = payload.response; + } else if (method === "input.respond" || method === "server.request.respond") { + if (payload.answers !== undefined) response.result = { answers: payload.answers }; + else { + const custom = {}; + for (const [key, value] of Object.entries(payload)) { + if (!["v", "kind", "type", "method", "commandId", "id", "sessionId", "actor", "requestId", "reason", "params", "payload", "command"].includes(key)) custom[key] = value; + } + if (Object.keys(custom).length) response.result = custom; + } + } else if (payload.decision !== undefined) { + response.result = { decision: approvalWireDecision(payload.decision) }; + } + return response; +} + +async function main() { + const relay = new CodexRelay(); + const address = await relay.start(); + const displayHost = address.host === "::" || address.host === "0.0.0.0" ? "127.0.0.1" : address.host; + process.stdout.write(`Codex Remote Collab: http://${displayHost}:${address.port}\n`); + if (relay.authRequired) { + process.stdout.write(`Host token: ${relay.hostToken}\n`); + process.stdout.write(`Operator token: ${relay.operatorToken}\n`); + process.stdout.write(`Viewer token: ${relay.viewerToken}\n`); + process.stdout.write("Keep these tokens private. Use TLS before exposing this relay outside a trusted network.\n"); + } else { + process.stdout.write("Authentication: disabled for loopback connections (set CODEX_REMOTE_AUTH=required to enable tokens).\n"); + } + + const shutdown = async () => { + await relay.stop(); + process.exit(0); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error.stack || error}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + ALLOWED_METHODS, + CodexRelay, + SERVER_REQUEST_METHODS, + redact, +}; diff --git a/aether-vscodex/test/adapter-safety.test.js b/aether-vscodex/test/adapter-safety.test.js new file mode 100644 index 000000000..99e1ecece --- /dev/null +++ b/aether-vscodex/test/adapter-safety.test.js @@ -0,0 +1,745 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js"); +const { RelayHost } = require("../vscode-extension/dist/relayHost.js"); + +class FakeRpc { + responses = []; + requests = []; + notificationListener; + requestListener; + exitListener; + overrides; + + constructor(overrides = {}) { + this.overrides = overrides; + } + + get running() { + return true; + } + + async start() {} + + async request(method, params) { + this.requests.push({ method, params }); + if (Object.prototype.hasOwnProperty.call(this.overrides, method)) { + const override = this.overrides[method]; + return typeof override === "function" ? override(params) : override; + } + if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" }; + if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" }; + if (method === "turn/start") return { turn: { id: "turn-test" } }; + if (method === "turn/steer") return { turn: { id: "turn-test" } }; + if (method === "turn/interrupt") return {}; + throw new Error(`unexpected request ${method}`); + } + + notify() {} + + respond(id, result) { + this.responses.push({ id, result }); + } + + respondError(id, code, message) { + this.responses.push({ id, error: { code, message } }); + } + + onNotification(listener) { + this.notificationListener = listener; + return { dispose: () => undefined }; + } + + onServerRequest(listener) { + this.requestListener = listener; + return { dispose: () => undefined }; + } + + onExit(listener) { + this.exitListener = listener; + return { dispose: () => undefined }; + } + + close() {} + + emitRequest(request) { + this.requestListener(request); + } + + emitNotification(notification) { + this.notificationListener(notification); + } +} + +class FakeRelay { + frames = []; + listeners = new Set(); + + async connect() {} + + send(frame) { + this.frames.push(frame); + } + + onMessage(listener) { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + close() {} +} + +test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => { + const rpc = new FakeRpc(); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + + rpc.emitRequest({ + id: 1, + method: "item/commandExecution/requestApproval", + params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" }, + }); + rpc.emitRequest({ + id: "1", + method: "item/commandExecution/requestApproval", + params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" }, + }); + + const snapshot = await adapter.snapshot(); + assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]); + await adapter.respondApproval(1, "deny"); + await adapter.respondApproval("1", "deny"); + assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]); + assert.equal((await adapter.snapshot()).pendingApprovals.length, 0); + await adapter.dispose(); +}); + +test("commandActions are included in high-risk approval classification", async () => { + const rpc = new FakeRpc(); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + rpc.emitRequest({ + id: 2, + method: "item/commandExecution/requestApproval", + params: { + threadId: "t", + turnId: "u", + itemId: "actions", + command: null, + commandActions: [{ type: "unknown", command: "sudo rm -rf /" }], + }, + }); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.pendingApprovals[0].risk, "high"); + await adapter.respondApproval(2, "deny"); + await adapter.dispose(); +}); + +test("output snapshots stay redacted and interrupt clears the active turn", async () => { + const rpc = new FakeRpc(); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + await adapter.startThread({}); + await adapter.startTurn({ text: "hello" }); + assert.equal((await adapter.snapshot()).turnId, "turn-test"); + + rpc.emitNotification({ + method: "item/agentMessage/delta", + params: { delta: "credential Bearer abcdefghijklmnop" }, + }); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false); + assert.match(snapshot.outputTail, /\[REDACTED\]/); + + await adapter.interruptTurn({}); + const afterInterrupt = await adapter.snapshot(); + assert.equal(afterInterrupt.turnId, null); + assert.equal(afterInterrupt.state, "idle"); + await adapter.dispose(); +}); + +test("async adapter lists app-server threads and exposes the model catalog", async () => { + const rpc = new FakeRpc({ + "model/list": { + data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }], + nextCursor: null, + }, + "thread/list": { + data: [ + { + id: "thread-recent", + name: null, + preview: "Inspect the workspace\nwith detail", + cwd: "/tmp/workspace", + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + status: { type: "idle" }, + source: "vscode", + }, + ], + nextCursor: "next-page", + backwardsCursor: null, + }, + }); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + + const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" }); + assert.equal(result.sessions[0].threadId, "thread-recent"); + assert.equal(result.sessions[0].title, "Inspect the workspace with detail"); + assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000); + assert.equal(result.nextCursor, "next-page"); + const listRequest = rpc.requests.find((entry) => entry.method === "thread/list"); + assert.deepEqual(listRequest.params, { + limit: 100, + sortKey: "updated_at", + sortDirection: "desc", + searchTerm: "workspace", + }); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.mode, "async"); + assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol"); + await adapter.dispose(); +}); + +test("async adapter projects live token usage notifications into metadata and snapshots", async () => { + const rpc = new FakeRpc({ + "model/list": { data: [], nextCursor: null }, + }); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + const events = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + await adapter.startThread({}); + + rpc.emitNotification({ + method: "thread/tokenUsage/updated", + params: { + threadId: "thread-test", + // A usage update may arrive after the turn has completed. It must not + // make the adapter report that historical turn as active again. + turnId: "turn-finished", + tokenUsage: { + total: { + totalTokens: 1_200, + inputTokens: 800, + cachedInputTokens: 100, + cacheWriteInputTokens: 20, + outputTokens: 300, + reasoningOutputTokens: 80, + }, + last: { + totalTokens: 450, + inputTokens: 300, + cachedInputTokens: 40, + cacheWriteInputTokens: 10, + outputTokens: 100, + reasoningOutputTokens: 40, + }, + modelContextWindow: 128_000, + }, + }, + }); + + const expected = { + total: { + totalTokens: 1_200, + inputTokens: 800, + cachedInputTokens: 100, + cacheWriteInputTokens: 20, + outputTokens: 300, + reasoningOutputTokens: 80, + }, + last: { + totalTokens: 450, + inputTokens: 300, + cachedInputTokens: 40, + cacheWriteInputTokens: 10, + outputTokens: 100, + reasoningOutputTokens: 40, + }, + modelContextWindow: 128_000, + }; + const snapshot = await adapter.snapshot(); + assert.deepEqual(snapshot.metadata.tokenUsage, expected); + assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected); + assert.equal(snapshot.turnId, null); + + const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated"); + assert.ok(usageEvent); + assert.deepEqual(usageEvent.payload.tokenUsage, expected); + assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected); + // Keep the raw diagnostic envelope redacted while exposing only the safe + // numeric projection to the browser. + assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]"); + + rpc.emitNotification({ + method: "thread/tokenUsage/updated", + params: { + threadId: "thread-test", + turnId: "turn-finished", + tokenUsage: { total: { inputTokens: -1 } }, + }, + }); + assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected); + await adapter.dispose(); +}); + +test("async adapter resumes a thread with structured history and ignores late notifications", async () => { + const thread = { + id: "thread-selected", + name: "Selected thread", + preview: "hello", + cwd: "/tmp/selected", + createdAt: 1_700_000_000, + updatedAt: 1_700_000_010, + status: { type: "idle" }, + turns: [{ + id: "turn-history", + status: "completed", + startedAt: 1_700_000_001, + completedAt: 1_700_000_004, + durationMs: 3_000, + items: [ + { type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] }, + { type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] }, + { type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] }, + { type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" }, + ], + }], + }; + const rpc = new FakeRpc({ + "model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null }, + "thread/resume": { + thread, + model: "gpt-5.6-sol", + modelProvider: "openai", + serviceTier: null, + cwd: "/tmp/selected", + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: { type: "workspaceWrite" }, + reasoningEffort: "high", + }, + }); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + const events = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + + const result = await adapter.selectSession({ threadId: "thread-selected" }); + assert.equal(result.threadId, "thread-selected"); + let snapshot = await adapter.snapshot(); + assert.equal(snapshot.messages.length, 4); + assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]); + assert.equal(snapshot.messages[2].output, "/tmp/selected\n"); + assert.equal(snapshot.metadata.title, "Selected thread"); + assert.equal(snapshot.metadata.threadSettings.effort, "high"); + assert.equal(snapshot.metadata.historyComplete, true); + assert.equal(snapshot.status.turnStatus, "completed"); + assert.match(snapshot.outputTail, /Done/); + assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true)); + const authoritative = events.find((event) => event.type === "session.snapshot"); + assert.equal(authoritative.payload.threadId, "thread-selected"); + assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol"); + assert.equal(authoritative.payload.messages.length, 4); + + rpc.emitNotification({ + method: "item/completed", + params: { + threadId: "thread-old", + turnId: "turn-old", + completedAtMs: Date.now(), + item: { type: "agentMessage", id: "late-old", text: "wrong thread" }, + }, + }); + rpc.emitNotification({ + method: "item/completed", + params: { + threadId: "thread-selected", + turnId: "turn-live", + completedAtMs: Date.now(), + item: { type: "agentMessage", id: "current-item", text: "current thread" }, + }, + }); + snapshot = await adapter.snapshot(); + assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false); + assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true); + await adapter.dispose(); +}); + +test("async adapter hydrates paginated turns and items into chronological complete history", async () => { + const threadId = "thread-paged-history"; + const userItem = (id, text) => ({ + type: "userMessage", + id, + clientId: null, + content: [{ type: "text", text, text_elements: [] }], + }); + const assistantItem = (id, text) => ({ + type: "agentMessage", + id, + text, + phase: "final_answer", + }); + const earlyUser = userItem("early-user", "first question"); + const rpc = new FakeRpc({ + "model/list": { data: [], nextCursor: null }, + "thread/resume": { + thread: { + id: threadId, + name: "Paged history", + preview: "first question", + cwd: "/tmp/paged", + createdAt: 50, + updatedAt: 350, + historyMode: "paginated", + status: { type: "idle" }, + turns: [], + }, + model: "gpt-5.6-sol", + cwd: "/tmp/paged", + initialTurnsPage: { + data: [{ + id: "turn-late", + status: "completed", + startedAt: 300, + completedAt: 310, + itemsView: "full", + items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")], + }], + nextCursor: "turn-page-2", + backwardsCursor: null, + }, + }, + "thread/turns/list": (params) => { + if (params.cursor === "turn-page-2") { + return { + data: [{ + id: "turn-early", + status: "completed", + startedAt: 100, + completedAt: 110, + itemsView: "summary", + items: [earlyUser], + }], + nextCursor: "turn-page-3", + backwardsCursor: null, + }; + } + assert.equal(params.cursor, "turn-page-3"); + return { + data: [{ + id: "turn-middle", + status: "completed", + startedAt: 200, + completedAt: 210, + itemsView: "full", + items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")], + }], + nextCursor: null, + backwardsCursor: null, + }; + }, + "thread/items/list": (params) => { + assert.equal(params.turnId, "turn-early"); + return { + data: [ + // The summary row is repeated by the full item page; hydration must + // de-duplicate it while adding the omitted assistant response. + { turnId: "turn-early", item: earlyUser }, + { turnId: "turn-early", item: assistantItem("early-agent", "first answer") }, + ], + nextCursor: null, + backwardsCursor: null, + }; + }, + }); + const events = []; + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + await adapter.selectSession({ threadId }); + + const resume = rpc.requests.find((entry) => entry.method === "thread/resume"); + assert.deepEqual(resume.params, { + threadId, + excludeTurns: true, + initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" }, + }); + const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list"); + assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]); + assert.ok(turnPages.every((entry) => entry.params.threadId === threadId + && entry.params.limit === 100 + && entry.params.sortDirection === "asc" + && entry.params.itemsView === "full")); + const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list"); + assert.deepEqual(itemPages.map((entry) => entry.params), [{ + threadId, + turnId: "turn-early", + limit: 100, + sortDirection: "asc", + }]); + assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false); + + const snapshot = await adapter.snapshot(); + assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [ + ["turn-early", "first question"], + ["turn-early", "first answer"], + ["turn-middle", "second question"], + ["turn-middle", "second answer"], + ["turn-late", "third question"], + ["turn-late", "third answer"], + ]); + assert.equal(snapshot.metadata.historyComplete, true); + const outputSnapshot = events.find((event) => event.type === "output.snapshot"); + assert.equal(outputSnapshot.payload.historyComplete, true); + assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [ + "first question", + "first answer", + "second question", + "second answer", + "third question", + "third answer", + ]); + await adapter.dispose(); +}); + +test("async adapter falls back to thread/read when resume omits existing history", async () => { + const metadataThread = { + id: "thread-paginated", + preview: "existing conversation", + cwd: "/tmp/project", + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + status: { type: "idle" }, + turns: [], + }; + const rpc = new FakeRpc({ + "model/list": { data: [], nextCursor: null }, + "thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" }, + "thread/read": { + thread: { + ...metadataThread, + turns: [{ + id: "turn-read", + status: "completed", + items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }], + }], + }, + }, + }); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + await adapter.selectSession({ threadId: "thread-paginated" }); + const read = rpc.requests.find((entry) => entry.method === "thread/read"); + assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true }); + assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history"); + await adapter.dispose(); +}); + +test("async adapter starts new sessions and sends flat durable thread settings", async () => { + const rpc = new FakeRpc({ + "model/list": { data: [], nextCursor: null }, + "thread/start": { + thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] }, + model: "gpt-5.6-sol", + cwd: "/tmp/new", + reasoningEffort: "medium", + }, + "thread/settings/update": { ok: true }, + }); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc); + await adapter.start(); + await adapter.newSession({}); + await adapter.updateThreadSettings({ + threadSettings: { + model: "gpt-5.6-terra", + effort: "high", + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandboxPolicy: "workspace-write", + permissions: ":workspace", + }, + }); + const start = rpc.requests.find((entry) => entry.method === "thread/start"); + assert.equal(start.params.cwd, "/tmp/default"); + const update = rpc.requests.find((entry) => entry.method === "thread/settings/update"); + assert.deepEqual(update.params, { + threadId: "thread-new", + model: "gpt-5.6-terra", + effort: "high", + approvalPolicy: "on-request", + approvalsReviewer: "user", + permissions: ":workspace", + }); + assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.model, "gpt-5.6-terra"); + assert.equal(snapshot.metadata.latestReasoningEffort, "high"); + assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write"); + await adapter.dispose(); +}); + +test("thread settings updates require the send_task_input capability", async () => { + const rpc = new FakeRpc(); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + const relay = new FakeRelay(); + const host = new RelayHost({ + adapter, + relay, + capabilities: ["read_output"], + sessionId: "test-session", + }); + + await host.handleFrame({ + kind: "command", + type: "thread.settings.update", + commandId: "settings-without-capability", + actor: { role: "operator" }, + payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } }, + }); + + const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability"); + assert.equal(result.type, "command.rejected"); + assert.match(result.payload.error, /missing capability: send_task_input/); + await adapter.dispose(); +}); + +test("RelayHost exposes session list as read-only and protects session selection", async () => { + const relay = new FakeRelay(); + const calls = []; + const adapter = { + async start() {}, + async sendInput() { return {}; }, + async cancel() { return {}; }, + async respondApproval() { return {}; }, + async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; }, + onEvent() { return { dispose() {} }; }, + async dispose() {}, + async listSessions(params) { + calls.push({ method: "listSessions", params }); + return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" }; + }, + async selectSession(params) { + calls.push({ method: "selectSession", params }); + return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true }; + }, + async newSession(params) { + calls.push({ method: "newSession", params }); + return { opened: true, command: "chatgpt.newCodexPanel" }; + }, + getControlMode() { return "sync"; }, + async setControlMode(params) { + calls.push({ method: "setControlMode", params }); + return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 }; + }, + }; + const host = new RelayHost({ + adapter, + relay, + capabilities: ["read_output", "send_task_input"], + sessionId: "test-session", + }); + + await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} }); + const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1"); + assert.equal(listed.type, "command.accepted"); + assert.equal(listed.payload.result.activeThreadId, "thread-a"); + assert.equal(calls[0].method, "listSessions"); + + await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } }); + const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer"); + assert.equal(denied.type, "command.rejected"); + + await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } }); + const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator"); + assert.equal(selected.type, "command.accepted"); + assert.equal(selected.payload.result.threadId, "thread-b"); + assert.equal(calls.at(-1).method, "selectSession"); + + await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} }); + const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer"); + assert.equal(deniedNew.type, "command.rejected"); + + await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} }); + const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator"); + assert.equal(opened.type, "command.accepted"); + assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel"); + assert.equal(calls.at(-1).method, "newSession"); + + await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} }); + const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer"); + assert.equal(mode.type, "command.accepted"); + assert.equal(mode.payload.result.mode, "sync"); + + await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } }); + const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer"); + assert.equal(deniedMode.type, "command.rejected"); + + await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } }); + const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator"); + assert.equal(changedMode.type, "command.accepted"); + assert.equal(changedMode.payload.result.controlMode, "async"); + assert.equal(calls.at(-1).method, "setControlMode"); +}); + +test("approval decision conflicts and unknown tagged objects fail closed", async () => { + const rpc = new FakeRpc(); + const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc); + await adapter.start(); + const relay = new FakeRelay(); + const host = new RelayHost({ + adapter, + relay, + capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"], + sessionId: "test-session", + }); + + rpc.emitRequest({ + id: 3, + method: "execCommandApproval", + params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] }, + }); + await host.handleFrame({ + kind: "command", + type: "approval.respond", + commandId: "conflicting-response", + actor: { role: "operator" }, + payload: { + requestId: 3, + decision: "deny", + response: { decision: "approved_mcp_policy_amendment" }, + }, + }); + assert.deepEqual(rpc.responses[0], { + id: 3, + result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } }, + }); + + rpc.emitRequest({ + id: 4, + method: "item/commandExecution/requestApproval", + params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" }, + }); + await host.handleFrame({ + kind: "command", + type: "approval.respond", + commandId: "unknown-tagged-response", + actor: { role: "operator" }, + payload: { + requestId: 4, + decision: "allow", + response: { decision: { futurePolicyGrant: { scope: "all" } } }, + }, + }); + assert.deepEqual(rpc.responses[1], { + id: 4, + result: { decision: "decline" }, + }); + await adapter.dispose(); +}); diff --git a/aether-vscodex/test/cloud-server.test.js b/aether-vscodex/test/cloud-server.test.js new file mode 100644 index 000000000..7940c3bea --- /dev/null +++ b/aether-vscodex/test/cloud-server.test.js @@ -0,0 +1,298 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { WebSocket } = require("ws"); + +const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js"); + +const internalToken = "test-internal-token-with-enough-entropy"; + +function internalFetch(base, pathname, options = {}) { + return fetch(`${base}${pathname}`, { + ...options, + headers: { + Authorization: `Bearer ${internalToken}`, + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }, + }); +} + +function websocketClient(base, clientType, token, sessionId) { + const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`); + const messages = []; + const waiters = []; + const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => { + const existing = messages.find(predicate); + if (existing) return resolve(existing); + const timer = setTimeout(() => { + const index = waiters.findIndex((entry) => entry.resolve === resolve); + if (index >= 0) waiters.splice(index, 1); + reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`)); + }, timeout); + waiters.push({ + predicate, + resolve: (message) => { + clearTimeout(timer); + resolve(message); + }, + }); + }); + socket.on("message", (data) => { + const message = JSON.parse(data.toString("utf8")); + messages.push(message); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + if (!waiters[index].predicate(message)) continue; + const waiter = waiters.splice(index, 1)[0]; + waiter.resolve(message); + } + }); + return new Promise((resolve, reject) => { + socket.once("open", () => { + socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) })); + socket.send(JSON.stringify(clientType === "host" + ? { v: 1, kind: "auth", accessToken: token } + : { type: "auth", token })); + wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject); + }); + socket.once("error", reject); + }); +} + +async function pairDevice(base, userId, name) { + const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, { + method: "POST", + body: JSON.stringify({ name }), + }); + assert.equal(pairingResponse.status, 201); + const pairing = await pairingResponse.json(); + const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: pairing.code, name }), + }); + assert.equal(exchangeResponse.status, 201); + return exchangeResponse.json(); +} + +async function browserTicket(base, userId, deviceId) { + const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, { + method: "POST", + body: JSON.stringify({ device_id: deviceId }), + }); + assert.equal(response.status, 201); + return response.json(); +} + +function exchangeAttempt(base, headers = {}) { + return fetch(`${base}/v1/pairings/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ code: "INVALID-CODE" }), + }); +} + +test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-")); + const server = new AetherVscodexCloudServer({ + host: "127.0.0.1", + port: 0, + internalToken, + publicWsUrl: "wss://aether.example/api/vscodex/ws", + dataDir, + }); + await server.start(); + t.after(async () => { + await server.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + const address = server.address(); + const base = `http://127.0.0.1:${address.port}`; + const trustedHeaders = (clientIp) => ({ + Authorization: `Bearer ${internalToken}`, + "X-Aether-Client-IP": clientIp, + }); + + for (let attempt = 0; attempt < 10; attempt += 1) { + assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400); + } + assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429); + assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400); + assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400); + + server.exchangeAttempts.clear(); + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400); + } + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal((await exchangeAttempt(base, { + Authorization: "Bearer invalid-internal-token", + "X-Aether-Client-IP": `198.51.100.${30 + attempt}`, + })).status, 400); + } + assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429); + + server.exchangeAttempts.clear(); + const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"]; + for (let attempt = 0; attempt < 10; attempt += 1) { + assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400); + } + assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429); +}); + +test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-")); + const server = new AetherVscodexCloudServer({ + host: "127.0.0.1", + port: 0, + internalToken, + publicWsUrl: "wss://aether.example/api/vscodex/ws", + dataDir, + pairingTtlMs: 5_000, + ticketTtlMs: 5_000, + }); + await server.start(); + t.after(async () => { + await server.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + const address = server.address(); + const base = `http://127.0.0.1:${address.port}`; + + const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`); + assert.equal(unauthorized.status, 401); + + const paired = await pairDevice(base, "user-a", "MacBook VS Code"); + assert.match(paired.device_token, /^avx1\./); + const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices"); + assert.equal(devicesResponse.status, 200); + const devices = await devicesResponse.json(); + assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [ + { id: paired.device_id, name: "MacBook VS Code", connected: false }, + ]); + + const ticket = await browserTicket(base, "user-a", paired.device_id); + assert.equal(ticket.ws_url, "/api/vscodex/ws"); + const host = await websocketClient(base, "host", paired.device_token, "host-user-a"); + const browser = await websocketClient(base, "web", ticket.ticket); + t.after(() => host.socket.close()); + t.after(() => browser.socket.close()); + browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 })); + + host.socket.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + id: "connection-a", + sessionId: "host-user-a", + seq: 1, + ts: new Date().toISOString(), + payload: {}, + })); + host.socket.send(JSON.stringify({ + v: 1, + kind: "event", + type: "session.snapshot", + id: "snapshot-a", + sessionId: "host-user-a", + seq: 2, + ts: new Date().toISOString(), + payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] }, + })); + const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot"); + assert.equal(snapshot.payload.threadId, "thread-a"); + assert.equal(snapshot.payload.messages[0].text, "user-a-only"); + + browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} })); + const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command"); + assert.equal(command.type, "session/list"); + + const secondUser = await pairDevice(base, "user-b", "Other VS Code"); + const secondTicket = await browserTicket(base, "user-b", secondUser.device_id); + const secondBrowser = await websocketClient(base, "web", secondTicket.ticket); + t.after(() => secondBrowser.socket.close()); + secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 })); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false); + + const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`); + const closed = new Promise((resolve, reject) => { + reusedTicket.once("open", () => { + reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 })); + reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket })); + }); + reusedTicket.once("close", (code) => resolve(code)); + reusedTicket.once("error", reject); + }); + assert.equal(await closed, 1008, "browser tickets are one-time credentials"); +}); + +test("device revocation closes its room and blocks future host authentication", async (t) => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-")); + const server = new AetherVscodexCloudServer({ + host: "127.0.0.1", + port: 0, + internalToken, + publicWsUrl: "wss://aether.example/api/vscodex/ws", + dataDir, + }); + await server.start(); + t.after(async () => { + await server.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + const address = server.address(); + const base = `http://127.0.0.1:${address.port}`; + const paired = await pairDevice(base, "user-a", "Revoked device"); + const host = await websocketClient(base, "host", paired.device_token, "revoked-host"); + + const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" }); + assert.equal(response.status, 204); + await new Promise((resolve) => host.socket.once("close", resolve)); + + const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`); + const closed = new Promise((resolve, reject) => { + rejected.once("open", () => { + rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" })); + rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token })); + }); + rejected.once("close", (code) => resolve(code)); + rejected.once("error", reject); + }); + assert.equal(await closed, 1008); +}); + +test("room revocation wins a concurrent room creation", async () => { + const rooms = new RoomManager(); + let releaseCreation; + const creationGate = new Promise((resolve) => { releaseCreation = resolve; }); + let stopped = false; + const room = { + key: rooms.key("user-a", "device-a"), + userId: "user-a", + deviceId: "device-a", + relay: { stop: async () => { stopped = true; } }, + connections: 0, + lastActiveMs: Date.now(), + }; + rooms.createRoom = async (key) => { + await creationGate; + rooms.rooms.set(key, room); + return room; + }; + + const pendingGet = rooms.get("user-a", "device-a"); + await new Promise((resolve) => setImmediate(resolve)); + const pendingRevoke = rooms.revoke("user-a", "device-a"); + releaseCreation(); + + await assert.rejects(pendingGet, /device revoked/); + await pendingRevoke; + assert.equal(stopped, true); + assert.equal(rooms.rooms.has(room.key), false); + await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/); +}); diff --git a/aether-vscodex/test/codex-ipc-adapter.test.js b/aether-vscodex/test/codex-ipc-adapter.test.js new file mode 100644 index 000000000..6f19ffc66 --- /dev/null +++ b/aether-vscodex/test/codex-ipc-adapter.test.js @@ -0,0 +1,2247 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { CodexIpcAgentAdapter } = require("../vscode-extension/dist/codexIpcAgentAdapter.js"); + +const THREAD_ID = "11111111-1111-4111-8111-111111111111"; +const SECOND_THREAD_ID = "22222222-2222-4222-8222-222222222222"; +const STALE_THREAD_ID = "33333333-3333-4333-8333-333333333333"; +const THIRD_THREAD_ID = "44444444-4444-4444-8444-444444444444"; +const ULID_THREAD_ID = "01a0399e790373d63b6fcde4e1f97d02"; + +async function waitFor(predicate, timeoutMs = 1_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(`condition was not met within ${timeoutMs}ms`); +} + +class FakeIpcClient { + constructor(state, emitSnapshot = true) { + this.socketPath = "/tmp/fake-codex-ipc.sock"; + this.state = state; + this.emitSnapshot = emitSnapshot; + this.broadcastListeners = new Set(); + this.streamListeners = new Set(); + this.errorListeners = new Set(); + this.closeListeners = new Set(); + this.calls = []; + this.streamStates = new Map(); + } + + subscribe(set, listener) { + set.add(listener); + return { dispose: () => set.delete(listener) }; + } + + onBroadcast(listener) { return this.subscribe(this.broadcastListeners, listener); } + onStreamEvent(listener) { return this.subscribe(this.streamListeners, listener); } + onError(listener) { return this.subscribe(this.errorListeners, listener); } + onClose(listener) { return this.subscribe(this.closeListeners, listener); } + getClientId() { return "follower"; } + getConversationState(threadId) { + const state = this.streamStates.get(threadId); + if (!state) return undefined; + return { + ...state, + conversationState: JSON.parse(JSON.stringify(state.conversationState)), + }; + } + async connect() { this.calls.push({ method: "connect" }); return "follower"; } + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + return threadId === THREAD_ID ? "owner" : null; + } + async followConversation(threadId, following, options) { + this.calls.push({ method: "followConversation", threadId, following, options }); + if (!following) { + this.streamStates.delete(threadId); + return; + } + if (this.emitSnapshot) queueMicrotask(() => this.emitState(this.state, 1, "snapshot", threadId)); + } + emitState(state, revision, kind = "snapshot", threadId = THREAD_ID, ownerClientId = "owner") { + const event = { + kind, + conversationId: threadId, + hostId: "local", + ownerClientId, + revision, + conversationState: state, + raw: { type: "broadcast", method: "thread-stream-state-changed", version: 11 }, + }; + this.streamStates.set(threadId, { + conversationId: threadId, + hostId: "local", + ownerClientId, + revision, + conversationState: JSON.parse(JSON.stringify(state)), + }); + for (const listener of this.streamListeners) listener(event); + } + emitFollowing(threadId, following, options = {}) { + const frame = { + type: "broadcast", + method: "thread-stream-following-changed", + version: options.version ?? 1, + sourceClientId: options.sourceClientId ?? "owner", + ...(options.targetClientIds ? { targetClientIds: options.targetClientIds } : {}), + params: { + conversationId: threadId, + hostId: options.hostId ?? "local", + following, + }, + }; + for (const listener of this.broadcastListeners) listener(frame); + } + emitClientStatus(clientId, status, options = {}) { + const frame = { + type: "broadcast", + method: "client-status-changed", + version: options.version ?? 0, + sourceClientId: options.sourceClientId ?? clientId, + params: { + clientId, + clientType: options.clientType ?? "vscode-webview", + status, + }, + }; + for (const listener of this.broadcastListeners) listener(frame); + } + emitClose(error = new Error("fixture IPC socket closed")) { + for (const listener of this.closeListeners) listener(error); + } + async startTurn(threadId, input, options) { + this.calls.push({ method: "startTurn", threadId, input, options }); + return { turnId: "turn-new" }; + } + async steerTurn(threadId, input, options) { + this.calls.push({ method: "steerTurn", threadId, input, options }); + return { turnId: "turn-new" }; + } + async updateThreadSettings(threadId, settings, options) { + this.calls.push({ method: "updateThreadSettings", threadId, settings, options }); + return { updated: true }; + } + async interruptTurn(threadId, options) { + this.calls.push({ method: "interruptTurn", threadId, options }); + return { interrupted: true }; + } + async respondCommandApproval(threadId, requestId, decision, options) { + this.calls.push({ method: "respondCommandApproval", threadId, requestId, decision, options }); + return { accepted: true }; + } + async respondFileApproval(threadId, requestId, decision, options) { + this.calls.push({ method: "respondFileApproval", threadId, requestId, decision, options }); + return { accepted: true }; + } + async respondPermissionsApproval(threadId, requestId, response, options) { + this.calls.push({ method: "respondPermissionsApproval", threadId, requestId, response, options }); + return { accepted: true }; + } + async respondUserInput(threadId, requestId, response, options) { + this.calls.push({ method: "respondUserInput", threadId, requestId, response, options }); + return { accepted: true }; + } + async respondMcpElicitation(threadId, requestId, response, options) { + this.calls.push({ method: "respondMcpElicitation", threadId, requestId, response, options }); + return { accepted: true }; + } + async loadCompleteHistory() { this.calls.push({ method: "loadCompleteHistory" }); } + async dispose() { this.calls.push({ method: "dispose" }); } +} + +class DiscoveryIpcClient extends FakeIpcClient { + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + return "owner"; + } +} + +class MultiSessionIpcClient extends FakeIpcClient { + constructor(states, owners = {}) { + super(states.get(THREAD_ID)); + this.states = states; + this.owners = owners; + } + + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + return this.owners[threadId] || null; + } + + async followConversation(threadId, following, options) { + this.calls.push({ method: "followConversation", threadId, following, options }); + if (!following) { + this.streamStates.delete(threadId); + return; + } + if (this.states.has(threadId)) { + const owner = this.owners[threadId] || "owner"; + queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner)); + } + } +} + +/** Simulates a target owned by another Codex process that never answers follow. */ +class NoSnapshotSwitchIpcClient extends MultiSessionIpcClient { + constructor(states, owners = {}) { + super(states, owners); + this.targetFollowFailed = false; + } + + async followConversation(threadId, following, options) { + this.calls.push({ method: "followConversation", threadId, following, options }); + if (!following) { + this.streamStates.delete(threadId); + return; + } + if (threadId === SECOND_THREAD_ID) { + this.targetFollowFailed = true; + return; + } + // The old owner is deliberately silent after the failed target attach; + // the adapter must restore from its cached stream state instead. + if (this.targetFollowFailed && threadId === THREAD_ID) return; + if (this.states.has(threadId)) { + const owner = this.owners[threadId] || "owner"; + queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner)); + } + } +} + +class ThrowingTargetFollowIpcClient extends MultiSessionIpcClient { + async followConversation(threadId, following, options) { + if (threadId === SECOND_THREAD_ID && following) { + this.calls.push({ method: "followConversation", threadId, following, options }); + throw new Error("target follow failed immediately"); + } + return super.followConversation(threadId, following, options); + } +} + +class OwnerChangingIpcClient extends MultiSessionIpcClient { + constructor(states, owners = {}) { + super(states, owners); + this.targetDiscoveryCount = 0; + } + + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + if (threadId === SECOND_THREAD_ID) { + this.targetDiscoveryCount += 1; + return this.targetDiscoveryCount === 1 ? "owner-b" : "owner-c"; + } + return this.owners[threadId] || null; + } +} + +/** Delays the first target snapshot so list probing overlaps a session selection request. */ +class DelayedProbeIpcClient extends MultiSessionIpcClient { + constructor(states, owners = {}) { + super(states, owners); + this.targetFollowCount = 0; + } + + async followConversation(threadId, following, options) { + this.calls.push({ method: "followConversation", threadId, following, options }); + if (!following) { + this.streamStates.delete(threadId); + return; + } + if (!this.states.has(threadId)) return; + const owner = this.owners[threadId] || "owner"; + if (threadId === SECOND_THREAD_ID) { + this.targetFollowCount += 1; + const delay = this.targetFollowCount === 1 ? 30 : 0; + setTimeout(() => this.emitState(this.states.get(threadId), this.targetFollowCount, "snapshot", threadId, owner), delay); + return; + } + queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner)); + } +} + +/** Delays the first waiting attach so a newer official route can supersede it. */ +class WaitingRouteRaceIpcClient extends MultiSessionIpcClient { + async followConversation(threadId, following, options) { + this.calls.push({ method: "followConversation", threadId, following, options }); + if (!following) { + this.streamStates.delete(threadId); + return; + } + if (!this.states.has(threadId)) return; + const owner = this.owners[threadId] || "owner"; + const delay = threadId === THREAD_ID ? 30 : 0; + setTimeout(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner), delay); + } +} + +/** Holds fallback discovery so an official route can arrive while it is stale. */ +class WaitingPollRouteRaceIpcClient extends WaitingRouteRaceIpcClient { + constructor(states, owners = {}) { + super(states, owners); + this.delayFallbackDiscovery = false; + this.fallbackDiscoveryStarted = false; + this.fallbackOwner = new Promise((resolve) => { + this.resolveFallbackOwner = resolve; + }); + } + + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + if (threadId === SECOND_THREAD_ID && this.delayFallbackDiscovery) { + this.fallbackDiscoveryStarted = true; + return this.fallbackOwner; + } + return this.owners[threadId] || null; + } + + releaseFallbackDiscovery() { + this.delayFallbackDiscovery = false; + const resolve = this.resolveFallbackOwner; + this.resolveFallbackOwner = null; + if (resolve) resolve(this.owners[SECOND_THREAD_ID] || null); + } +} + +/** Holds the post-snapshot owner confirmation so assertions run mid-switch. */ +class DelayedTargetOwnerConfirmationIpcClient extends MultiSessionIpcClient { + constructor(states, owners = {}) { + super(states, owners); + this.targetDiscoveryCount = 0; + this.targetConfirmationStarted = false; + this.targetOwnerConfirmation = new Promise((resolve) => { + this.resolveTargetOwnerConfirmation = resolve; + }); + } + + async findThreadOwner(threadId) { + this.calls.push({ method: "findThreadOwner", threadId }); + const owner = this.owners[threadId] || null; + if (threadId !== SECOND_THREAD_ID) return owner; + this.targetDiscoveryCount += 1; + if (this.targetDiscoveryCount === 1) return owner; + this.targetConfirmationStarted = true; + return this.targetOwnerConfirmation; + } + + releaseTargetOwnerConfirmation() { + if (!this.resolveTargetOwnerConfirmation) return; + const resolve = this.resolveTargetOwnerConfirmation; + this.resolveTargetOwnerConfirmation = null; + resolve(this.owners[SECOND_THREAD_ID] || null); + } +} + +function fixtureState(requests = []) { + return { + id: THREAD_ID, + title: "fixture session", + cwd: "/tmp/workspace", + turns: [{ + id: "turn-old", + status: "completed", + items: [ + { type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello" }] }, + { type: "agentMessage", id: "agent-1", text: "hi from VS Code" }, + ], + }], + requests, + threadRuntimeStatus: { type: "idle" }, + }; +} + +test("IPC adapter follows an existing session and routes input/approval without spawning", async () => { + const client = new FakeIpcClient(fixtureState([ + { + id: 7, + method: "item/commandExecution/requestApproval", + params: { threadId: THREAD_ID, turnId: "turn-old", command: "echo safe" }, + }, + { + id: "question-1", + method: "item/tool/requestUserInput", + params: { threadId: THREAD_ID, turnId: "turn-old", questions: [{ id: "choice", question: "Pick one" }] }, + }, + ])); + const events = []; + let newSessionCalls = 0; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + openNewSession: async () => { + newSessionCalls += 1; + return { opened: true, command: "chatgpt.newCodexPanel" }; + }, + }); + adapter.onEvent((event) => events.push(event)); + + await adapter.start(); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.threadId, THREAD_ID); + assert.equal(snapshot.metadata.adapter, "codex-ipc-follower"); + assert.match(snapshot.outputTail, /hi from VS Code/); + assert.deepEqual(snapshot.pendingApprovals.map((item) => item.requestId), [7]); + assert.equal(snapshot.pendingRequests.length, 2); + assert.equal(client.calls.some((call) => call.method === "startProcess"), false); + await assert.rejects(() => adapter.startThread({}), /does not create a new thread/); + assert.deepEqual(await adapter.newSession(), { opened: true, command: "chatgpt.newCodexPanel" }); + assert.equal(newSessionCalls, 1); + + await adapter.startTurn({ text: "remote input" }); + const startCall = client.calls.find((call) => call.method === "startTurn"); + assert.deepEqual(startCall.input, "remote input"); + assert.equal(startCall.options.ownerClientId, "owner"); + + await adapter.respondApproval(7, "allow"); + const approvalCall = client.calls.find((call) => call.method === "respondCommandApproval"); + assert.equal(approvalCall.decision, "accept"); + + await adapter.respondApproval("question-1", "allow", undefined, { answers: { choice: ["yes"] } }); + const inputCall = client.calls.find((call) => call.method === "respondUserInput"); + assert.deepEqual(inputCall.response, { answers: { choice: { answers: ["yes"] } } }); + const outputSnapshot = events.find((event) => event.type === "output.snapshot"); + assert.ok(outputSnapshot); + assert.deepEqual(outputSnapshot.payload.messages.map((item) => [item.role, item.kind, item.text]), [ + ["user", "user", "hello"], + ["assistant", "assistant", "hi from VS Code"], + ]); + await adapter.dispose(); +}); + +test("IPC adapter clears stale projections on close while preserving closed session identity", async () => { + const client = new FakeIpcClient(fixtureState()); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + + const before = await adapter.snapshot(); + assert.equal(before.threadId, THREAD_ID); + assert.equal(before.metadata.ownerClientId, "owner"); + assert.equal(before.metadata.attachReady, true); + assert.ok(before.messages.length > 0); + assert.match(before.outputTail, /hi from VS Code/); + events.length = 0; + + client.emitClose(new Error("fixture IPC connection lost")); + + const closedEvent = events.find((event) => event.type === "connection.closed"); + assert.ok(closedEvent); + assert.equal(closedEvent.threadId, THREAD_ID); + assert.equal(closedEvent.payload.ownerClientId, "owner"); + assert.equal(closedEvent.payload.message, "fixture IPC connection lost"); + + const after = await adapter.snapshot(); + assert.equal(after.state, "disconnected"); + assert.equal(after.threadId, null); + assert.equal(after.turnId, null); + assert.equal(after.outputTail, ""); + assert.deepEqual(after.messages, []); + assert.deepEqual(after.subagents, []); + assert.equal(after.metadata.attachReady, false); + assert.equal(Object.hasOwn(after.metadata, "ownerClientId"), false); + assert.equal(Object.hasOwn(after.metadata, "revision"), false); + await adapter.dispose(); +}); + +test("IPC adapter persists model and effort through the official follower settings envelope", async () => { + const client = new FakeIpcClient(fixtureState()); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + + await adapter.start(); + const result = await adapter.updateThreadSettings({ + threadSettings: { model: " gpt-5.6-sol ", effort: " ultra " }, + // UI-only fields must not leak into the owner request. + commandId: "ignored", + }); + assert.deepEqual(result, { updated: true }); + const call = client.calls.find((entry) => entry.method === "updateThreadSettings"); + assert.equal(call.threadId, THREAD_ID); + assert.deepEqual(call.settings, { model: "gpt-5.6-sol", effort: "ultra" }); + assert.equal(call.options.ownerClientId, "owner"); + await assert.rejects(() => adapter.updateThreadSettings({ model: "" }), /non-empty string/); + await adapter.dispose(); +}); + +test("IPC adapter projects official latest model and reasoning effort fields", async () => { + const state = fixtureState(); + state.latestModel = "gpt-5.6-sol"; + state.latestReasoningEffort = "ultra"; + state.latestThreadSettings = { + model: "gpt-5.6-sol", + modelProvider: "aether", + effort: "ultra", + multiAgentMode: "explicitRequestOnly", + }; + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const metadata = (await adapter.snapshot()).metadata; + assert.equal(metadata.model, "gpt-5.6-sol"); + assert.equal(metadata.latestModel, "gpt-5.6-sol"); + assert.equal(metadata.effort, "ultra"); + assert.equal(metadata.latestReasoningEffort, "ultra"); + assert.equal(metadata.modelProvider, "aether"); + await adapter.dispose(); +}); + +test("IPC adapter projects a bounded model catalog from compatible state locations", async () => { + const state = fixtureState(); + // Exercise all of the state names used by different official extension + // builds. The same entries should be merged rather than duplicated. + state.availableModels = [{ model: "gpt-5.6-sol" }]; + state.models = [ + { model: "gpt-5.6-sol", description: "initial description" }, + { id: "gpt-5.6-terra", displayName: "5.6 Terra", efforts: ["low", "medium"] }, + ]; + state.modelCatalog = { + data: [{ + id: "gpt-5.6-sol", + model: "gpt-5.6-sol", + displayName: "5.6 Sol", + description: "通用 Codex 模型", + hidden: false, + isDefault: true, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "快速" }, + { reasoningEffort: "high", description: "深入" }, + ], + apiKey: "sk-this-must-not-cross-the-relay", + capabilities: { internal: true }, + }], + nextCursor: "private-cursor", + }; + state.listModels = { data: [{ model: "gpt-5.6-terra", hidden: false }] }; + + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + const metadata = (await adapter.snapshot()).metadata; + assert.ok(Array.isArray(metadata.availableModels)); + assert.deepEqual(metadata.models, metadata.availableModels); + assert.equal(metadata.availableModels.length, 2); + const sol = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-sol"); + assert.deepEqual(sol, { + model: "gpt-5.6-sol", + id: "gpt-5.6-sol", + description: "initial description", + displayName: "5.6 Sol", + hidden: false, + isDefault: true, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "快速" }, + { reasoningEffort: "high", description: "深入" }, + ], + }); + const terra = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-terra"); + assert.deepEqual(terra, { + model: "gpt-5.6-terra", + id: "gpt-5.6-terra", + displayName: "5.6 Terra", + supportedReasoningEfforts: [ + { reasoningEffort: "low" }, + { reasoningEffort: "medium" }, + ], + hidden: false, + }); + assert.equal(JSON.stringify(metadata.availableModels).includes("sk-this-must-not-cross-the-relay"), false); + assert.equal(JSON.stringify(metadata.availableModels).includes("capabilities"), false); + await adapter.dispose(); +}); + +test("IPC adapter publishes a snapshot when only thread settings metadata changes", async () => { + const state = fixtureState(); + state.latestModel = "gpt-5.6-terra"; + state.latestReasoningEffort = "medium"; + state.latestThreadSettings = { model: "gpt-5.6-terra", effort: "medium" }; + const client = new FakeIpcClient(state); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + await new Promise((resolve) => setImmediate(resolve)); + events.length = 0; + + const updated = structuredClone(state); + updated.latestModel = "gpt-5.6-sol"; + updated.latestReasoningEffort = "ultra"; + updated.latestThreadSettings = { model: "gpt-5.6-sol", effort: "ultra" }; + client.emitState(updated, 2, "patches"); + await new Promise((resolve) => setImmediate(resolve)); + + const snapshots = events.filter((event) => event.type === "session.snapshot"); + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].payload.metadata.latestModel, "gpt-5.6-sol"); + assert.equal(snapshots[0].payload.metadata.latestReasoningEffort, "ultra"); + await adapter.dispose(); +}); + +test("IPC adapter projects official activity, timestamps, command details, and turn duration", async () => { + const turnStartedAtMs = Date.now() - 20_000; + const finalAssistantStartedAtMs = turnStartedAtMs + 15_000; + const state = fixtureState(); + state.turns[0] = { + id: "turn-timed", + status: "completed", + turnStartedAtMs, + finalAssistantStartedAtMs, + durationMs: 18_000, + commandExecutionStartedAtMsById: { "command-1": turnStartedAtMs + 2_000 }, + items: [ + { type: "userMessage", id: "user-timed", content: [{ type: "text", text: "**run** it" }] }, + { + type: "commandExecution", + id: "command-1", + command: ["/bin/zsh", "-lc", "echo status-test"], + commandActions: [ + { type: "unknown", command: "/bin/zsh" }, + { type: "unknown", cmd: "echo status-test" }, + ], + cwd: "/tmp/workspace", + shellName: "zsh", + status: "completed", + aggregatedOutput: "status-test", + durationMs: 250, + exitCode: 0, + }, + { type: "agentMessage", id: "agent-timed", text: "done", phase: "final_answer" }, + ], + }; + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.status.activity, "completed"); + assert.equal(snapshot.durationMs, 18_000); + const [user, command, assistant] = snapshot.messages; + assert.equal(user.startedAtMs, turnStartedAtMs); + assert.equal(command.command, "echo status-test"); + assert.deepEqual(command.commandActions, [ + { type: "unknown", command: "/bin/zsh", cmd: "/bin/zsh" }, + { type: "unknown", cmd: "echo status-test", command: "echo status-test" }, + ]); + assert.equal(command.cwd, "/tmp/workspace"); + assert.equal(command.shellName, "zsh"); + assert.equal(command.startedAtMs, turnStartedAtMs + 2_000); + assert.equal(command.durationMs, 250); + assert.equal(command.exitCode, 0); + assert.equal(assistant.startedAtMs, finalAssistantStartedAtMs); + assert.equal(assistant.durationMs, 18_000); + + const active = structuredClone(state); + active.turns[0].status = "inProgress"; + delete active.turns[0].durationMs; + active.turns[0].items = [{ type: "reasoning", id: "reasoning-1", summary: ["checking"] }]; + client.emitState(active, 2, "patches"); + const activeSnapshot = await adapter.snapshot(); + assert.equal(activeSnapshot.activity, "thinking"); + assert.equal(activeSnapshot.turnStatus, "inprogress"); + assert.equal(activeSnapshot.turnId, "turn-timed"); + assert.equal(activeSnapshot.messages[0].turnStatus, "inProgress"); + + active.turns[0].items = [{ + type: "fileChange", + id: "edit-1", + status: "inProgress", + changes: [{ path: "src/example.ts", diff: "+const remote = true;" }], + }]; + client.emitState(active, 3, "patches"); + const editingSnapshot = await adapter.snapshot(); + assert.equal(editingSnapshot.activity, "editing"); + + active.turns[0].items = [{ + type: "commandExecution", + id: "command-active", + status: "inProgress", + command: "echo running", + }]; + client.emitState(active, 4, "patches"); + const runningSnapshot = await adapter.snapshot(); + assert.equal(runningSnapshot.activity, "running"); + + active.requests = [{ + id: "approval-active", + method: "item/commandExecution/requestApproval", + params: { threadId: THREAD_ID, turnId: "turn-timed", command: "echo approve" }, + }]; + client.emitState(active, 5, "patches"); + const waitingSnapshot = await adapter.snapshot(); + assert.equal(waitingSnapshot.activity, "waiting_approval"); + await adapter.dispose(); +}); + +test("IPC adapter keeps command-action-only items visible and suppresses shell bootstraps", async () => { + const state = fixtureState(); + state.turns[0] = { + id: "turn-command-actions", + status: "inProgress", + items: [ + { + // Older snapshots can expose a generic shell item while retaining + // the official commandActions payload. + type: "shell", + id: "command-actions-only", + status: "inProgress", + aggregatedOutput: "", + commandActions: [ + { type: "unknown", command: "/bin/zsh" }, + { type: "search", cmd: "rg --files", path: "src" }, + ], + cwd: "/tmp/workspace", + shellName: "zsh", + }, + { + type: "commandExecution", + id: "shell-bootstrap-only", + status: "inProgress", + aggregatedOutput: "", + command: "/bin/zsh", + commandActions: [{ type: "unknown", command: "/bin/zsh" }], + }, + { + type: "commandExecution", + id: "wrapped-command", + status: "inProgress", + aggregatedOutput: "", + command: "/bin/zsh -lc 'printf wrapped'", + }, + { + type: "commandExecution", + id: "wrapped-action", + status: "inProgress", + aggregatedOutput: "", + commandActions: ["/bin/zsh", "/bin/zsh -lc 'printf action-wrapped'"], + }, + ], + }; + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.messages.length, 3); + const actionsOnly = snapshot.messages.find((message) => message.itemId === "command-actions-only"); + assert.ok(actionsOnly); + assert.equal(actionsOnly.command, "rg --files"); + assert.equal(actionsOnly.text, "rg --files"); + assert.equal(actionsOnly.cwd, "/tmp/workspace"); + assert.equal(actionsOnly.shellName, "zsh"); + const wrapped = snapshot.messages.find((message) => message.itemId === "wrapped-command"); + assert.ok(wrapped); + assert.equal(wrapped.command, "'printf wrapped'"); + assert.equal(wrapped.text, "'printf wrapped'"); + const wrappedAction = snapshot.messages.find((message) => message.itemId === "wrapped-action"); + assert.ok(wrappedAction); + assert.equal(wrappedAction.command, "'printf action-wrapped'"); + assert.equal(wrappedAction.text, "'printf action-wrapped'"); + assert.doesNotMatch(snapshot.outputTail, /\/bin\/zsh/); + await adapter.dispose(); +}); + +test("IPC adapter projects collab items, metadata envelopes, and subagent lifecycle", async () => { + const childThreadId = "22222222-2222-4222-8222-222222222222"; + const state = fixtureState(); + state.id = THREAD_ID; + state.turns[0] = { + id: "turn-subagents", + status: "inProgress", + turnStartedAtMs: Date.now() - 2_000, + items: [ + { type: "userMessage", id: "user-subagents", content: [{ type: "text", text: "inspect this" }] }, + { + type: "agentMessage", + id: "message-with-collab-metadata", + text: "I am delegating this review", + metadata: { + codex_collab_agent_tool_call: { + type: "collabAgentToolCall", + id: "collab-spawn-1", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: THREAD_ID, + receiverThreadIds: [childThreadId], + prompt: "Inspect the tests", + model: "gpt-5.6", + reasoningEffort: "high", + agentsStates: { [childThreadId]: { status: "running", message: null } }, + }, + }, + }, + { + type: "subAgentActivity", + id: "activity-started-1", + kind: "started", + agentThreadId: childThreadId, + agentPath: "root/reviewer_agent", + }, + ], + }; + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + let snapshot = await adapter.snapshot(); + assert.equal(snapshot.subagents.length, 1); + assert.equal(snapshot.subagents[0].threadId, childThreadId); + assert.equal(snapshot.subagents[0].displayName, "Reviewer agent"); + assert.equal(snapshot.subagents[0].prompt, "Inspect the tests"); + assert.equal(snapshot.subagents[0].objective, "Inspect the tests"); + assert.equal(snapshot.subagents[0].status, "working"); + assert.equal(snapshot.subagents[0].model, "gpt-5.6"); + assert.equal(snapshot.subagents[0].canInteract, true); + assert.ok(snapshot.messages.some((message) => message.itemType === "collabAgentToolCall" && message.uiType === "multi-agent-action")); + assert.ok(snapshot.messages.some((message) => message.itemType === "subAgentActivity" && message.uiType === "subagent-activity")); + + const completed = structuredClone(state); + completed.turns[0].status = "completed"; + completed.turns[0].items.push({ + type: "collabAgentToolCall", + id: "collab-wait-1", + tool: "wait", + status: "completed", + senderThreadId: THREAD_ID, + receiverThreadIds: [], + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + }); + client.emitState(completed, 2, "patches"); + snapshot = await adapter.snapshot(); + assert.equal(snapshot.subagents[0].status, "done"); + await adapter.dispose(); +}); + +test("IPC adapter discovers pending requests retained inside official turn items", async () => { + const state = fixtureState(); + state.turns[0].status = "inProgress"; + state.turns[0].items.push({ + type: "permission-request", + id: "permission-in-turn", + threadId: THREAD_ID, + turnId: "turn-old", + summary: "需要访问工作区", + permissions: { fileSystem: { write: true } }, + }); + const client = new FakeIpcClient(state); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.pendingRequests.length, 1); + assert.equal(snapshot.pendingRequests[0].requestId, "permission-in-turn"); + assert.equal(snapshot.pendingRequests[0].method, "item/permissions/requestApproval"); + assert.deepEqual(snapshot.pendingRequests[0].params.permissions, { fileSystem: { write: true } }); + assert.equal(snapshot.messages.some((message) => message && message.itemId === "permission-in-turn"), false); + await adapter.dispose(); +}); + +test("IPC adapter expires unanswered requests and rolls back a failed follow", async () => { + const client = new FakeIpcClient(fixtureState([{ + id: 8, + method: "item/commandExecution/requestApproval", + params: { threadId: THREAD_ID, command: "echo timeout" }, + }])); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 25, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.ok(events.some((event) => event.type === "approval.expired" && event.requestId === 8)); + assert.equal((await adapter.snapshot()).pendingApprovals.length, 0); + const expiryResponse = client.calls.find((call) => call.method === "respondCommandApproval"); + assert.equal(expiryResponse.decision, "decline"); + await adapter.dispose(); + + const noSnapshotClient = new FakeIpcClient(fixtureState(), false); + const failed = new CodexIpcAgentAdapter({ + client: noSnapshotClient, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 15, + }); + await assert.rejects(() => failed.start(), /Timed out waiting for a snapshot/); + assert.equal((await failed.snapshot()).state, "disconnected"); + assert.equal((await failed.snapshot()).threadId, null); + await failed.dispose(); +}); + +test("IPC adapter stays available while waiting for the first VS Code Codex session", async (t) => { + // An empty rollout directory represents a freshly opened VS Code window + // whose Codex panel has not created/selected a conversation yet. Starting + // the relay must remain possible so a later panel navigation can attach + // without restarting the bridge. + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-no-session-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const client = new FakeIpcClient(fixtureState()); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 25, + followTimeoutMs: 50, + }); + adapter.onEvent((event) => events.push(event)); + + await assert.doesNotReject(() => adapter.start()); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.threadId, null); + assert.equal(snapshot.state, "waiting_for_host"); + assert.equal(snapshot.metadata?.waitingForSession, true); + assert.equal(snapshot.metadata?.attachReady, false); + assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false); + assert.ok(events.some((event) => event.type === "connection.opened" && event.threadId === undefined)); + + await adapter.dispose(); +}); + +test("IPC adapter attaches in-place when the official panel selects a session after waiting", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-route-after-wait-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const client = new FakeIpcClient(fixtureState()); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 100, + followTimeoutMs: 250, + }); + adapter.onEvent((event) => events.push(event)); + + await adapter.start(); + assert.equal((await adapter.snapshot()).state, "waiting_for_host"); + // The official webview broadcasts this untargeted route update when the + // user opens a conversation. The bridge should attach over the same IPC + // client rather than asking the user to restart the command. + client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" }); + await waitFor(async () => (await adapter.snapshot()).threadId === THREAD_ID); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.state, "idle"); + assert.equal(snapshot.metadata.waitingForSession, false); + assert.equal(snapshot.metadata.attachReady, true); + assert.equal(client.calls.filter((call) => call.method === "followConversation" && call.following === true).length, 1); + assert.equal(events.filter((event) => event.type === "connection.opened").length, 1); + assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID)); + await adapter.dispose(); +}); + +test("IPC waiting attach follows the latest official route when selection changes mid-snapshot", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-route-race-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "latest route" }], + ]); + const client = new WaitingRouteRaceIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 100, + followTimeoutMs: 250, + vscodeSessionFollowDebounceMs: 0, + }); + + await adapter.start(); + client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" }); + await waitFor(() => client.calls.some((call) => call.method === "followConversation" + && call.threadId === THREAD_ID && call.following === true)); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "official-vscode-panel" }); + await waitFor(async () => { + const snapshot = await adapter.snapshot(); + return snapshot.threadId === SECOND_THREAD_ID && snapshot.metadata.attachReady === true; + }); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.attachReady, true); + assert.equal(snapshot.metadata.title, "latest route"); + assert.ok(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === true)); + await adapter.dispose(); +}); + +test("IPC waiting discovery never overrides a newer official VS Code route", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-poll-route-race-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "stale fallback" }], + ]); + const client = new WaitingPollRouteRaceIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 100, + followTimeoutMs: 250, + vscodeSessionFollowDebounceMs: 0, + }); + + await adapter.start(); + assert.equal((await adapter.snapshot()).state, "waiting_for_host"); + + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + await fs.writeFile(path.join(sessions, `rollout-${SECOND_THREAD_ID}.jsonl`), `${JSON.stringify({ + type: "session_meta", + payload: { originator: "codex_vscode", source: "vscode", cwd: "/tmp/stale-fallback" }, + })}\n`); + + client.delayFallbackDiscovery = true; + const fallbackDiscovery = adapter.runWaitingDiscovery(); + await waitFor(() => client.fallbackDiscoveryStarted); + client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" }); + await waitFor(() => client.calls.some((call) => call.method === "followConversation" + && call.threadId === THREAD_ID && call.following === true)); + client.releaseFallbackDiscovery(); + await fallbackDiscovery; + await waitFor(async () => (await adapter.snapshot()).metadata.attachReady === true); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.threadId, THREAD_ID); + assert.equal(snapshot.metadata.title, "fixture session"); + assert.equal(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === true), false); + await adapter.dispose(); +}); + +test("IPC adapter waits when the configured VS Code conversation has no live owner", async () => { + const client = new FakeIpcClient(fixtureState()); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: STALE_THREAD_ID, + autoDiscoverThread: false, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 25, + followTimeoutMs: 50, + }); + + await assert.doesNotReject(() => adapter.start()); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.threadId, null); + assert.equal(snapshot.state, "waiting_for_host"); + assert.equal(snapshot.metadata?.waitingForSession, true); + assert.equal(snapshot.metadata?.attachReady, false); + assert.ok(client.calls.some((call) => call.method === "findThreadOwner" && call.threadId === STALE_THREAD_ID)); + assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false); + + await adapter.dispose(); +}); + +test("IPC adapter preserves official request timestamps and streams a sliding output tail as a delta", async () => { + const startedAtMs = Date.now() - 200; + const initial = fixtureState([{ + id: 9, + method: "item/commandExecution/requestApproval", + createdAt: Date.now(), + params: { threadId: THREAD_ID, command: "echo timestamp", startedAtMs }, + }]); + initial.turns[0].items = [{ type: "agentMessage", id: "streaming", text: "x".repeat(40) }]; + const client = new FakeIpcClient(initial); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 10_000, + maxOutputTailChars: 32, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + const pending = (await adapter.snapshot()).pendingApprovals[0]; + assert.equal(pending.createdAt, startedAtMs); + + const next = structuredClone(initial); + next.turns[0].items[0].text += "y"; + client.emitState(next, 2); + const outputEvents = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk"); + assert.equal(outputEvents.at(-1).type, "output.chunk"); + assert.equal(outputEvents.at(-1).payload.text, "y"); + assert.equal(outputEvents.at(-1).payload.messages, undefined); + assert.equal(outputEvents.at(-1).payload.messagesPatch.start, 0); + assert.equal(outputEvents.at(-1).payload.messagesPatch.deleteCount, 1); + assert.equal(outputEvents.at(-1).payload.messagesPatch.messages[0].text, `${"x".repeat(40)}y`); + + // The bounded tail can remain byte-for-byte identical when a repeated + // character arrives. It must still advance by one chunk using total length. + const repeated = structuredClone(next); + repeated.turns[0].items[0].text += "x"; + client.emitState(repeated, 3); + const repeatedEvent = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk").at(-1); + assert.equal(repeatedEvent.type, "output.chunk"); + assert.equal(repeatedEvent.payload.text, "x"); + assert.equal(repeatedEvent.payload.messagesPatch.messages[0].text, `${"x".repeat(40)}yx`); + await adapter.dispose(); +}); + +test("IPC adapter normalizes epoch-second approval timestamps", async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const startedSeconds = nowSeconds - 2; + const expiresSeconds = nowSeconds + 60; + const initial = fixtureState([ + { + id: 10, + method: "item/commandExecution/requestApproval", + createdAt: startedSeconds, + expiresAt: expiresSeconds, + params: { threadId: THREAD_ID, command: "echo outer seconds" }, + }, + { + id: 11, + method: "item/commandExecution/requestApproval", + params: { + threadId: THREAD_ID, + command: "echo nested seconds", + startedAt: String(startedSeconds), + expiresAt: String(expiresSeconds), + }, + }, + ]); + const client = new FakeIpcClient(initial); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 10_000, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + + await adapter.start(); + const pending = (await adapter.snapshot()).pendingApprovals; + assert.equal(pending.length, 2); + for (const approval of pending) { + assert.equal(approval.createdAt, startedSeconds * 1000); + assert.equal(approval.expiresAt, expiresSeconds * 1000); + } + assert.equal(events.some((event) => event.type === "approval.expired"), false); + await adapter.dispose(); +}); + +test("IPC adapter waits for the revision returned by complete-history loading", async () => { + const initial = fixtureState(); + initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false }; + const complete = fixtureState(); + complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false }; + class HistoryClient extends FakeIpcClient { + async loadCompleteHistory() { + this.calls.push({ method: "loadCompleteHistory" }); + // The owner can acknowledge the request before the stream broadcast. + // An unrelated owner's revision must not release our waiter. + setTimeout(() => this.emitState(initial, 2, "snapshot", THREAD_ID, "other-owner"), 0); + setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 10); + return { revision: 2 }; + } + } + const client = new HistoryClient(initial); + const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 }); + await adapter.start(); + await new Promise((resolve) => setTimeout(resolve, 5)); + // The unrelated owner event at t=0 must not overwrite the attached stream. + assert.equal((await adapter.snapshot()).metadata.historyComplete, false); + await new Promise((resolve) => setTimeout(resolve, 20)); + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.historyComplete, true); + assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1); + await adapter.dispose(); +}); + +test("IPC adapter retries complete-history loading once after a transient failure", async () => { + const initial = fixtureState(); + initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false }; + const complete = fixtureState(); + complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false }; + class RetryingHistoryClient extends FakeIpcClient { + async loadCompleteHistory() { + this.calls.push({ method: "loadCompleteHistory" }); + const attempts = this.calls.filter((call) => call.method === "loadCompleteHistory").length; + if (attempts === 1) throw Object.assign(new Error("history request timed out"), { code: "timeout" }); + setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 5); + return { revision: 2 }; + } + } + const client = new RetryingHistoryClient(initial); + const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 }); + await adapter.start(); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 2); + assert.equal((await adapter.snapshot()).metadata.historyComplete, true); + await adapter.dispose(); +}); + +test("IPC adapter cancels a pending history retry when switching sessions", async () => { + const initial = fixtureState(); + initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false }; + const states = new Map([[THREAD_ID, initial], [SECOND_THREAD_ID, fixtureState()]]); + class SwitchingHistoryClient extends MultiSessionIpcClient { + async loadCompleteHistory(threadId) { + this.calls.push({ method: "loadCompleteHistory", threadId }); + throw Object.assign(new Error("history request timed out"), { code: "timeout" }); + } + } + const client = new SwitchingHistoryClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 }); + await adapter.start(); + await new Promise((resolve) => setImmediate(resolve)); + await adapter.selectSession({ threadId: SECOND_THREAD_ID }); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.deepEqual(client.calls.filter((call) => call.method === "loadCompleteHistory").map((call) => call.threadId), [THREAD_ID]); + assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID); + await adapter.dispose(); +}); + +test("IPC adapter cancels a pending history retry when disposed", async () => { + const initial = fixtureState(); + initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false }; + class DisposedHistoryClient extends FakeIpcClient { + async loadCompleteHistory() { + this.calls.push({ method: "loadCompleteHistory" }); + throw Object.assign(new Error("history request timed out"), { code: "timeout" }); + } + } + const client = new DisposedHistoryClient(initial); + const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 }); + await adapter.start(); + await new Promise((resolve) => setImmediate(resolve)); + await adapter.dispose(); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1); +}); + +test("IPC adapter reports canonical history incomplete until one complete island and all item pages", async () => { + const state = fixtureState(); + state.turns = []; + state.turnHistory = { + kind: "canonical", + history: { + isComplete: true, + islands: [{ entries: [] }, { entries: [] }], + entitiesByKey: { + "turn:1": { + id: "turn-1", + status: "completed", + items: [], + itemsPagination: { hasLoadedOldest: true }, + }, + }, + }, + }; + const client = new FakeIpcClient(state); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + assert.equal((await adapter.snapshot()).metadata.historyComplete, false); + + const itemPagePending = structuredClone(state); + itemPagePending.turnHistory.history.islands = [{ entries: [] }]; + itemPagePending.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = false; + client.emitState(itemPagePending, 2); + assert.equal((await adapter.snapshot()).metadata.historyComplete, false); + + const complete = structuredClone(itemPagePending); + complete.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = true; + events.length = 0; + client.emitState(complete, 3, "patches"); + assert.equal((await adapter.snapshot()).metadata.historyComplete, true); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(events.some((event) => event.type === "session.snapshot")); + await adapter.dispose(); +}); + +test("IPC auto-discovery excludes subagents and Codex Desktop tasks", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-discovery-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + + const preferredCwd = path.join(codexHome, "current-workspace"); + const subagentId = "22222222-2222-4222-8222-222222222222"; + const otherWorkspaceId = "33333333-3333-4333-8333-333333333333"; + const matchingDesktopId = "44444444-4444-4444-8444-444444444444"; + const matchingVscodeId = "55555555-5555-4555-8555-555555555555"; + const writeRollout = async (id, payload, mtimeSeconds) => { + const fileName = path.join(sessions, `rollout-2026-08-28T00-00-00-${id}.jsonl`); + await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`); + await fs.utimes(fileName, mtimeSeconds, mtimeSeconds); + }; + + await writeRollout(subagentId, { + originator: "codex_vscode", + source: { subagent: { thread_spawn: {} } }, + thread_source: "subagent", + cwd: preferredCwd, + }, 3_000); + await writeRollout(otherWorkspaceId, { + originator: "codex_vscode", + source: "vscode", + thread_source: "user", + cwd: path.join(codexHome, "other-workspace"), + }, 2_000); + await writeRollout(matchingDesktopId, { + originator: "Codex Desktop", + source: "vscode", + thread_source: "user", + cwd: preferredCwd, + }, 4_000); + await writeRollout(matchingVscodeId, { + originator: "codex_vscode", + source: "vscode", + thread_source: "user", + cwd: preferredCwd, + }, 1_000); + + const client = new DiscoveryIpcClient(fixtureState()); + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + preferredCwds: [preferredCwd], + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + + await adapter.start(); + assert.equal((await adapter.snapshot()).threadId, matchingVscodeId); + const discovered = client.calls + .filter((call) => call.method === "findThreadOwner") + .map((call) => call.threadId); + assert.equal(discovered.includes(subagentId), false); + assert.equal(discovered.includes(matchingDesktopId), false); + await adapter.dispose(); +}); + +test("IPC auto-discovery finds an older live conversation beyond the first twelve rollouts", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-deep-discovery-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + const staleIds = Array.from({ length: 20 }, (_, index) => `aaaaaaaa-aaaa-4aaa-8aaa-${String(index).padStart(12, "0")}`); + for (const [index, id] of staleIds.entries()) { + await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({ + type: "session_meta", + payload: { + originator: "codex_vscode", + source: "vscode", + cwd: `/tmp/stale-${index}`, + updated_at: `2026-08-29T${String(20 - index).padStart(2, "0")}:00:00Z`, + }, + })}\n`); + } + const liveRollout = path.join(sessions, `rollout-${THREAD_ID}.jsonl`); + await fs.writeFile(liveRollout, `${JSON.stringify({ + type: "session_meta", + payload: { + originator: "codex_vscode", + source: "vscode", + cwd: "/tmp/live-old", + updated_at: "2026-08-01T00:00:00Z", + }, + })}\n`); + const oldMtime = new Date("2026-08-01T00:00:00Z"); + await fs.utimes(liveRollout, oldMtime, oldMtime); + + const client = new FakeIpcClient(fixtureState()); + const adapter = new CodexIpcAgentAdapter({ + client, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + assert.equal((await adapter.snapshot()).threadId, THREAD_ID); + const discoveryCalls = client.calls.filter((call) => call.method === "findThreadOwner"); + assert.ok(discoveryCalls.length > 12); + assert.ok(discoveryCalls.some((call) => call.threadId === THREAD_ID)); + await adapter.dispose(); +}); + +test("IPC adapter lists only indexed VS Code sessions with live attachable snapshots", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-list-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + const writeRollout = async (id, payload) => { + const fileName = path.join(sessions, `rollout-${id}.jsonl`); + await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`); + }; + await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" }); + await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" }); + await writeRollout(STALE_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/c" }); + await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [ + JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }), + JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "另一个工作区", updated_at: "2026-08-29T11:00:00Z" }), + JSON.stringify({ id: STALE_THREAD_ID, thread_name: "已关闭会话", updated_at: "2026-08-29T12:00:00Z" }), + ].join("\n") + "\n"); + + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "另一个工作区" }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const result = await adapter.listSessions({ limit: 10 }); + assert.equal(result.activeThreadId, THREAD_ID); + assert.deepEqual(result.sessions.map((entry) => entry.threadId), [SECOND_THREAD_ID, THREAD_ID]); + assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).available, true); + assert.equal(result.sessions.find((entry) => entry.threadId === STALE_THREAD_ID), undefined); + assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).title, "另一个工作区"); + assert.ok(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === true)); + assert.ok(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === false)); + await adapter.dispose(); +}); + +test("IPC session list keeps the active attachment when newer stale history fills the limit", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-limit-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + const writeRollout = async (id, cwd) => { + await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({ + type: "session_meta", + payload: { originator: "codex_vscode", source: "vscode", cwd }, + })}\n`); + }; + await writeRollout(THREAD_ID, "/tmp/current"); + await writeRollout(STALE_THREAD_ID, "/tmp/stale"); + await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [ + JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-28T10:00:00Z" }), + JSON.stringify({ id: STALE_THREAD_ID, thread_name: "较新的失效记录", updated_at: "2026-08-29T12:00:00Z" }), + ].join("\n") + "\n"); + + const client = new MultiSessionIpcClient(new Map([[THREAD_ID, fixtureState()]]), { [THREAD_ID]: "owner-a" }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const result = await adapter.listSessions({ limit: 1 }); + assert.deepEqual(result.sessions.map((entry) => entry.threadId), [THREAD_ID]); + assert.equal(result.sessions[0].active, true); + await adapter.dispose(); +}); + +test("IPC session list omits an owner that does not return a matching snapshot", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-probe-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + const writeRollout = async (id, payload) => { + const fileName = path.join(sessions, `rollout-${id}.jsonl`); + await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`); + }; + await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" }); + await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" }); + await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [ + JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }), + JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "桌面会话", updated_at: "2026-08-29T11:00:00Z" }), + ].join("\n") + "\n"); + + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "桌面会话" }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "desktop-owner", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + ownerDiscoveryTimeoutMs: 250, + followTimeoutMs: 250, + }); + await adapter.start(); + const result = await adapter.listSessions({ limit: 10 }); + assert.equal(result.sessions.find((entry) => entry.threadId === THREAD_ID).available, true); + assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID), undefined); + const targetCalls = client.calls.filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID); + assert.equal(targetCalls.filter((call) => call.following === true).length, 1); + assert.equal(targetCalls.filter((call) => call.following === false).length, 1); + await adapter.dispose(); +}); + +test("IPC adapter switches follows only after the target owner snapshot arrives", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话", turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "target output" }] }] }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + const result = await adapter.selectSession({ threadId: SECOND_THREAD_ID }); + assert.deepEqual(result, { + threadId: SECOND_THREAD_ID, + previousThreadId: THREAD_ID, + switched: true, + available: true, + }); + assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID); + assert.match((await adapter.snapshot()).outputTail, /target output/); + assert.ok(events.some((event) => event.type === "session.switching")); + assert.ok(events.some((event) => event.type === "session.selected")); + const oldUnfollow = client.calls.find((call) => call.method === "followConversation" && call.threadId === THREAD_ID && call.following === false); + assert.ok(oldUnfollow); + await adapter.dispose(); +}); + +test("IPC adapter follows paired route changes from the attached VS Code panel", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { + ...fixtureState(), + id: SECOND_THREAD_ID, + title: "面板目标会话", + turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "panel target output" }] }], + }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "target-owner", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 5, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID); + + const snapshot = await adapter.snapshot(); + assert.match(snapshot.outputTail, /panel target output/); + assert.ok(events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID)); + assert.ok(events.some((event) => event.type === "session.selected" && event.threadId === SECOND_THREAD_ID)); + await adapter.dispose(); +}); + +test("IPC adapter ignores isolated, targeted, and other-client following broadcasts", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "不应自动切换" }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "target-owner", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 0, + }); + await adapter.start(); + + // Bind the route source with a same-thread leave/re-enter pair. + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(THREAD_ID, true, { sourceClientId: "panel-owner" }); + // A reconnect/status replay is a lone true and is not a route change. + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + // Targeted replies describe liveness to one follower, not panel navigation. + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner", targetClientIds: ["follower"] }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner", targetClientIds: ["follower"] }); + // Another Codex window shares the router but cannot take over this bridge. + client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-panel" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "other-panel" }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.equal((await adapter.snapshot()).threadId, THREAD_ID); + assert.equal(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === true), false); + await adapter.dispose(); +}); + +test("IPC adapter does not trust an isolated false from another follower", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "真实面板目标" }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 0, + }); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "disposing-remote-follower" }); + client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" }); + await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID); + await adapter.dispose(); +}); + +test("IPC adapter binds the matching route source when another unbound source emits false", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { + ...fixtureState(), + id: SECOND_THREAD_ID, + title: "真实面板目标", + turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "real panel target" }] }], + }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 0, + }); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" }); + client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-follower" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" }); + await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID); + + assert.match((await adapter.snapshot()).outputTail, /real panel target/); + await adapter.dispose(); +}); + +test("IPC adapter lets a replacement route source take over after the bound client disconnects", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { + ...fixtureState(), + id: SECOND_THREAD_ID, + title: "重连后的目标", + turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "replacement panel target" }] }], + }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 0, + }); + await adapter.start(); + + // First bind the official panel without changing the selected conversation. + client.emitFollowing(THREAD_ID, false, { sourceClientId: "old-vscode-panel" }); + client.emitFollowing(THREAD_ID, true, { sourceClientId: "old-vscode-panel" }); + client.emitClientStatus("old-vscode-panel", "disconnected"); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "replacement-vscode-panel" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "replacement-vscode-panel" }); + await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID); + + assert.match((await adapter.snapshot()).outputTail, /replacement panel target/); + await adapter.dispose(); +}); + +test("IPC adapter coalesces rapid VS Code A to B to C navigation to C", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "中间会话" }], + [THIRD_THREAD_ID, { + ...fixtureState(), + id: THIRD_THREAD_ID, + title: "最终会话", + turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "final C output" }] }], + }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "owner-b", + [THIRD_THREAD_ID]: "owner-c", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 20, + }); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID); + + assert.match((await adapter.snapshot()).outputTail, /final C output/); + assert.equal(client.calls.some((call) => call.method === "followConversation" + && call.threadId === SECOND_THREAD_ID && call.following === true), false); + await adapter.dispose(); +}); + +test("IPC adapter cancels an in-flight B snapshot when VS Code moves on to C", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照 B" }], + [THIRD_THREAD_ID, { + ...fixtureState(), + id: THIRD_THREAD_ID, + title: "最终 C", + turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "C arrived" }] }], + }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "owner-b", + [THIRD_THREAD_ID]: "owner-c", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 1_000, + vscodeSessionFollowDebounceMs: 0, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(() => events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID)); + const movedOnAt = Date.now(); + client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID, 500); + + assert.ok(Date.now() - movedOnAt < 500, "C should not wait for B's 1s snapshot timeout"); + assert.equal(events.some((event) => event.type === "session.selected" + && event.threadId === SECOND_THREAD_ID && event.payload.switched === true), false); + assert.match((await adapter.snapshot()).outputTail, /C arrived/); + await adapter.dispose(); +}); + +test("IPC adapter defers the latest VS Code route while the old turn is active", async () => { + const active = fixtureState(); + active.turns[0].status = "inProgress"; + active.threadRuntimeStatus = { type: "active" }; + const completed = structuredClone(active); + completed.turns[0].status = "completed"; + completed.threadRuntimeStatus = { type: "idle" }; + const states = new Map([ + [THREAD_ID, active], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "延后目标" }], + ]); + const client = new MultiSessionIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "target-owner", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + vscodeSessionFollowDebounceMs: 0, + }); + await adapter.start(); + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal((await adapter.snapshot()).threadId, THREAD_ID); + + client.emitState(completed, 2, "patches", THREAD_ID, "panel-owner"); + await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID, 1_000); + await adapter.dispose(); +}); + +test("IPC adapter publishes an explicit rollback when a VS Code route cannot stream", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "target-owner", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 25, + vscodeSessionFollowDebounceMs: 0, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + events.length = 0; + + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(() => events.some((event) => event.type === "session.selected" && event.payload.failed === true)); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.threadId, THREAD_ID); + assert.match(snapshot.outputTail, /hi from VS Code/); + const rollbackIndex = events.findIndex((event) => event.type === "session.selected" && event.payload.failed === true); + const restoredOutputIndex = events.findIndex((event, index) => index > rollbackIndex + && event.type === "output.snapshot" && event.threadId === THREAD_ID); + assert.ok(rollbackIndex >= 0); + assert.ok(restoredOutputIndex > rollbackIndex); + await adapter.dispose(); +}); + +test("IPC adapter keeps dispose authoritative while an automatic switch is waiting", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "等待中的目标" }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "panel-owner", + [SECOND_THREAD_ID]: "target-owner", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 1_000, + vscodeSessionFollowDebounceMs: 0, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" }); + client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" }); + await waitFor(() => events.some((event) => event.type === "session.switching")); + + const eventCountAtDispose = events.length; + await adapter.dispose(); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal((await adapter.snapshot()).state, "disconnected"); + assert.equal(events.slice(eventCountAtDispose).some((event) => [ + "session.selected", + "output.snapshot", + "session.snapshot", + ].includes(event.type)), false); +}); + +test("IPC adapter absorbs a snapshot waiter when target follow fails immediately", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "立即失败" }], + ]); + const client = new ThrowingTargetFollowIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /target follow failed immediately/); + assert.equal((await adapter.snapshot()).threadId, THREAD_ID); + await adapter.dispose(); +}); + +test("IPC adapter rejects a target snapshot when owner changes before commit", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "Owner handoff" }], + ]); + const client = new OwnerChangingIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + await assert.rejects( + () => adapter.selectSession({ threadId: SECOND_THREAD_ID }), + /owner changed while switching/, + ); + assert.equal((await adapter.snapshot()).threadId, THREAD_ID); + await adapter.dispose(); +}); + +test("IPC adapter serializes list probes before selecting the same session", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-serialization-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + for (const [id, cwd] of [[THREAD_ID, "/tmp/current"], [SECOND_THREAD_ID, "/tmp/target"]]) { + await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({ + type: "session_meta", + payload: { originator: "codex_vscode", source: "vscode", cwd }, + })}\n`); + } + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }], + ]); + const client = new DelayedProbeIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + const listPromise = adapter.listSessions({ limit: 10 }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const selectPromise = adapter.selectSession({ threadId: SECOND_THREAD_ID }); + await Promise.all([listPromise, selectPromise]); + + const targetFollowing = client.calls + .filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID) + .map((call) => call.following); + assert.deepEqual(targetFollowing, [true, false, true]); + assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID); + await adapter.dispose(); +}); + +test("IPC adapter restores the cached previous projection when target follow has no snapshot", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const events = []; + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 25, + }); + adapter.onEvent((event) => events.push(event)); + await adapter.start(); + const before = await adapter.snapshot(); + assert.equal(before.threadId, THREAD_ID); + assert.match(before.outputTail, /hi from VS Code/); + + await assert.rejects( + () => adapter.selectSession({ threadId: SECOND_THREAD_ID }), + /Timed out waiting for a snapshot from VS Code conversation/, + ); + + const after = await adapter.snapshot(); + assert.equal(after.threadId, THREAD_ID); + assert.equal(after.outputTail, before.outputTail); + assert.deepEqual(after.messages, before.messages); + assert.equal(after.state, "idle"); + assert.ok(events.some((event) => event.type === "output.snapshot" && event.threadId === THREAD_ID)); + assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID)); + await adapter.dispose(); +}); + +test("IPC adapter restores its owner-validated projection after the IPC cache is polluted", async () => { + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }], + ]); + const client = new NoSnapshotSwitchIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 25, + }); + await adapter.start(); + const verified = await adapter.snapshot(); + + const polluted = { + ...fixtureState(), + title: "来自旧 owner 的污染快照", + turns: [{ + id: "turn-polluted", + status: "completed", + items: [{ type: "agentMessage", id: "polluted-message", text: "poisoned stale output" }], + }], + }; + client.emitState(polluted, 99, "snapshot", THREAD_ID, "old-owner"); + + assert.equal(client.getConversationState(THREAD_ID).ownerClientId, "old-owner"); + assert.equal(client.getConversationState(THREAD_ID).conversationState.title, "来自旧 owner 的污染快照"); + assert.equal((await adapter.snapshot()).outputTail, verified.outputTail); + + await assert.rejects( + () => adapter.selectSession({ threadId: SECOND_THREAD_ID }), + /Timed out waiting for a snapshot from VS Code conversation/, + ); + + const restored = await adapter.snapshot(); + assert.equal(restored.threadId, THREAD_ID); + assert.equal(restored.outputTail, verified.outputTail); + assert.deepEqual(restored.messages, verified.messages); + assert.doesNotMatch(restored.outputTail, /poisoned stale output/); + await adapter.dispose(); +}); + +test("IPC adapter denies target approvals on dispose and blocks ordinary commands mid-switch", async () => { + const targetApprovalId = "target-approval"; + const targetState = { + ...fixtureState([{ + id: targetApprovalId, + method: "item/commandExecution/requestApproval", + params: { threadId: SECOND_THREAD_ID, turnId: "turn-target", command: "echo target" }, + }]), + id: SECOND_THREAD_ID, + title: "等待 owner 确认的目标", + }; + const states = new Map([ + [THREAD_ID, fixtureState()], + [SECOND_THREAD_ID, targetState], + ]); + const client = new DelayedTargetOwnerConfirmationIpcClient(states, { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + + const switching = adapter.selectSession({ threadId: SECOND_THREAD_ID }); + void switching.catch(() => undefined); + await waitFor(() => client.targetConfirmationStarted); + await waitFor(async () => (await adapter.snapshot()).pendingApprovals.some((entry) => entry.requestId === targetApprovalId)); + + await assert.rejects( + () => adapter.startTurn({ text: "must not be sent while switching" }), + /session switch is still in progress/, + ); + await assert.rejects( + () => adapter.respondApproval(targetApprovalId, "allow"), + /session switch is still in progress/, + ); + assert.equal(client.calls.some((call) => call.method === "startTurn"), false); + assert.equal(client.calls.some((call) => call.method === "respondCommandApproval"), false); + + await adapter.dispose(); + const denied = client.calls.find((call) => call.method === "respondCommandApproval" + && call.requestId === targetApprovalId); + assert.ok(denied); + assert.equal(denied.threadId, SECOND_THREAD_ID); + assert.equal(denied.decision, "decline"); + assert.equal(denied.options.ownerClientId, "owner-b"); + + client.releaseTargetOwnerConfirmation(); + await assert.rejects(switching, /session selection was cancelled because the IPC session closed/); +}); + +test("IPC adapter refuses session switching during an active turn or pending request", async () => { + const state = fixtureState([{ id: 12, method: "item/commandExecution/requestApproval", params: { command: "echo pending" } }]); + const client = new MultiSessionIpcClient(new Map([[THREAD_ID, state], [SECOND_THREAD_ID, fixtureState()]]), { + [THREAD_ID]: "owner-a", + [SECOND_THREAD_ID]: "owner-b", + }); + const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, loadCompleteHistory: false, approvalTimeoutMs: 0, followTimeoutMs: 500 }); + await adapter.start(); + await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /turn or approval is active/); + await adapter.dispose(); +}); + +test("IPC session discovery accepts current ULID rollout filenames", async (t) => { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-ulid-")); + t.after(() => fs.rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions", "2026", "08"); + await fs.mkdir(sessions, { recursive: true }); + await fs.writeFile(path.join(sessions, `rollout-2026-08-29T00-00-00-${ULID_THREAD_ID}.jsonl`), `${JSON.stringify({ + type: "session_meta", + payload: { id: ULID_THREAD_ID, originator: "codex_vscode", source: "vscode", cwd: "/tmp/ulid" }, + })}\n`); + await fs.writeFile(path.join(codexHome, "session_index.jsonl"), `${JSON.stringify({ id: ULID_THREAD_ID, thread_name: "ULID 会话", updated_at: "2026-08-29T12:00:00Z" })}\n`); + const client = new DiscoveryIpcClient(fixtureState()); + const adapter = new CodexIpcAgentAdapter({ + client, + threadId: THREAD_ID, + codexHome, + loadCompleteHistory: false, + approvalTimeoutMs: 0, + followTimeoutMs: 500, + }); + await adapter.start(); + const result = await adapter.listSessions({ limit: 10 }); + const session = result.sessions.find((entry) => entry.threadId === ULID_THREAD_ID); + assert.ok(session); + assert.equal(session.title, "ULID 会话"); + assert.equal(session.available, true); + await adapter.dispose(); +}); diff --git a/aether-vscodex/test/codex-ipc.test.js b/aether-vscodex/test/codex-ipc.test.js new file mode 100644 index 000000000..7bd366b26 --- /dev/null +++ b/aether-vscodex/test/codex-ipc.test.js @@ -0,0 +1,209 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { mkdtempSync, rmSync } = require("node:fs"); +const test = require("node:test"); + +const { + CODEX_IPC_METHOD_VERSIONS, + CodexIpcClient, + IpcFrameDecoder, + applyIpcPatches, + encodeIpcFrame, +} = require("../vscode-extension/dist/codexIpc.js"); + +function waitFor(predicate, timeoutMs = 2_000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const poll = () => { + if (predicate()) return resolve(); + if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture")); + setTimeout(poll, 5); + }; + poll(); + }); +} + +test("private IPC framing handles split UTF-8 frames", () => { + const message = { + type: "broadcast", + method: "thread-stream-following-changed", + sourceClientId: "client-1", + version: 1, + params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" }, + }; + const frame = encodeIpcFrame(message); + const decoder = new IpcFrameDecoder(); + const first = decoder.push(frame.subarray(0, 3)); + assert.deepEqual(first, []); + const second = decoder.push(frame.subarray(3, frame.length - 1)); + assert.deepEqual(second, []); + assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]); +}); + +test("applyIpcPatches updates a conversation snapshot", () => { + const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" }; + const next = applyIpcPatches(initial, [ + { op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" }, + { op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } }, + { op: "replace", path: ["status"], value: "active" }, + ]); + assert.deepEqual(next, { + turns: [{ items: [{ text: "new" }, { text: "second" }] }], + status: "active", + }); +}); + +test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => { + const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-")); + const socketPath = path.join(temp, "ipc.sock"); + const threadId = "11111111-1111-4111-8111-111111111111"; + const ownerId = "owner-client"; + const requests = []; + const followingBroadcasts = []; + let fixtureSocket; + const server = net.createServer((socket) => { + fixtureSocket = socket; + const decoder = new IpcFrameDecoder(); + socket.on("data", (chunk) => { + for (const message of decoder.push(chunk)) { + if (message.type === "request" && message.method === "initialize") { + socket.write(encodeIpcFrame({ + type: "response", + requestId: message.requestId, + resultType: "success", + method: "initialize", + handledByClientId: "fixture-client", + result: { clientId: "fixture-client" }, + })); + continue; + } + if (message.type === "broadcast" && message.method === "thread-stream-following-changed") { + followingBroadcasts.push(message); + const target = message.sourceClientId; + socket.write(encodeIpcFrame({ + type: "broadcast", + method: "thread-stream-state-changed", + sourceClientId: ownerId, + targetClientIds: [target], + version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"], + params: { + conversationId: threadId, + hostId: "local", + change: { + type: "snapshot", + revision: 1, + conversationState: { id: threadId, title: "fixture", turns: [], requests: [] }, + }, + }, + })); + continue; + } + if (message.type === "request") { + requests.push(message); + socket.write(encodeIpcFrame({ + type: "response", + requestId: message.requestId, + resultType: "success", + method: message.method, + handledByClientId: ownerId, + result: { method: message.method, ok: true }, + })); + } + } + }); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + const client = new CodexIpcClient({ socketPath, autoReconnect: false }); + const streamEvents = []; + client.onStreamEvent((event) => streamEvents.push(event)); + await client.connect(); + await client.followConversation(threadId); + await waitFor(() => streamEvents.some((event) => event.kind === "snapshot")); + assert.equal(client.getConversationState(threadId).ownerClientId, ownerId); + fixtureSocket.write(encodeIpcFrame({ + type: "broadcast", + method: "thread-stream-following-status-requested", + sourceClientId: ownerId, + targetClientIds: ["fixture-client"], + version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"], + params: { conversationId: threadId, hostId: "local" }, + })); + await waitFor(() => followingBroadcasts.length >= 2); + assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]); + assert.deepEqual(followingBroadcasts[1].params, { + conversationId: threadId, + hostId: "local", + following: true, + }); + + await client.startTurn(threadId, "hello", { ownerClientId: ownerId }); + await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId }); + await client.updateThreadSettings(threadId, { + model: "gpt-5.6-sol", + effort: "ultra", + multiAgentMode: "explicitRequestOnly", + }, { ownerClientId: ownerId }); + await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId }); + await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId }); + await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId }); + await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId }); + await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId }); + await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId }); + + assert.deepEqual(requests.map((request) => request.method), [ + "thread-follower-start-turn", + "thread-follower-steer-turn", + "thread-follower-update-thread-settings", + "thread-follower-interrupt-turn", + "thread-follower-command-approval-decision", + "thread-follower-file-approval-decision", + "thread-follower-permissions-request-approval-response", + "thread-follower-submit-user-input", + "thread-follower-submit-mcp-server-elicitation-response", + ]); + assert.deepEqual(requests[0].params, { + conversationId: threadId, + turnStart: { + request: { + threadId, + input: [{ type: "text", text: "hello", text_elements: [] }], + }, + context: { inheritThreadSettings: true }, + }, + }); + assert.deepEqual(requests[2].params, { + conversationId: threadId, + threadSettings: { + model: "gpt-5.6-sol", + effort: "ultra", + multiAgentMode: "explicitRequestOnly", + }, + }); + assert.equal(requests[2].version, 1); + assert.deepEqual(requests[3].params, { + conversationId: threadId, + mode: "user-stop", + expectedTurnId: "turn-1", + }); + assert.equal(requests[3].version, 4); + assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" }); + assert.deepEqual(requests[8].params, { + conversationId: threadId, + requestId: 11, + response: { action: "decline", content: null, _meta: null }, + }); + await client.dispose(); + } finally { + await new Promise((resolve) => server.close(resolve)); + rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/aether-vscodex/test/codex-path.test.js b/aether-vscodex/test/codex-path.test.js new file mode 100644 index 000000000..ed76ad1f6 --- /dev/null +++ b/aether-vscodex/test/codex-path.test.js @@ -0,0 +1,57 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js"); +const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js"); + +function temporaryDirectory() { + return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-")); +} + +test("resolveCodexCommand finds a bare command in PATH", () => { + const root = temporaryDirectory(); + try { + const bin = path.join(root, "bin"); + const executable = path.join(bin, "codex-test"); + mkdirSync(bin); + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); + chmodSync(executable, 0o755); + assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => { + const root = temporaryDirectory(); + try { + const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"); + mkdirSync(path.dirname(executable), { recursive: true }); + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); + chmodSync(executable, 0o755); + assert.equal( + resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }), + executable, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a missing explicit command reports a full-path setting hint", () => { + assert.throws( + () => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }), + /Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/, + ); +}); + +test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => { + const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] }); + await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/); + client.close(); +}); diff --git a/aether-vscodex/test/composite-relay.test.js b/aether-vscodex/test/composite-relay.test.js new file mode 100644 index 000000000..323c5a879 --- /dev/null +++ b/aether-vscodex/test/composite-relay.test.js @@ -0,0 +1,105 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js"); + +class FakeRelay { + constructor({ connectError } = {}) { + this.connectError = connectError; + this.frames = []; + this.closed = false; + this.listeners = { message: new Set(), open: new Set(), close: new Set() }; + } + + async connect() { + if (this.connectError) throw this.connectError; + for (const listener of this.listeners.open) listener(); + } + + send(frame) { this.frames.push(frame); } + close() { this.closed = true; } + onMessage(listener) { return this.add("message", listener); } + onOpen(listener) { return this.add("open", listener); } + onClose(listener) { return this.add("close", listener); } + add(type, listener) { + this.listeners[type].add(listener); + return { dispose: () => this.listeners[type].delete(listener) }; + } + receive(frame) { for (const listener of this.listeners.message) listener(frame); } + disconnect(error) { for (const listener of this.listeners.close) listener(error); } +} + +test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => { + const local = new FakeRelay(); + const cloud = new FakeRelay({ connectError: new Error("cloud offline") }); + const relay = new CompositeRelayTransport([ + { id: "local", transport: local, required: true }, + { id: "cloud", transport: cloud }, + ]); + + await relay.connect(); + assert.equal(relay.isConnected("local"), true); + assert.equal(relay.isConnected("cloud"), false); + relay.send({ kind: "event", type: "session.snapshot" }); + assert.equal(local.frames.length, 1); + assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect"); + relay.close(); + assert.equal(local.closed, true); + assert.equal(cloud.closed, true); +}); + +test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => { + const local = new FakeRelay(); + const cloud = new FakeRelay(); + const relay = new CompositeRelayTransport([ + { id: "local", transport: local, required: true }, + { id: "cloud", transport: cloud }, + ]); + const messages = []; + const closes = []; + relay.onMessage((frame) => messages.push(frame)); + relay.onClose((error) => closes.push(error?.message)); + + await relay.connect(); + assert.equal(relay.isConnected("local"), true); + assert.equal(relay.isConnected("cloud"), true); + cloud.receive({ kind: "command", type: "turn.start" }); + assert.equal(messages.length, 1); + local.disconnect(new Error("local offline")); + assert.equal(relay.isConnected("local"), false); + assert.deepEqual(closes, []); + cloud.disconnect(new Error("cloud offline")); + assert.deepEqual(closes, ["cloud offline"]); + relay.close(); +}); + +test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => { + const local = new FakeRelay(); + const cloud = new FakeRelay(); + const relay = new CompositeRelayTransport([ + { id: "local", transport: local, required: true }, + { id: "cloud", transport: cloud }, + ]); + let opens = 0; + relay.onOpen(() => { opens += 1; }); + await relay.connect(); + assert.equal(opens, 2); + cloud.disconnect(new Error("cloud offline")); + for (const listener of cloud.listeners.open) listener(); + assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot"); + relay.close(); +}); + +test("CompositeRelayTransport fails when the required local relay cannot connect", async () => { + const local = new FakeRelay({ connectError: new Error("local offline") }); + const cloud = new FakeRelay(); + const relay = new CompositeRelayTransport([ + { id: "local", transport: local, required: true }, + { id: "cloud", transport: cloud }, + ]); + await assert.rejects(relay.connect(), /local: local offline/); + assert.equal(local.closed, true); + assert.equal(cloud.closed, true); +}); diff --git a/aether-vscodex/test/extension-i18n-build.test.js b/aether-vscodex/test/extension-i18n-build.test.js new file mode 100644 index 000000000..ff4969dbf --- /dev/null +++ b/aether-vscodex/test/extension-i18n-build.test.js @@ -0,0 +1,34 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +test("VS Code runtime strings have English and Simplified Chinese bundles", () => { + const extensionRoot = path.join(__dirname, "..", "vscode-extension"); + const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8"); + const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8")); + const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8")); + const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8")); + const keys = [...source.matchAll(/(? match[1]); + + assert.equal(manifest.l10n, "./l10n"); + assert.ok(keys.length > 20, "expected runtime-localized extension strings"); + for (const key of new Set(keys)) { + assert.equal(english[key], key, `missing English source string: ${key}`); + assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`); + assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`); + } +}); + +test("production copy scripts require the Vue build instead of silently falling back", () => { + const projectRoot = path.join(__dirname, ".."); + const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8"); + const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8"); + const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8")); + + assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/); + assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/); + assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/); +}); diff --git a/aether-vscodex/test/local-relay.test.js b/aether-vscodex/test/local-relay.test.js new file mode 100644 index 000000000..58ef1a7c4 --- /dev/null +++ b/aether-vscodex/test/local-relay.test.js @@ -0,0 +1,120 @@ +const assert = require("node:assert/strict"); +const http = require("node:http"); +const path = require("node:path"); +const test = require("node:test"); + +const { + LocalRelayController, + localRelayTarget, + relayHealthAvailable, +} = require("../vscode-extension/dist/localRelay.js"); + +test("local relay target accepts only loopback ws URLs", () => { + assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), { + host: "127.0.0.1", + port: 8898, + healthUrl: "http://127.0.0.1:8898/api/health", + webUrl: "http://127.0.0.1:8898/", + }); + assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined); + assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined); + assert.equal(localRelayTarget("not a url"), undefined); +}); + +test("local relay health probe recognizes a responding HTTP service", async (t) => { + const server = http.createServer((request, response) => { + if (request.url === "/api/health") { + response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true })); + } else if (request.url === "/aborted") { + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"ok":'); + response.destroy(); + } else if (request.url === "/drip") { + response.writeHead(200, { "content-type": "application/json" }); + const interval = setInterval(() => response.write(" "), 10); + response.on("close", () => clearInterval(interval)); + } else { + response.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const address = server.address(); + assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true); + assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false); + assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false); + const startedAt = Date.now(); + assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false); + assert.ok(Date.now() - startedAt < 500); +}); + +test("local relay controller starts and stops a bundled loopback relay", async () => { + let starts = 0; + let stops = 0; + class FakeRelay { + async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; } + async stop() { stops += 1; } + } + const controller = new LocalRelayController({ + extensionPath: path.resolve(__dirname, "../vscode-extension"), + probeTimeoutMs: 20, + loadRelayModule: () => ({ CodexRelay: FakeRelay }), + }); + assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true); + assert.equal(starts, 1); + await controller.stop(); + assert.equal(stops, 1); +}); + +test("local relay controller does not leak a relay when stopped during startup", async () => { + let releaseStart; + const startGate = new Promise((resolve) => { releaseStart = resolve; }); + let startEntered; + const entered = new Promise((resolve) => { startEntered = resolve; }); + let stops = 0; + class SlowRelay { + async start() { + startEntered(); + await startGate; + return { host: "127.0.0.1", port: 65533 }; + } + async stop() { stops += 1; } + } + const controller = new LocalRelayController({ + extensionPath: path.resolve(__dirname, "../vscode-extension"), + probeTimeoutMs: 20, + loadRelayModule: () => ({ CodexRelay: SlowRelay }), + }); + const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect"); + await entered; + const stopping = controller.stop(); + releaseStart(); + await Promise.all([starting, stopping]); + assert.equal(stops, 1); +}); + +test("local relay controller does not start after stop wins an in-flight health probe", async () => { + let resolveProbe; + const probe = new Promise((resolve) => { resolveProbe = resolve; }); + let probeEntered; + const entered = new Promise((resolve) => { probeEntered = resolve; }); + let starts = 0; + class FakeRelay { + async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; } + async stop() {} + } + const controller = new LocalRelayController({ + extensionPath: path.resolve(__dirname, "../vscode-extension"), + loadRelayModule: () => ({ CodexRelay: FakeRelay }), + probeRelayHealth: async () => { + probeEntered(); + return probe; + }, + }); + const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect"); + await entered; + await controller.stop(); + resolveProbe(false); + assert.equal(await ensuring, false); + assert.equal(starts, 0); +}); diff --git a/aether-vscodex/test/public-embed-i18n.test.js b/aether-vscodex/test/public-embed-i18n.test.js new file mode 100644 index 000000000..6cffda015 --- /dev/null +++ b/aether-vscodex/test/public-embed-i18n.test.js @@ -0,0 +1,194 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js"); +const i18n = require("../public/i18n.js"); + +function embeddedWindow() { + const listeners = new Map(); + const posts = []; + const parent = { postMessage: (message, origin) => posts.push({ message, origin }) }; + const bodyClasses = new Set(); + const documentElement = { dataset: {}, style: {} }; + const windowLike = { + location: { search: "?embed=aether", origin: "https://aether.example" }, + parent, + document: { + body: { classList: { add: (value) => bodyClasses.add(value) } }, + documentElement, + }, + addEventListener: (name, listener) => listeners.set(name, listener), + removeEventListener: (name, listener) => { + if (listeners.get(name) === listener) listeners.delete(name); + }, + }; + return { bodyClasses, documentElement, listeners, parent, posts, windowLike }; +} + +test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => { + assert.equal(isAetherEmbed({ search: "" }), false); + assert.equal(isAetherEmbed({ search: "?embed=other" }), false); + assert.equal(isAetherEmbed({ search: "?embed=aether" }), true); + + const fixture = embeddedWindow(); + const bridge = createAetherEmbedBridge(fixture.windowLike); + assert.equal(bridge.active, true); + bridge.start(); + assert.equal(fixture.bodyClasses.has("embed-aether"), true); + assert.deepEqual(fixture.posts, [{ + message: { v: 1, type: "aether-vscodex/ready" }, + origin: "https://aether.example", + }]); +}); + +test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => { + const fixture = embeddedWindow(); + const bridge = createAetherEmbedBridge(fixture.windowLike); + bridge.start(); + const dispatch = fixture.listeners.get("message"); + const connect = { + v: 1, + type: "aether-vscodex/connect", + ticket: "one-time-ticket", + wsUrl: "/api/vscodex/ws", + locale: "en-US", + theme: "dark", + }; + + dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect }); + dispatch({ origin: "https://aether.example", source: {}, data: connect }); + let received = null; + bridge.on("connect", (message) => { received = message; }); + assert.equal(received, null); + + dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect }); + assert.equal(received.ticket, "one-time-ticket"); + assert.equal(fixture.documentElement.dataset.theme, "dark"); + + const second = embeddedWindow(); + const bufferedBridge = createAetherEmbedBridge(second.windowLike); + bufferedBridge.start(); + second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect }); + let buffered = null; + bufferedBridge.on("connect", (message) => { buffered = message; }); + assert.equal(buffered.ticket, "one-time-ticket"); +}); + +test("bridge ticket requests never place the ticket in a URL", () => { + const fixture = embeddedWindow(); + const bridge = createAetherEmbedBridge(fixture.windowLike); + bridge.start(); + bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" }); + assert.deepEqual(fixture.posts.at(-1), { + message: { + v: 1, + type: "aether-vscodex/request-ticket", + reason: "disconnected", + deviceId: "device-1", + }, + origin: "https://aether.example", + }); +}); + +test("locale dictionary covers static shell and core dynamic status text", () => { + assert.equal(i18n.translate("设置", "en-US"), "Settings"); + assert.equal(i18n.translate("中文", "en-US"), "Chinese"); + assert.equal(i18n.translate("正在思考", "en-US"), "Thinking"); + assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files"); + assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s"); + assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed"); + assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout"); + assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host"); + assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md"); + assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out"); + assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)"); + assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed"); + assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s"); + assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s"); + assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi"); + assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s"); + assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed"); + assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted"); + assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi"); + assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置"); + assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置"); + assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s"); + assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”..."); + assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working"); + assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed"); + assert.equal(i18n.translate("正在搜索", "en-US"), "Searching"); + assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s"); + assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model"); + assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files"); + assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s"); + assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan"); + assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s"); + assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)"); + assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative"); + assert.equal(i18n.translate("控制模式", "en-US"), "Control mode"); + assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation"); + assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently"); + assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes"); + assert.equal(i18n.translate("Settings", "zh-CN"), "设置"); + assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN"); + assert.equal(i18n.normalizeLocale("en-GB"), "en-US"); +}); + +test("renderer-owned dynamic labels have English fallbacks without translating host values", () => { + assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi"); + assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working"); + assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed"); + assert.equal(i18n.translate("正在搜索", "en-US"), "Searching"); + assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model"); + + const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8"); + // Command/path/title values are appended after a locale-specific prefix; + // they are never passed through the translator as a whole. + assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/); + assert.match(app, /uiWithRaw\("已读取 ", "Read ",/); + assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/); +}); + +test("public shell uses relative assets and embedded startup skips the health probe", () => { + const publicRoot = path.join(__dirname, "..", "public"); + const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8"); + const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8"); + assert.match(html, /href="\.\/style\.css"/); + assert.match(html, /src="\.\/embed-bridge\.js"/); + assert.match(html, /src="\.\/i18n\.js"/); + assert.match(html, /src="\.\/app\.js"/); + assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/); + assert.doesNotMatch(app, /ticket=.*state\.embedTicket/); + assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/); + assert.match(app, /outputContent\.textContent = t\("无输出"\)/); + assert.match(app, /button\.title = t\(title\)/); + assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/); + assert.match(app, /return t\("需要远程确认或输入"\)/); + assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/); + assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/); + assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/); +}); + +test("control mode is snapshot-authoritative and gates independent session actions", () => { + const publicRoot = path.join(__dirname, "..", "public"); + const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8"); + const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8"); + + assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/); + assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/); + assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/); + assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/); + assert.match(app, /sessionList: source\.sessionList === true/); + assert.match(app, /Boolean\(state\.sessionListCommandId\)/); + assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/); + assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/); + assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/); + assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/); + assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/); + assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/); + assert.match(app, /sessionPickerButton\.disabled = !listAllowed/); +}); diff --git a/aether-vscodex/test/relay-client.test.js b/aether-vscodex/test/relay-client.test.js new file mode 100644 index 000000000..f2f2adcbe --- /dev/null +++ b/aether-vscodex/test/relay-client.test.js @@ -0,0 +1,206 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const test = require("node:test"); + +const { RelayClient } = require("../vscode-extension/dist/relayClient.js"); + +class FakeWebSocket extends EventEmitter { + static instances = []; + + constructor(url) { + super(); + this.url = url; + this.readyState = 0; + this.sent = []; + FakeWebSocket.instances.push(this); + } + + open() { + this.readyState = 1; + this.emit("open"); + } + + receive(frame) { + this.emit("message", Buffer.from(JSON.stringify(frame))); + } + + send(data) { + this.sent.push(JSON.parse(data)); + } + + close() { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close"); + } +} + +test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://relay.invalid/v1/connect", + accessToken: "host-token", + reconnect: false, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const firstConnect = client.connect(); + const first = FakeWebSocket.instances[0]; + first.open(); + assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]); + + client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } }); + assert.equal(first.sent.length, 2, "application event must not be sent before authentication"); + first.receive({ type: "auth.ok", role: "host", clientType: "host" }); + await firstConnect; + assert.equal(first.sent.length, 3); + assert.equal(first.sent[2].id, "event-1"); + + first.close(); + const secondConnect = client.connect(); + const second = FakeWebSocket.instances[1]; + second.open(); + assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]); + + client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } }); + assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated"); + second.receive({ type: "auth.ok", role: "host", clientType: "host" }); + await secondConnect; + assert.equal(second.sent.length, 3); + assert.equal(second.sent[2].id, "event-2"); +}); + +test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://relay.invalid/v1/connect", + accessToken: "host-token", + reconnect: false, + maxFrameBytes: 4_096, + maxQueuedBytes: 4_096, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const connecting = client.connect(); + const socket = FakeWebSocket.instances[0]; + socket.open(); + client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } }); + client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } }); + client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } }); + client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } }); + + assert.ok(client.queueBytes <= 4_096); + socket.receive({ type: "auth.ok", role: "host", clientType: "host" }); + await connecting; + const queuedIds = socket.sent.slice(2).map((frame) => frame.id); + assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]); +}); + +test("RelayClient evicts reconstructible projections before queued control events", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://relay.invalid/v1/connect", + accessToken: "host-token", + reconnect: false, + maxFrameBytes: 4_096, + maxQueuedBytes: 2_500, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const connecting = client.connect(); + const socket = FakeWebSocket.instances[0]; + socket.open(); + client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } }); + client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } }); + client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } }); + + assert.ok(client.queueBytes <= 2_500); + socket.receive({ type: "auth.ok", role: "host", clientType: "host" }); + await connecting; + const queuedIds = socket.sent.slice(2).map((frame) => frame.id); + assert.deepEqual(queuedIds, ["approval", "command"]); +}); + +test("RelayClient supports a tokenless local handshake", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://127.0.0.1:8787/v1/connect", + reconnect: false, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const connecting = client.connect(); + const socket = FakeWebSocket.instances[0]; + socket.open(); + assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]); + socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false }); + await connecting; + + client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} }); + assert.equal(socket.sent.length, 2); + assert.equal(socket.sent[1].type, "connection.opened"); +}); + +test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://127.0.0.1:8787/v1/connect", + reconnect: false, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const connecting = client.connect(); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false }); + await connecting; + + const historyText = "x".repeat(512 * 1024); + assert.doesNotThrow(() => client.send({ + v: 1, + kind: "event", + type: "session.snapshot", + id: "large-history-snapshot", + sessionId: "session-local", + payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] }, + })); + assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length); +}); + +test("RelayClient ignores late events from a replaced socket", async (t) => { + FakeWebSocket.instances.length = 0; + const client = new RelayClient({ + url: "ws://relay.invalid/v1/connect", + accessToken: "host-token", + reconnect: false, + webSocket: FakeWebSocket, + }); + t.after(() => client.close()); + + const firstConnect = client.connect(); + const first = FakeWebSocket.instances[0]; + first.open(); + client.close(); + + const secondConnect = client.connect(); + const second = FakeWebSocket.instances[1]; + second.open(); + + // Simulate a delayed event from the old socket after the replacement. + first.open(); + first.receive({ type: "auth.ok", role: "host", clientType: "host" }); + assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket"); + + client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } }); + second.receive({ type: "auth.ok", role: "host", clientType: "host" }); + await secondConnect; + assert.equal(second.sent[2].id, "event-after-replace"); + await assert.rejects(firstConnect); +}); diff --git a/aether-vscodex/test/relay.test.js b/aether-vscodex/test/relay.test.js new file mode 100644 index 000000000..c248ef9ae --- /dev/null +++ b/aether-vscodex/test/relay.test.js @@ -0,0 +1,1610 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const http = require("node:http"); +const path = require("node:path"); +const test = require("node:test"); +const { WebSocket } = require("ws"); +const { CodexRelay } = require("../relay/server.js"); + +const fakeServer = path.join(__dirname, "..", "fixtures", "fake-app-server.cjs"); + +function waitFor(predicate, timeout = 5_000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + try { + const result = predicate(); + if (result) return resolve(result); + } catch (error) { return reject(error); } + if (Date.now() - started > timeout) return reject(new Error("timed out waiting for condition")); + setTimeout(tick, 20); + }; + tick(); + }); +} + +function request(base, token, pathname, options = {}) { + return fetch(`${base}${pathname}`, { + ...options, + headers: { Authorization: `Bearer ${token}`, ...(options.headers || {}) }, + }); +} + +function connectWs(base, token) { + const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws"); + const messages = []; + const waiters = []; + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + messages.push(message); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + if (waiters[index].predicate(message)) { + const waiter = waiters.splice(index, 1)[0]; + waiter.resolve(message); + } + } + }); + const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => { + const existing = messages.find(predicate); + if (existing) return resolve(existing); + const timer = setTimeout(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) waiters.splice(index, 1); + reject(new Error("timed out waiting for websocket message")); + }, timeout); + waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } }); + }); + return new Promise((resolve, reject) => { + ws.once("open", () => { + ws.send(JSON.stringify({ type: "auth", token })); + wait((message) => message.type === "auth.ok").then(() => { + ws.send(JSON.stringify({ type: "subscribe", fromSeq: 0 })); + resolve({ ws, wait, messages }); + }, reject); + }); + ws.once("error", reject); + }); +} + +function connectBrowserHello(base, token) { + const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws"); + const messages = []; + const waiters = []; + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + messages.push(message); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + if (waiters[index].predicate(message)) { + const waiter = waiters.splice(index, 1)[0]; + waiter.resolve(message); + } + } + }); + const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => { + const existing = messages.find(predicate); + if (existing) return resolve(existing); + const timer = setTimeout(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) waiters.splice(index, 1); + reject(new Error("timed out waiting for websocket message")); + }, timeout); + waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } }); + }); + return new Promise((resolve, reject) => { + ws.once("open", () => { + ws.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 })); + if (token !== undefined) ws.send(JSON.stringify({ type: "auth", token })); + wait((message) => message.type === "auth.ok").then(() => resolve({ ws, wait, messages }), reject); + }); + ws.once("error", reject); + }); +} + +function connectHost(base, token, sessionId = "host-session") { + const ws = new WebSocket(base.replace(/^http/, "ws") + "/v1/connect"); + const messages = []; + const waiters = []; + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + messages.push(message); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + if (waiters[index].predicate(message)) { + const waiter = waiters.splice(index, 1)[0]; + waiter.resolve(message); + } + } + }); + const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => { + const existing = messages.find(predicate); + if (existing) return resolve(existing); + const timer = setTimeout(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) waiters.splice(index, 1); + reject(new Error("timed out waiting for host websocket message")); + }, timeout); + waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } }); + }); + return new Promise((resolve, reject) => { + ws.once("open", () => { + ws.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId })); + ws.send(JSON.stringify({ v: 1, kind: "auth", accessToken: token })); + wait((message) => message.type === "auth.ok" && message.clientType === "host").then(() => { + resolve({ ws, wait, messages, sessionId }); + }, reject); + }); + ws.once("error", reject); + }); +} + +test("loopback relay defaults to tokenless browser and VS Code host connections", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + }); + assert.equal(relay.authRequired, false); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const health = await fetch(`${base}/api/health`); + assert.equal(health.status, 200); + assert.equal((await health.json()).authRequired, false); + + const state = await fetch(`${base}/api/state`); + assert.equal(state.status, 200); + assert.equal((await state.json()).role, "operator"); + + const crossOriginWrite = await fetch(`${base}/api/command`, { + method: "POST", + headers: { Origin: "https://untrusted.example", "Content-Type": "application/json" }, + body: JSON.stringify({ commandId: "cross-origin", method: "turn/start", params: {} }), + }); + assert.equal(crossOriginWrite.status, 403); + + const reboundHost = await new Promise((resolve, reject) => { + const request = http.request({ + host: "127.0.0.1", + port: address.port, + path: "/api/health", + headers: { Host: `rebound.example:${address.port}` }, + }, (response) => { + response.resume(); + response.once("end", () => resolve(response)); + }); + request.once("error", reject); + request.end(); + }); + assert.equal(reboundHost.statusCode, 403); + + const deceptiveHost = await new Promise((resolve, reject) => { + const request = http.request({ + host: "127.0.0.1", + port: address.port, + path: "/api/health", + headers: { Host: `127.evil:${address.port}` }, + }, (response) => { + response.resume(); + response.once("end", () => resolve(response)); + }); + request.once("error", reject); + request.end(); + }); + assert.equal(deceptiveHost.statusCode, 403); + + const host = await connectHost(base, undefined, "tokenless-host"); + t.after(() => host.ws.close()); + const browser = await connectWs(base, undefined); + t.after(() => browser.ws.close()); + const browserHello = await connectBrowserHello(base); + t.after(() => browserHello.ws.close()); + const browserStaleToken = await connectBrowserHello(base, "stale-local-token"); + t.after(() => browserStaleToken.ws.close()); + assert.equal(host.messages.find((message) => message.type === "auth.ok")?.role, "host"); + assert.equal(browser.messages.find((message) => message.type === "auth.ok")?.role, "operator"); + assert.equal(browserHello.messages.find((message) => message.type === "auth.ok")?.authRequired, false); + assert.equal(browserStaleToken.messages.some((message) => message.type === "error"), false); +}); + +test("loopback relay hydrates attached-session snapshots larger than 256 KiB", async (t) => { + const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const host = await connectHost(base, undefined, "large-snapshot-host"); + const browser = await connectWs(base, undefined); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + const historyText = "x".repeat(512 * 1024); + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "session.snapshot", + id: "large-snapshot-event", + sessionId: host.sessionId, + seq: 1, + ts: new Date().toISOString(), + payload: { + threadId: "large-thread", + state: "idle", + outputTail: historyText.slice(-32_000), + messages: [{ kind: "assistant", text: historyText }], + metadata: { title: "Large attached session", historyComplete: false }, + }, + })); + + const snapshot = await browser.wait((message) => message.kind === "event" + && message.type === "session.snapshot" + && message.payload?.sourceSeq === 1); + assert.equal(snapshot.payload.messages[0].text.length, historyText.length); + assert.equal(relay.state.messages[0].text.length, historyText.length); + assert.equal(relay.state.sessionMetadata.title, "Large attached session"); + assert.equal(relay.state.sessionMetadata.historyComplete, false); + const replayed = relay.events.find((event) => event.type === "session.snapshot" && event.payload?.sourceSeq === 1); + assert.equal(replayed.payload.messages, undefined); + assert.equal(replayed.payload.projectionInControlSnapshot, true); + const controlSnapshot = relay.snapshot(); + assert.equal(controlSnapshot.state.messages, undefined); + assert.equal(controlSnapshot.state.outputTail, undefined); + assert.equal(controlSnapshot.state.subagents, undefined); + assert.equal(controlSnapshot.state.sessionMetadata, undefined); + assert.equal(controlSnapshot.metadata.historyComplete, false); + const serializedControl = JSON.stringify(controlSnapshot); + assert.ok(Buffer.byteLength(serializedControl) < historyText.length + 100_000, "history must be serialized only once"); +}); + +test("late browser control snapshot preserves the attach waiting state", async (t) => { + const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const host = await connectHost(base, undefined, "waiting-session-host"); + t.after(() => host.ws.close()); + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "session.snapshot", + id: "waiting-session-snapshot", + sessionId: host.sessionId, + seq: 1, + ts: new Date().toISOString(), + payload: { + threadId: null, + state: "waiting_for_host", + metadata: { waitingForSession: true, attachReady: false }, + }, + })); + await host.wait((message) => message.kind === "ack" && message.seq === 1); + + const browser = await connectWs(base, undefined); + t.after(() => browser.ws.close()); + const control = await browser.wait((message) => message.type === "session.snapshot" + && message.snapshot && typeof message.snapshot === "object"); + assert.equal(control.snapshot.state.activeThreadId, null); + assert.equal(control.snapshot.metadata.waitingForSession, true); + assert.equal(control.snapshot.metadata.attachReady, false); +}); + +test("relay keeps live transcript events rich while bounding the replay ring", () => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + eventByteLimit: 4_096, + }); + const liveClient = { id: "live", role: "operator", authenticated: true, subscribed: true, capture: [] }; + relay.clients.add(liveClient); + relay.recordEvent("output.chunk", { + threadId: "thread-large", + text: "delta", + outputTail: "o".repeat(32_000), + messages: [{ text: "m".repeat(32_000) }], + messagesPatch: { start: 0, deleteCount: 0, messages: [{ text: "p".repeat(32_000) }] }, + subagents: [{ output: "s".repeat(32_000) }], + raw: { transcript: "r".repeat(32_000) }, + }); + + assert.equal(liveClient.capture[0].payload.messagesPatch.messages[0].text.length, 32_000); + const replayed = relay.events[0]; + assert.equal(replayed.payload.text, "delta"); + assert.equal(replayed.payload.messages, undefined); + assert.equal(replayed.payload.messagesPatch, undefined); + assert.equal(replayed.payload.outputTail, undefined); + assert.equal(replayed.payload.subagents, undefined); + assert.equal(replayed.payload.raw, undefined); + assert.equal(replayed.payload.projectionInControlSnapshot, true); + + relay.clients.delete(liveClient); + for (let index = 0; index < 30; index += 1) { + relay.recordEvent("command.pending", { commandId: `command-${index}`, note: "n".repeat(400) }); + } + assert.ok(relay.eventBytes <= 4_096); + assert.equal(relay.eventBytes, relay.eventSizes.reduce((total, size) => total + size, 0)); + assert.ok(relay.events.length < 30, "byte budget should prune before the count limit"); +}); + +test("relay skips oversized replay windows and sends one authoritative snapshot", () => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + eventByteLimit: 64 * 1024, + replayByteLimit: 400, + }); + relay.state.messages = [{ kind: "assistant", text: "authoritative history" }]; + for (let index = 0; index < 5; index += 1) { + relay.recordEvent("command.pending", { commandId: `command-${index}`, note: "n".repeat(180) }); + } + const client = { id: "late", role: "operator", authenticated: true, subscribed: false, capture: [] }; + relay.clients.add(client); + relay.subscribe(client, 0); + + assert.equal(client.capture[0].type, "resync.required"); + assert.equal(client.capture[0].reason, "replay_too_large"); + assert.equal(client.capture.filter((message) => message.kind === "event").length, 0); + const snapshot = client.capture.find((message) => message.type === "session.snapshot"); + assert.equal(snapshot.snapshot.messages[0].text, "authoritative history"); + assert.equal(snapshot.snapshot.latestSeq, relay.nextSeq); +}); + +test("relay socket backpressure accounts for the serialized frame size", () => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + clientBufferedByteLimit: 2_048, + }); + const accepted = { + readyState: WebSocket.OPEN, + bufferedAmount: 1_000, + sent: [], + send(value) { this.sent.push(value); }, + close(code, reason) { this.closed = { code, reason }; }, + }; + relay.sendControl({ socket: accepted }, { type: "small", text: "x".repeat(500) }); + assert.equal(accepted.sent.length, 1); + assert.equal(accepted.closed, undefined); + + const slow = { + readyState: WebSocket.OPEN, + bufferedAmount: 1_900, + sent: [], + send(value) { this.sent.push(value); }, + close(code, reason) { this.closed = { code, reason }; }, + }; + relay.sendControl({ socket: slow }, { type: "small", text: "x".repeat(500) }); + assert.equal(slow.sent.length, 0); + assert.equal(slow.closed.code, 1013); +}); + +test("loopback relay can explicitly require tokens", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + authRequired: true, + operatorToken: "explicit-operator-token", + viewerToken: "explicit-viewer-token", + hostToken: "explicit-host-token", + }); + assert.equal(relay.authRequired, true); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const health = await fetch(`${base}/api/health`); + assert.equal((await health.json()).authRequired, true); + assert.equal((await fetch(`${base}/api/state`)).status, 401); + + const browser = await connectWs(base, "explicit-operator-token"); + t.after(() => browser.ws.close()); + assert.equal(browser.messages.find((message) => message.type === "auth.ok")?.role, "operator"); +}); + +test("non-loopback relay requires tokens by default", () => { + const relay = new CodexRelay({ + host: "0.0.0.0", + port: 0, + mode: "host", + }); + assert.equal(relay.authRequired, true); +}); + +test("deceptive numeric-looking hostnames never enable local no-auth", () => { + const relay = new CodexRelay({ host: "127.evil", port: 0, mode: "host" }); + assert.equal(relay.authRequired, true); +}); + +test("relay defaults to host mode so bare npm start does not spawn Codex", () => { + const relay = new CodexRelay({ host: "127.0.0.1", port: 0, authRequired: false }); + assert.equal(relay.mode, "host"); + assert.equal(relay.spawnCodex, false); +}); + +test("thread settings validation accepts null reasoning effort", () => { + const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" }); + assert.equal(relay.validateCommand("thread/settings/update", { + threadId: "thread-test", + threadSettings: { model: "gpt-5.6-sol", effort: null }, + }), null); + assert.match(relay.validateCommand("thread/settings/update", { + threadId: "thread-test", + threadSettings: { effort: 3 }, + }), /string or null/); +}); + +test("control mode validation only accepts sync and async", () => { + const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" }); + assert.equal(relay.validateCommand("control/mode/get", {}), null); + assert.equal(relay.validateCommand("control/mode/set", { mode: "sync" }), null); + assert.equal(relay.validateCommand("control/mode/set", { mode: "async" }), null); + assert.match(relay.validateCommand("control/mode/set", { mode: "attach" }), /sync or async/); + assert.match(relay.validateCommand("control/mode/get", { mode: "sync" }), /does not accept/); +}); + +test("authenticated relay forwards commands, output, approval requests, and replay", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "embedded", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + codexCommand: process.execPath, + codexArgs: [fakeServer], + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + await waitFor(() => relay.state.initialized); + + const health = await fetch(`${base}/api/health`); + assert.equal(health.status, 200); + const unauthorized = await fetch(`${base}/api/state`); + assert.equal(unauthorized.status, 401); + + const viewerCommand = await request(base, "viewer-test-token", "/api/command", { + method: "POST", + body: JSON.stringify({ commandId: "viewer-1", method: "thread/start", params: {} }), + headers: { "Content-Type": "application/json" }, + }); + assert.equal(viewerCommand.status, 400); + assert.equal((await viewerCommand.json()).code, "forbidden"); + + const client = await connectWs(base, "operator-test-token"); + t.after(() => client.ws.close()); + await client.wait((message) => message.type === "session.snapshot"); + + client.ws.send(JSON.stringify({ type: "command", commandId: "thread-1", method: "thread/start", params: { cwd: "/tmp", sandbox: "workspace-write" } })); + const threadResult = await client.wait((message) => message.type === "command.result" && message.payload?.commandId === "thread-1"); + assert.equal(threadResult.payload.ok, true); + const threadId = threadResult.payload.result.thread.id; + assert.equal(relay.state.activeThreadId, threadId); + + client.ws.send(JSON.stringify({ type: "command", commandId: "turn-1", method: "turn/start", params: { threadId, input: [{ type: "text", text: "approve this", text_elements: [] }] } })); + await client.wait((message) => message.type === "command.result" && message.payload?.commandId === "turn-1"); + const approval = await client.wait((message) => message.kind === "event" && message.type === "approval.requested"); + assert.equal(approval.payload.requestId, 9001); + assert.equal(approval.payload.method, "item/commandExecution/requestApproval"); + + client.ws.send(JSON.stringify({ type: "respond", requestId: "9001", result: { decision: "accept" } })); + await client.wait((message) => message.kind === "event" && message.type === "server.responded"); + await client.wait((message) => message.kind === "event" && message.type === "output.delta" && message.payload.text.includes("approval response")); + assert.equal(relay.pendingServerRequests.size, 0); + + const events = await request(base, "viewer-test-token", "/api/events?fromSeq=0"); + const eventBody = await events.json(); + assert.ok(eventBody.latestSeq >= 1); + assert.ok(eventBody.events.some((event) => event.type === "approval.requested")); +}); + +test("host mode proxies browser commands and preserves one-shot approval request ids", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const host = await connectHost(base, "separate-host-token", "session-from-vscode"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + await browser.wait((message) => message.type === "session.snapshot"); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + id: "host-event-1", + sessionId: host.sessionId, + seq: 1, + ts: new Date().toISOString(), + payload: {}, + })); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + assert.equal(relay.state.initialized, true); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "output.chunk", + id: "host-event-2", + sessionId: host.sessionId, + seq: 2, + ts: new Date().toISOString(), + payload: { stream: "codex", text: "output from VS Code" }, + })); + const output = await browser.wait((message) => message.kind === "event" && message.type === "output.chunk"); + assert.equal(output.payload.text, "output from VS Code"); + const ack = await host.wait((message) => message.kind === "ack" && message.seq === 2); + assert.equal(ack.sessionId, host.sessionId); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "host-thread-1", + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const hostCommand = await host.wait((message) => message.kind === "command" && message.commandId === "host-thread-1"); + assert.equal(hostCommand.type, "thread/start"); + assert.equal(hostCommand.payload.sandbox, "workspace-write"); + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "command.accepted", + id: "host-result-1", + sessionId: host.sessionId, + seq: 3, + ts: new Date().toISOString(), + payload: { commandId: "host-thread-1", method: "thread/start", ok: true, result: { thread: { id: "thread-on-host" } } }, + })); + const result = await browser.wait((message) => message.kind === "event" && message.type === "command.result" && message.payload.commandId === "host-thread-1"); + assert.equal(result.payload.ok, true); + assert.equal(relay.state.activeThreadId, "thread-on-host"); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "approval.requested", + id: "host-approval-1", + sessionId: host.sessionId, + seq: 4, + ts: new Date().toISOString(), + payload: { + requestId: 77, + method: "item/commandExecution/requestApproval", + commandHash: "approval-hash-77", + params: { command: "echo approved" }, + }, + })); + const approval = await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 77); + assert.equal(approval.payload.params.command, "echo approved"); + browser.ws.send(JSON.stringify({ type: "respond", requestId: "77", result: { decision: "accept" } })); + const responseCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond"); + assert.equal(responseCommand.payload.requestId, 77); + assert.equal(responseCommand.payload.decision, "allow"); + assert.equal(responseCommand.payload.commandHash, "approval-hash-77"); + assert.deepEqual(responseCommand.payload.response, { decision: "accept" }); + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "command.accepted", + id: "host-response-result-1", + sessionId: host.sessionId, + seq: 5, + ts: new Date().toISOString(), + payload: { commandId: responseCommand.commandId, method: "approval.respond", ok: true, result: { accepted: true } }, + })); + await browser.wait((message) => message.kind === "event" && message.type === "server.responded" && String(message.payload.requestId) === "77"); + assert.equal(relay.pendingServerRequests.size, 0); + + browser.ws.send(JSON.stringify({ type: "respond", requestId: "77", result: { decision: "accept" } })); + const duplicate = await browser.wait((message) => message.type === "response.rejected" && String(message.requestId) === "77"); + assert.equal(duplicate.code, "unknown_request"); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "approval.requested", + id: "host-approval-2", + sessionId: host.sessionId, + seq: 6, + ts: new Date().toISOString(), + payload: { requestId: 78, method: "item/commandExecution/requestApproval", params: { command: "echo retry" } }, + })); + await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 78); + browser.ws.send(JSON.stringify({ type: "respond", requestId: "78", result: { decision: "accept" } })); + const rejectedCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 78); + assert.equal(rejectedCommand.payload.decision, "allow"); + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "command.rejected", + id: "host-response-rejected-1", + sessionId: host.sessionId, + seq: 7, + ts: new Date().toISOString(), + payload: { commandId: rejectedCommand.commandId, method: "approval.respond", ok: false, error: { message: "local policy denied" } }, + })); + const responseRejected = await browser.wait((message) => message.type === "response.rejected" && String(message.requestId) === "78"); + assert.equal(responseRejected.code, "host_rejected"); + assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "78"), true); +}); + +test("host mode forwards session list/select and publishes the selected session", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "session-picker-host"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + const sendEvent = (type, seq, payload) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type, + id: `session-picker-event-${seq}`, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload, + })); + + sendEvent("connection.opened", 1, { mode: "attach" }); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + sendEvent("session.snapshot", 2, { + threadId: "thread-one", + activeThreadId: "thread-one", + title: "当前会话", + metadata: { + controlMode: "sync", + modeEpoch: 0, + capabilities: { + followsVscodeRoute: true, + sessionList: false, + sessionSelect: false, + sessionCreate: false, + threadSettings: true, + }, + }, + }); + await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot"); + assert.equal(relay.snapshot().metadata.controlMode, "sync"); + assert.equal(relay.snapshot().metadata.capabilities.sessionSelect, false); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "session-list-1", + method: "session/list", + params: { limit: 10 }, + })); + const listCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-list-1"); + assert.equal(listCommand.type, "session/list"); + assert.equal(listCommand.payload.limit, 10); + sendEvent("command.result", 3, { + commandId: "session-list-1", + method: "session/list", + ok: true, + result: { + activeThreadId: "thread-one", + sessions: [ + { threadId: "thread-one", title: "当前会话", active: true, available: true }, + { threadId: "thread-two", title: "另一个会话", active: false, available: true }, + ], + }, + }); + const listResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" && message.payload?.commandId === "session-list-1"); + assert.equal(listResult.payload.result.sessions.length, 2); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "session-select-1", + method: "session/select", + params: { threadId: "thread-two" }, + })); + const selectCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-select-1"); + assert.equal(selectCommand.type, "session/select"); + assert.equal(selectCommand.payload.threadId, "thread-two"); + sendEvent("session.switching", 4, { previousThreadId: "thread-one", targetThreadId: "thread-two" }); + sendEvent("session.snapshot", 5, { threadId: "thread-two", activeThreadId: "thread-two", title: "另一个会话" }); + sendEvent("session.selected", 6, { threadId: "thread-two", activeThreadId: "thread-two" }); + sendEvent("command.result", 7, { + commandId: "session-select-1", + method: "session/select", + ok: true, + result: { threadId: "thread-two", previousThreadId: "thread-one", switched: true, available: true }, + }); + await browser.wait((message) => message.kind === "event" && message.type === "session.switching"); + await browser.wait((message) => message.kind === "event" && message.type === "session.selected"); + const selectResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" && message.payload?.commandId === "session-select-1"); + assert.equal(selectResult.payload.result.threadId, "thread-two"); + assert.equal(relay.state.activeThreadId, "thread-two"); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "session-new-1", + method: "session/new", + params: {}, + })); + const newCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-new-1"); + assert.equal(newCommand.type, "session/new"); + sendEvent("command.result", 8, { + commandId: "session-new-1", + method: "session/new", + ok: true, + result: { opened: true, command: "chatgpt.newCodexPanel" }, + }); + const newResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" && message.payload?.commandId === "session-new-1"); + assert.equal(newResult.payload.result.command, "chatgpt.newCodexPanel"); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "mode-set-1", + method: "control/mode/set", + params: { mode: "async" }, + })); + const modeCommand = await host.wait((message) => message.kind === "command" && message.commandId === "mode-set-1"); + assert.equal(modeCommand.type, "control/mode/set"); + assert.deepEqual(modeCommand.payload, { mode: "async" }); + sendEvent("command.result", 9, { + commandId: "mode-set-1", + method: "control/mode/set", + ok: true, + result: { mode: "async", modeEpoch: 1 }, + }); + const modeResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" && message.payload?.commandId === "mode-set-1"); + assert.equal(modeResult.payload.result.mode, "async"); +}); + +test("host mode records normalized server.requested events for response routing", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "generic-request-host"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + id: "generic-ready", + sessionId: host.sessionId, + seq: 1, + ts: new Date().toISOString(), + payload: {}, + })); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "server.requested", + id: "generic-request", + sessionId: host.sessionId, + seq: 2, + ts: new Date().toISOString(), + payload: { + requestId: "generic-1", + method: "custom/request", + params: { prompt: "host-only" }, + }, + })); + const requestEvent = await browser.wait((message) => message.kind === "event" && message.type === "server.requested"); + assert.equal(requestEvent.payload.requestId, "generic-1"); + assert.equal(relay.pendingServerRequests.get("string:generic-1")?.method, "custom/request"); + + browser.ws.send(JSON.stringify({ type: "respond", requestId: "generic-1", result: { accepted: true } })); + const rejected = await browser.wait((message) => message.type === "response.rejected" && message.requestId === "generic-1"); + assert.equal(rejected.code, "unsupported_request"); + assert.equal(relay.pendingServerRequests.has("string:generic-1"), true); +}); + +test("host mode accepts versioned command, approval, and input response frames", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "versioned-host-session"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + id: "versioned-ready", + sessionId: host.sessionId, + seq: 1, + ts: new Date().toISOString(), + payload: {}, + })); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "thread.start", + commandId: "versioned-thread-1", + payload: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const threadCommand = await host.wait((message) => message.kind === "command" && message.commandId === "versioned-thread-1"); + assert.equal(threadCommand.type, "thread/start"); + assert.deepEqual(threadCommand.payload, { cwd: "/tmp", sandbox: "workspace-write" }); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "approval.requested", + id: "versioned-approval-request", + sessionId: host.sessionId, + seq: 2, + ts: new Date().toISOString(), + payload: { + requestId: 91, + method: "item/commandExecution/requestApproval", + commandHash: "versioned-hash-91", + params: { command: "echo versioned" }, + }, + })); + await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 91); + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "approval.respond", + commandId: "browser-approval-91", + payload: { requestId: 91, decision: "allow", response: { decision: "accept" } }, + })); + const approvalCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 91); + assert.equal(approvalCommand.payload.commandHash, "versioned-hash-91"); + assert.equal(approvalCommand.payload.decision, "allow"); + assert.deepEqual(approvalCommand.payload.response, { decision: "accept" }); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "input.requested", + id: "versioned-input-request", + sessionId: host.sessionId, + seq: 3, + ts: new Date().toISOString(), + payload: { + requestId: 92, + method: "item/tool/requestUserInput", + params: { questions: [{ id: "choice", question: "Continue?" }] }, + }, + })); + await browser.wait((message) => message.kind === "event" && message.type === "input.requested" && message.payload.requestId === 92); + browser.ws.send(JSON.stringify({ + type: "input.respond", + payload: { requestId: 92, answers: { choice: { answers: ["yes"] } } }, + })); + const inputCommand = await host.wait((message) => message.kind === "command" && message.type === "server.request.respond" && message.payload.requestId === 92); + assert.equal(inputCommand.payload.decision, "allow"); + assert.deepEqual(inputCommand.payload.response, { answers: { choice: { answers: ["yes"] } } }); + + host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "approval.requested", + id: "versioned-tagged-approval", + sessionId: host.sessionId, + seq: 4, + ts: new Date().toISOString(), + payload: { + requestId: 93, + method: "item/commandExecution/requestApproval", + params: { command: "echo amend" }, + }, + })); + await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 93); + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "approval.respond", + commandId: "browser-tagged-approval-93", + payload: { + requestId: 93, + decision: "allow", + response: { decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] } } }, + }, + })); + const taggedCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 93); + assert.equal(taggedCommand.payload.decision, "allow"); + assert.deepEqual(taggedCommand.payload.response, { + decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] } }, + }); +}); + +test("keeps numeric and string host approval ids distinct end to end", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "typed-id-session"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + const sendHostEvent = (type, seq, payload, id) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type, + id: id || `typed-${seq}`, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload, + })); + sendHostEvent("connection.opened", 1, {}); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + sendHostEvent("approval.requested", 2, { + requestId: 1, + method: "item/commandExecution/requestApproval", + params: { command: "echo numeric" }, + }); + sendHostEvent("approval.requested", 3, { + requestId: "1", + method: "item/commandExecution/requestApproval", + params: { command: "echo string" }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === 1); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === "1"); + assert.equal(relay.pendingServerRequests.size, 2); + + browser.ws.send(JSON.stringify({ type: "respond", requestId: 1, result: { decision: "accept" } })); + browser.ws.send(JSON.stringify({ type: "respond", requestId: "1", result: { decision: "accept" } })); + const firstCommand = await host.wait((message) => message.kind === "command" + && message.type === "approval.respond" + && message.payload?.requestId === 1); + const secondCommand = await host.wait((message) => message.kind === "command" + && message.type === "approval.respond" + && message.payload?.requestId === "1"); + assert.notEqual(firstCommand.commandId, secondCommand.commandId); + + sendHostEvent("command.result", 4, { + commandId: firstCommand.commandId, + method: "approval.respond", + ok: true, + result: { accepted: true }, + }); + sendHostEvent("command.result", 5, { + commandId: secondCommand.commandId, + method: "approval.respond", + ok: true, + result: { accepted: true }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "server.responded" + && message.payload?.requestId === 1); + await browser.wait((message) => message.kind === "event" + && message.type === "server.responded" + && message.payload?.requestId === "1"); + assert.equal(relay.pendingServerRequests.size, 0); +}); + +test("normalizes legacy approval decisions and rejects outer/inner conflicts", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "decision-schema-session"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + const sendHostEvent = (type, seq, payload) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type, + id: `decision-${seq}`, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload, + })); + sendHostEvent("connection.opened", 1, {}); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + sendHostEvent("approval.requested", 2, { + requestId: 201, + method: "applyPatchApproval", + params: { reason: "legacy patch" }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === 201); + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "approval.respond", + commandId: "legacy-approval-201", + payload: { requestId: 201, decision: "approved" }, + })); + const legacyCommand = await host.wait((message) => message.kind === "command" + && message.type === "approval.respond" + && message.payload?.requestId === 201); + assert.deepEqual(legacyCommand.payload.response, { decision: "approved" }); + + sendHostEvent("approval.requested", 3, { + requestId: 202, + method: "item/commandExecution/requestApproval", + params: { command: "echo conflict" }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === 202); + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "approval.respond", + commandId: "conflicting-approval-202", + payload: { + requestId: 202, + decision: "deny", + response: { decision: "accept" }, + }, + })); + const rejected = await browser.wait((message) => message.type === "response.rejected" + && message.requestId === 202); + assert.equal(rejected.code, "decision_mismatch"); + assert.equal(relay.pendingServerRequests.size, 2); + + sendHostEvent("approval.requested", 4, { + requestId: 203, + method: "item/commandExecution/requestApproval", + params: { command: "echo mixed-tag" }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === 203); + browser.ws.send(JSON.stringify({ + v: 1, + kind: "command", + type: "approval.respond", + commandId: "mixed-tag-approval-203", + payload: { + requestId: 203, + decision: "allow", + response: { + decision: { + acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] }, + futurePolicyGrant: { scope: "all" }, + }, + }, + }, + })); + const mixedRejected = await browser.wait((message) => message.type === "response.rejected" + && message.requestId === 203); + assert.equal(mixedRejected.code, "invalid_response"); + assert.equal(relay.pendingServerRequests.size, 3); +}); + +test("host command result cache is unavailable offline and isolated by host session", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const sendReady = (host, seq, id) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + id, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload: {}, + })); + const sendCommandResult = (host, seq, id, commandId, threadId) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "command.result", + id, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload: { + commandId, + method: "thread/start", + ok: true, + result: { thread: { id: threadId } }, + }, + })); + + const host1 = await connectHost(base, "separate-host-token", "host-session-1"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host1.ws.close()); + t.after(() => browser.ws.close()); + sendReady(host1, 1, "host1-ready"); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + const commandId = "reused-command-id"; + browser.ws.send(JSON.stringify({ + type: "command", + commandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + await host1.wait((message) => message.kind === "command" && message.commandId === commandId); + sendCommandResult(host1, 2, "host1-command-result", commandId, "thread-from-session-1"); + const firstResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" + && message.payload?.commandId === commandId + && message.payload?.result?.thread?.id === "thread-from-session-1"); + assert.equal(firstResult.payload.ok, true); + + const uncertainCommandId = "uncertain-command-id"; + browser.ws.send(JSON.stringify({ + type: "command", + commandId: uncertainCommandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + await host1.wait((message) => message.kind === "command" && message.commandId === uncertainCommandId); + + host1.ws.close(); + await waitFor(() => relay.hostClient === null); + await browser.wait((message) => message.type === "command.result" + && message.commandId === uncertainCommandId + && message.uncertain === true); + + // A stale success (or uncertain disconnect result) must not be replayed + // while no host is connected. + browser.ws.send(JSON.stringify({ + type: "command", + commandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const offline = await browser.wait((message) => message.type === "command.rejected" && message.commandId === commandId); + assert.equal(offline.code, "app_not_ready"); + browser.ws.send(JSON.stringify({ + type: "command", + commandId: uncertainCommandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const uncertainOffline = await browser.wait((message) => message.type === "command.rejected" && message.commandId === uncertainCommandId); + assert.equal(uncertainOffline.code, "app_not_ready"); + + // A reconnect carrying the same stable session id retains normal command + // idempotency and returns the cached result without forwarding a command. + const sameSessionHost = await connectHost(base, "separate-host-token", "host-session-1"); + t.after(() => sameSessionHost.ws.close()); + sendReady(sameSessionHost, 1, "same-session-ready"); + await browser.wait((message) => message.kind === "event" + && message.type === "connection.opened" + && message.payload?.source === "vscode-host"); + browser.ws.send(JSON.stringify({ + type: "command", + commandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const replay = await browser.wait((message) => message.type === "command.result" + && message.cached === true + && message.commandId === commandId); + assert.equal(replay.result.thread.id, "thread-from-session-1"); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(sameSessionHost.messages.some((message) => message.kind === "command" && message.commandId === commandId), false); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: uncertainCommandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + await sameSessionHost.wait((message) => message.kind === "command" && message.commandId === uncertainCommandId); + sendCommandResult(sameSessionHost, 2, "same-session-uncertain-result", uncertainCommandId, "thread-after-uncertain"); + const uncertainRetry = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" + && message.payload?.commandId === uncertainCommandId + && message.payload?.result?.thread?.id === "thread-after-uncertain"); + assert.equal(uncertainRetry.payload.ok, true); + + sameSessionHost.ws.close(); + await waitFor(() => relay.hostClient === null); + + // A different host session cannot reuse the old command id; it must receive + // a fresh command even though the browser retries the same id. + const host2 = await connectHost(base, "separate-host-token", "host-session-2"); + t.after(() => host2.ws.close()); + sendReady(host2, 1, "host2-ready"); + await browser.wait((message) => message.kind === "event" + && message.type === "connection.opened" + && message.payload?.source === "vscode-host"); + browser.ws.send(JSON.stringify({ + type: "command", + commandId, + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const forwarded = await host2.wait((message) => message.kind === "command" && message.commandId === commandId); + assert.equal(forwarded.type, "thread/start"); + sendCommandResult(host2, 2, "host2-command-result", commandId, "thread-from-session-2"); + const secondResult = await browser.wait((message) => message.kind === "event" + && message.type === "command.result" + && message.payload?.commandId === commandId + && message.payload?.result?.thread?.id === "thread-from-session-2"); + assert.equal(secondResult.payload.ok, true); +}); + +test("rejects non-object websocket frames without taking down the relay", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "embedded", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + codexCommand: process.execPath, + codexArgs: [fakeServer], + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const ws = new WebSocket(`ws://127.0.0.1:${address.port}/ws`); + t.after(() => ws.close()); + const invalidFrame = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for invalid frame response")), 5_000); + ws.once("error", reject); + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message.code === "invalid_frame") { + clearTimeout(timer); + resolve(message); + } + }); + }); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + ws.send("null"); + await invalidFrame; + const health = await fetch(`http://127.0.0.1:${address.port}/api/health`); + assert.equal(health.status, 200); +}); + +test("malformed percent escapes return 400 without crashing the relay", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + + const malformed = await fetch(`${base}/%ZZ`); + assert.equal(malformed.status, 400); + assert.equal(await malformed.text(), "Invalid URL"); + + const health = await fetch(`${base}/api/health`); + assert.equal(health.status, 200); +}); + +test("cleans embedded app pending commands and approvals when the child exits", async (t) => { + const crashServer = [ + "const readline = require('node:readline');", + "const input = readline.createInterface({ input: process.stdin });", + "const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');", + "input.on('line', (line) => {", + " let request; try { request = JSON.parse(line); } catch { return; }", + " if (request.method === 'initialize') { send({ id: request.id, result: { userAgent: 'crash-test' } }); return; }", + " if (request.method === 'thread/start') { send({ id: request.id, result: { thread: { id: 'thread-crash' }, cwd: '/tmp' } }); return; }", + " if (request.method === 'turn/start') {", + " send({ id: 4321, method: 'item/commandExecution/requestApproval', params: { command: 'echo crash' } });", + " setTimeout(() => process.exit(23), 30);", + " }", + "});", + ].join("\n"); + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "embedded", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + codexCommand: process.execPath, + codexArgs: ["-e", crashServer], + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const browser = await connectWs(base, "operator-test-token"); + t.after(() => browser.ws.close()); + await browser.wait((message) => message.type === "session.snapshot"); + await waitFor(() => relay.state.initialized === true); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "crash-thread", + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const threadResult = await browser.wait((message) => message.type === "command.result" + && message.payload?.commandId === "crash-thread"); + assert.equal(threadResult.payload.ok, true); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "crash-turn", + method: "turn/start", + params: { threadId: "thread-crash", input: [{ type: "text", text: "crash" }] }, + })); + await browser.wait((message) => message.type === "command.accepted" && message.commandId === "crash-turn"); + const approval = await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && String(message.payload?.requestId) === "4321"); + assert.equal(approval.payload.method, "item/commandExecution/requestApproval"); + assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "4321"), true); + + const uncertain = await browser.wait((message) => message.type === "command.result" + && message.commandId === "crash-turn" + && message.uncertain === true); + assert.equal(uncertain.retryable, true); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.expired" + && String(message.payload?.requestId) === "4321"); + await waitFor(() => relay.state.app === "offline"); + assert.equal(relay.state.initialized, false); + assert.equal(relay.pendingAppRequests.size, 0); + assert.equal(relay.pendingServerRequests.size, 0); + assert.equal(relay.appProcess, null); + assert.throws(() => relay.sendToApp({ method: "ping" }), (error) => error.code === "app_offline"); + + // The uncertain marker is deliberately not replayed. A retry while the app + // is offline receives the normal readiness error instead of a duplicate. + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "crash-turn", + method: "turn/start", + params: { threadId: "thread-crash", input: [{ type: "text", text: "retry" }] }, + })); + const rejected = await browser.wait((message) => message.type === "command.rejected" + && message.commandId === "crash-turn"); + assert.equal(rejected.code, "app_not_ready"); +}); + +test("cleans host pending work on app connection.closed and honors expiry events", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const host = await connectHost(base, "separate-host-token", "cleanup-host-session"); + const browser = await connectWs(base, "operator-test-token"); + t.after(() => host.ws.close()); + t.after(() => browser.ws.close()); + + const sendHostEvent = (type, seq, payload, id = `cleanup-${seq}`) => host.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type, + id, + sessionId: host.sessionId, + seq, + ts: new Date().toISOString(), + payload, + })); + + sendHostEvent("connection.opened", 1, {}); + await browser.wait((message) => message.kind === "event" && message.type === "connection.opened"); + + // Complete one command first so the app-unavailable transition has a cache + // entry to invalidate as well as an in-flight command to settle. + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "host-completed-before-exit", + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + await host.wait((message) => message.kind === "command" && message.commandId === "host-completed-before-exit"); + sendHostEvent("command.result", 2, { + commandId: "host-completed-before-exit", + method: "thread/start", + ok: true, + result: { thread: { id: "host-thread-cleanup" } }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "command.result" + && message.payload?.commandId === "host-completed-before-exit"); + assert.equal(relay.commandResults.has("host-completed-before-exit"), true); + + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "host-pending-before-exit", + method: "turn/start", + params: { threadId: "host-thread-cleanup", input: [{ type: "text", text: "pending" }] }, + })); + await host.wait((message) => message.kind === "command" && message.commandId === "host-pending-before-exit"); + assert.equal(relay.pendingHostCommands.has("host-pending-before-exit"), true); + + sendHostEvent("approval.requested", 3, { + requestId: 501, + method: "item/commandExecution/requestApproval", + params: { command: "echo pending approval" }, + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.requested" + && message.payload?.requestId === 501); + assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "501"), true); + + sendHostEvent("approval.expired", 4, { + requestId: 501, + method: "item/commandExecution/requestApproval", + }); + await browser.wait((message) => message.kind === "event" + && message.type === "approval.expired" + && message.payload?.requestId === 501); + assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "501"), false); + + sendHostEvent("connection.closed", 5, { message: "embedded app exited" }); + await browser.wait((message) => message.kind === "event" && message.type === "connection.closed"); + const uncertain = await browser.wait((message) => message.type === "command.result" + && message.commandId === "host-pending-before-exit" + && message.uncertain === true); + assert.equal(uncertain.error.code, "app_unavailable"); + await waitFor(() => relay.state.app === "offline"); + assert.equal(relay.state.initialized, false); + assert.equal(relay.state.hostConnected, true); + assert.equal(relay.pendingHostCommands.size, 0); + assert.equal(relay.pendingServerRequests.size, 0); + assert.equal(relay.commandResults.size, 0); + + // The host transport remains connected, but commands are rejected until it + // reports a fresh connection.opened/session snapshot. + browser.ws.send(JSON.stringify({ + type: "command", + commandId: "host-completed-before-exit", + method: "thread/start", + params: { cwd: "/tmp", sandbox: "workspace-write" }, + })); + const rejected = await browser.wait((message) => message.type === "command.rejected" + && message.commandId === "host-completed-before-exit"); + assert.equal(rejected.code, "app_not_ready"); +}); + +test("ignores a stale host connection.closed frame after host replacement", async (t) => { + const relay = new CodexRelay({ + host: "127.0.0.1", + port: 0, + mode: "host", + operatorToken: "operator-test-token", + viewerToken: "viewer-test-token", + hostToken: "separate-host-token", + }); + await relay.start(); + t.after(() => relay.stop()); + const address = relay.address(); + const base = `http://127.0.0.1:${address.port}`; + const firstHost = await connectHost(base, "separate-host-token", "stale-session-1"); + t.after(() => firstHost.ws.close()); + firstHost.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + sessionId: firstHost.sessionId, + seq: 1, + payload: {}, + })); + await waitFor(() => relay.state.app === "ready"); + const staleClient = relay.hostClient; + firstHost.ws.close(); + await waitFor(() => relay.hostClient === null); + + const secondHost = await connectHost(base, "separate-host-token", "stale-session-2"); + t.after(() => secondHost.ws.close()); + secondHost.ws.send(JSON.stringify({ + v: 1, + kind: "event", + type: "connection.opened", + sessionId: secondHost.sessionId, + seq: 1, + payload: {}, + })); + await waitFor(() => relay.state.app === "ready" && relay.hostClient?.sessionId === "stale-session-2"); + const sequenceBefore = relay.nextSeq; + + relay.ingestHostEvent(staleClient, { + v: 1, + kind: "event", + type: "connection.closed", + sessionId: "stale-session-1", + seq: 99, + payload: { message: "late old app exit" }, + }); + + assert.equal(relay.state.app, "ready"); + assert.equal(relay.state.initialized, true); + assert.equal(relay.state.hostConnected, true); + assert.equal(relay.state.hostSessionId, "stale-session-2"); + assert.equal(relay.nextSeq, sequenceBefore); +}); diff --git a/aether-vscodex/test/switchable-agent-adapter.test.js b/aether-vscodex/test/switchable-agent-adapter.test.js new file mode 100644 index 000000000..643807a46 --- /dev/null +++ b/aether-vscodex/test/switchable-agent-adapter.test.js @@ -0,0 +1,387 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js"); + +class FakeAdapter { + constructor(name, options = {}) { + this.name = name; + this.options = options; + this.listeners = new Set(); + this.calls = []; + this.disposed = false; + this.snapshotValue = options.snapshot ?? idleSnapshot(name); + } + + async start() { + this.calls.push(["start"]); + this.emit({ type: "candidate.starting", payload: { name: this.name } }); + if (this.options.startGate) await this.options.startGate.promise; + if (this.options.startError) throw this.options.startError; + this.emit({ type: "connection.opened", payload: { name: this.name } }); + } + + async startThread(params = {}) { return this.record("startThread", params); } + async newSession(params = {}) { return this.record("newSession", params); } + async startTurn(params) { return this.record("startTurn", params); } + async steerTurn(params) { return this.record("steerTurn", params); } + async updateThreadSettings(params) { return this.record("updateThreadSettings", params); } + async listSessions(params = {}) { return this.record("listSessions", params); } + async selectSession(params) { return this.record("selectSession", params); } + async interruptTurn(params) { return this.record("interruptTurn", params); } + async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); } + async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); } + async respondApproval(requestId, decision, reason, response) { + return this.record("respondApproval", { requestId, decision, reason, response }); + } + async denyPending(reason) { this.calls.push(["denyPending", reason]); } + + async snapshot() { + this.calls.push(["snapshot"]); + return structuredClone(this.snapshotValue); + } + + onEvent(listener) { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + emit(event) { + for (const listener of this.listeners) listener(event); + } + + async dispose() { + this.calls.push(["dispose"]); + this.disposed = true; + } + + record(method, params) { + this.calls.push([method, params]); + return { adapter: this.name, method, params }; + } +} + +function idleSnapshot(name) { + return { + threadId: `${name}-thread`, + turnId: null, + state: "idle", + pendingApprovals: [], + pendingRequests: [], + outputTail: "", + metadata: { adapter: name }, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => { + const sync = new FakeAdapter("sync"); + const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync }); + await adapter.start(); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.adapter, "sync"); + assert.equal(snapshot.metadata.mode, "sync"); + assert.equal(snapshot.metadata.controlMode, "sync"); + assert.equal(snapshot.metadata.modeEpoch, 0); + assert.deepEqual(snapshot.metadata.capabilities, { + followsVscodeRoute: true, + sessionList: false, + sessionSelect: false, + sessionCreate: false, + threadSettings: true, + }); + + await assert.rejects(adapter.listSessions(), /unavailable in sync mode/); + await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/); + await assert.rejects(adapter.newSession(), /unavailable in sync mode/); + await assert.rejects(adapter.startThread(), /unavailable in sync mode/); + assert.equal((await adapter.sendInput("hello")).adapter, "sync"); + assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync"); + await adapter.dispose(); +}); + +test("async mode proxies the complete AgentAdapter surface", async () => { + const independent = new FakeAdapter("async"); + const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent }); + await adapter.start(); + + await adapter.startThread({ cwd: "/workspace" }); + await adapter.newSession({ model: "codex" }); + await adapter.startTurn({ text: "start" }); + await adapter.steerTurn({ text: "steer" }); + await adapter.updateThreadSettings({ effort: "high" }); + await adapter.listSessions({ limit: 10 }); + await adapter.selectSession({ threadId: "thread-2" }); + await adapter.interruptTurn({ turnId: "turn-1" }); + await adapter.sendInput("input", { source: "web" }); + await adapter.cancel("turn-2", { reason: "user" }); + await adapter.respondApproval(7, "allow", "approved", { decision: "accept" }); + await adapter.denyPending("offline"); + + assert.deepEqual( + independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)), + [ + "startThread", + "newSession", + "startTurn", + "steerTurn", + "updateThreadSettings", + "listSessions", + "selectSession", + "interruptTurn", + "sendInput", + "cancel", + "respondApproval", + "denyPending", + ], + ); + await adapter.dispose(); +}); + +test("session/new falls back to thread/start for a minimal async adapter", async () => { + const independent = new FakeAdapter("async"); + independent.newSession = undefined; + const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent }); + await adapter.start(); + + const result = await adapter.newSession({ cwd: "/workspace" }); + assert.equal(result.method, "startThread"); + assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true); + await adapter.dispose(); +}); + +test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => { + const sync = new FakeAdapter("sync"); + const gate = deferred(); + const asyncAdapter = new FakeAdapter("async", { startGate: gate }); + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter, + }); + const events = []; + adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`)); + await adapter.start(); + events.length = 0; + + const switching = adapter.setControlMode({ mode: "async" }); + await Promise.resolve(); + sync.emit({ type: "old.while-current", payload: { name: "sync" } }); + assert.deepEqual(events, ["old.while-current:sync"]); + await assert.rejects(adapter.sendInput("racing input"), /mode is switching/); + gate.resolve(); + + const result = await switching; + assert.deepEqual(result, { + changed: true, + controlMode: "async", + previousControlMode: "sync", + modeEpoch: 1, + }); + assert.equal(sync.disposed, true); + assert.equal(adapter.getControlMode(), "async"); + assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async")); + assert.ok(events.includes("connection.opened:async")); + + sync.emit({ type: "old.after-commit", payload: { name: "sync" } }); + asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } }); + assert.equal(events.includes("old.after-commit:sync"), false); + assert.equal(events.includes("new.after-commit:async"), true); + + const snapshot = await adapter.snapshot(); + assert.equal(snapshot.metadata.modeEpoch, 1); + assert.deepEqual(snapshot.metadata.capabilities, { + followsVscodeRoute: false, + sessionList: true, + sessionSelect: true, + sessionCreate: true, + threadSettings: true, + }); + assert.equal((await adapter.listSessions()).adapter, "async"); + assert.equal((await adapter.newSession()).adapter, "async"); + await adapter.dispose(); +}); + +test("delegate snapshot events always carry authoritative mode metadata", async () => { + const sync = new FakeAdapter("sync"); + const asyncAdapter = new FakeAdapter("async"); + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter, + }); + const snapshots = []; + adapter.onEvent((event) => { + if (event.type === "session.snapshot") snapshots.push(event.payload); + }); + await adapter.start(); + + sync.emit({ + type: "session.snapshot", + threadId: "sync-thread-2", + payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } }, + }); + assert.deepEqual(snapshots.at(-1).metadata, { + adapter: "sync", + route: "/thread/2", + mode: "sync", + controlMode: "sync", + modeEpoch: 0, + capabilities: { + followsVscodeRoute: true, + sessionList: false, + sessionSelect: false, + sessionCreate: false, + threadSettings: true, + }, + }); + + await adapter.setControlMode({ mode: "async" }); + snapshots.length = 0; + asyncAdapter.emit({ + type: "session.snapshot", + threadId: "async-thread-2", + payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } }, + }); + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].metadata.adapter, "async"); + assert.equal(snapshots[0].metadata.title, "Second"); + assert.equal(snapshots[0].metadata.controlMode, "async"); + assert.equal(snapshots[0].metadata.modeEpoch, 1); + assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false); + assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true); + await adapter.dispose(); +}); + +test("active turns and pending requests prevent a mode switch", async (t) => { + const cases = [ + ["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }], + ["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }], + ["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }], + ["pending approval", { + ...idleSnapshot("sync"), + pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }], + }], + ["pending input", { + ...idleSnapshot("sync"), + pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }], + }], + ]; + + for (const [name, snapshot] of cases) { + await t.test(name, async () => { + const sync = new FakeAdapter("sync", { snapshot }); + let factoryCalls = 0; + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => { + factoryCalls += 1; + return mode === "sync" ? sync : new FakeAdapter("async"); + }, + }); + await adapter.start(); + await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/); + assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter"); + assert.equal(adapter.getControlMode(), "sync"); + await adapter.dispose(); + }); + } +}); + +test("candidate startup failure leaves the old adapter authoritative", async () => { + const sync = new FakeAdapter("sync"); + const failed = new FakeAdapter("async", { startError: new Error("candidate failed") }); + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => mode === "sync" ? sync : failed, + }); + const events = []; + adapter.onEvent((event) => events.push(event.type)); + await adapter.start(); + events.length = 0; + + await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/); + assert.equal(adapter.getControlMode(), "sync"); + assert.equal(failed.disposed, true); + assert.equal(sync.disposed, false); + assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private"); + assert.equal((await adapter.sendInput("still attached")).adapter, "sync"); + assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0); + await adapter.dispose(); +}); + +test("a mode factory cannot reuse the currently active adapter instance", async () => { + const shared = new FakeAdapter("shared"); + const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared }); + await adapter.start(); + + await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/); + assert.equal(adapter.getControlMode(), "sync"); + assert.equal(shared.disposed, false); + assert.equal((await adapter.sendInput("still live")).adapter, "shared"); + await adapter.dispose(); +}); + +test("listener failures cannot turn a committed switch into a rejected command", async () => { + const sync = new FakeAdapter("sync"); + const asyncAdapter = new FakeAdapter("async"); + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter, + }); + adapter.onEvent(() => { throw new Error("consumer failed"); }); + await adapter.start(); + + const result = await adapter.setControlMode({ mode: "async" }); + assert.equal(result.changed, true); + assert.equal(adapter.getControlMode(), "async"); + assert.equal(sync.disposed, true); + await adapter.dispose(); +}); + +test("a turn that appears while the candidate starts aborts before commit", async () => { + const sync = new FakeAdapter("sync"); + const gate = deferred(); + const candidate = new FakeAdapter("async", { startGate: gate }); + const adapter = new SwitchableAgentAdapter({ + initialMode: "sync", + createAdapter: (mode) => mode === "sync" ? sync : candidate, + }); + await adapter.start(); + + const switching = adapter.setControlMode({ mode: "async" }); + await Promise.resolve(); + sync.snapshotValue.turnId = "turn-race"; + sync.snapshotValue.state = "active"; + gate.resolve(); + + await assert.rejects(switching, /turn or request is active/); + assert.equal(adapter.getControlMode(), "sync"); + assert.equal(candidate.disposed, true); + assert.equal(sync.disposed, false); + sync.snapshotValue.turnId = null; + sync.snapshotValue.state = "idle"; + await adapter.dispose(); +}); + +test("control mode validation and idempotent switches are explicit", async () => { + const sync = new FakeAdapter("sync"); + const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync }); + await adapter.start(); + + await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/); + assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), { + changed: false, + controlMode: "sync", + previousControlMode: "sync", + modeEpoch: 0, + }); + await adapter.dispose(); +}); diff --git a/aether-vscodex/vscode-extension/.gitignore b/aether-vscodex/vscode-extension/.gitignore new file mode 100644 index 000000000..a08e1da2d --- /dev/null +++ b/aether-vscodex/vscode-extension/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.vsix diff --git a/aether-vscodex/vscode-extension/.vscodeignore b/aether-vscodex/vscode-extension/.vscodeignore new file mode 100644 index 000000000..26b7df07e --- /dev/null +++ b/aether-vscodex/vscode-extension/.vscodeignore @@ -0,0 +1,9 @@ +src/** +.gitignore +tsconfig.json +**/*.map +node_modules/@types/** +node_modules/typescript/** +node_modules/.package-lock.json +*.tsbuildinfo +*.vsix diff --git a/aether-vscodex/vscode-extension/LICENSE b/aether-vscodex/vscode-extension/LICENSE new file mode 100644 index 000000000..f0d01c030 --- /dev/null +++ b/aether-vscodex/vscode-extension/LICENSE @@ -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. diff --git a/aether-vscodex/vscode-extension/README.md b/aether-vscodex/vscode-extension/README.md new file mode 100644 index 000000000..a2d479a00 --- /dev/null +++ b/aether-vscodex/vscode-extension/README.md @@ -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. diff --git a/aether-vscodex/vscode-extension/l10n/bundle.l10n.json b/aether-vscodex/vscode-extension/l10n/bundle.l10n.json new file mode 100644 index 000000000..6aa35d6db --- /dev/null +++ b/aether-vscodex/vscode-extension/l10n/bundle.l10n.json @@ -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." +} diff --git a/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json b/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json new file mode 100644 index 000000000..6a2cefdc1 --- /dev/null +++ b/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json @@ -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。" +} diff --git a/aether-vscodex/vscode-extension/package-lock.json b/aether-vscodex/vscode-extension/package-lock.json new file mode 100644 index 000000000..e746545ed --- /dev/null +++ b/aether-vscodex/vscode-extension/package-lock.json @@ -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 + } + } + } + } +} diff --git a/aether-vscodex/vscode-extension/package.json b/aether-vscodex/vscode-extension/package.json new file mode 100644 index 000000000..9cf9e1e7a --- /dev/null +++ b/aether-vscodex/vscode-extension/package.json @@ -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" + } +} diff --git a/aether-vscodex/vscode-extension/package.nls.json b/aether-vscodex/vscode-extension/package.nls.json new file mode 100644 index 000000000..03c11081e --- /dev/null +++ b/aether-vscodex/vscode-extension/package.nls.json @@ -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." +} diff --git a/aether-vscodex/vscode-extension/package.nls.zh-cn.json b/aether-vscodex/vscode-extension/package.nls.zh-cn.json new file mode 100644 index 000000000..58314d4f0 --- /dev/null +++ b/aether-vscodex/vscode-extension/package.nls.zh-cn.json @@ -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 断开后自动重连。" +} diff --git a/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs b/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs new file mode 100644 index 000000000..a4db0a17a --- /dev/null +++ b/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs @@ -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 }); diff --git a/aether-vscodex/vscode-extension/src/bridge.ts b/aether-vscodex/vscode-extension/src/bridge.ts new file mode 100644 index 000000000..9f6050499 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/bridge.ts @@ -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; + logger?: Logger; +} +export interface CodexRemoteBridge { + adapter: AgentAdapter; + relay: RelayTransport; + host: RelayHost; + start(): Promise; + stop(): Promise; +} + +/** 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(), + }; +} diff --git a/aether-vscodex/vscode-extension/src/cli.ts b/aether-vscodex/vscode-extension/src/cli.ts new file mode 100644 index 000000000..511b7defe --- /dev/null +++ b/aether-vscodex/vscode-extension/src/cli.ts @@ -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 { + 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 => { + 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; +}); diff --git a/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts b/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts new file mode 100644 index 000000000..56a3fff80 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts @@ -0,0 +1,1914 @@ +import { createHash } from "node:crypto"; + +import { + AgentAdapter, + AgentEvent, + asJsonObject, + asJsonValue, + approvalDecisionKindForMethod, + Disposable, + hasApprovalDecisionField, + isRecord, + JsonObject, + JsonRpcId, + JsonRpcRequest, + JsonValue, + Logger, + PendingApproval, + SessionSnapshot, + isJsonRpcId, + jsonRpcIdKey, +} from "./protocol"; +import { JsonlRpcClient, JsonlRpcClientOptions } from "./jsonlRpc"; + +const APPROVAL_METHODS = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "applyPatchApproval", + "execCommandApproval", +]); + +const INPUT_REQUEST_METHODS = new Set([ + "item/tool/requestUserInput", + "mcpServer/elicitation/request", +]); + +// Paginated threads can contain many thousands of items. Keep hydration +// bounded so one history request cannot exhaust the extension host or exceed +// the relay frame limit, while still covering normal long-running sessions. +const HISTORY_PAGE_SIZE = 100; +const MAX_HISTORY_TURN_PAGES = 100; +const MAX_HISTORY_TURNS = HISTORY_PAGE_SIZE * MAX_HISTORY_TURN_PAGES; +const MAX_HISTORY_ITEM_PAGES = 100; + +export interface CodexAgentAdapterOptions extends JsonlRpcClientOptions { + clientName?: string; + clientTitle?: string | null; + clientVersion?: string; + initializeCapabilities?: JsonObject; + defaultCwd?: string; + maxOutputTailChars?: number; + approvalTimeoutMs?: number; + /** Handle non-approval server requests (auth refresh, tool calls, etc.). */ + onServerRequest?: (request: JsonRpcRequest) => Promise; + autoRejectUnsupportedRequests?: boolean; +} + +interface PendingRequest { + request: JsonRpcRequest; + approval?: PendingApproval; + timer?: NodeJS.Timeout; +} + +/** + * AgentAdapter implementation backed by a child `codex app-server --stdio`. + * + * It deliberately does not shell out for individual tasks. All task and + * approval operations go through the app-server JSON-RPC channel. + */ +export class CodexAgentAdapter implements AgentAdapter { + readonly rpc: JsonlRpcClient; + private readonly options: Required< + Pick + > & + Omit; + private readonly listeners = new Set<(event: AgentEvent) => void>(); + private readonly pending = new Map(); + private threadId: string | null = null; + private turnId: string | null = null; + private state = "disconnected"; + private outputTail = ""; + private messages: JsonValue[] = []; + private sessionMetadata: JsonObject = {}; + private availableModels: JsonValue[] = []; + private historyComplete = true; + private sessionSwitching = false; + private started = false; + private readonly rpcDisposables: Disposable[]; + + constructor(options: CodexAgentAdapterOptions = {}, rpc?: JsonlRpcClient) { + this.options = { + clientName: options.clientName ?? "codex-remote-collab", + clientVersion: options.clientVersion ?? "0.4.0", + maxOutputTailChars: options.maxOutputTailChars ?? 32_000, + approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000, + ...options, + }; + this.rpc = rpc ?? new JsonlRpcClient(options); + this.rpcDisposables = [ + this.rpc.onNotification((message) => this.handleNotification(message.method, message.params)), + this.rpc.onServerRequest((request) => this.handleServerRequest(request)), + this.rpc.onExit((error) => { + this.started = false; + this.state = "disconnected"; + // A new app-server process cannot safely reuse ids from the dead + // process. Clear them before publishing the terminal event so a + // reconnect/restart cannot steer or interrupt a stale turn. + this.threadId = null; + this.turnId = null; + this.outputTail = ""; + this.messages = []; + this.sessionMetadata = {}; + this.historyComplete = true; + this.sessionSwitching = false; + // The child cannot receive a response after exit. Drop every pending + // approval/input and publish an explicit expiry so the relay removes + // its corresponding request instead of retaining a stale request id. + this.dropPendingRequests(error?.message ?? "app-server exited"); + this.emit({ type: "connection.closed", payload: error ? { message: error.message } : {} }); + }), + ]; + } + + async start(): Promise { + if (this.started) return; + await this.rpc.start(); + this.state = "initializing"; + const capabilities = { + experimentalApi: true, + requestAttestation: false, + ...(this.options.initializeCapabilities ?? {}), + }; + await this.rpc.request("initialize", { + clientInfo: { + name: this.options.clientName, + title: this.options.clientTitle ?? null, + version: this.options.clientVersion, + }, + capabilities, + }); + this.rpc.notify("initialized"); + this.started = true; + this.state = "idle"; + await this.refreshAvailableModels(); + this.emit({ type: "connection.opened", payload: {} }); + } + + async startThread(params: JsonObject = {}): Promise { + this.ensureStarted(); + this.ensureSessionChangeAllowed(); + const requestParams: JsonObject = { ...params }; + if (requestParams.cwd === undefined && this.options.defaultCwd) { + requestParams.cwd = this.options.defaultCwd; + } + const result = await this.rpc.request("thread/start", requestParams); + this.historyComplete = true; + this.commitThreadResult(result, true); + await this.publishHistorySnapshot(); + return result; + } + + async newSession(params: JsonObject = {}): Promise { + return this.startThread(params); + } + + async listSessions(params: JsonObject = {}): Promise { + this.ensureStarted(); + const result = await this.rpc.request("thread/list", normalizeThreadListParams(params)); + const threads = isRecord(result) && Array.isArray(result.data) + ? result.data.filter(isRecord).map((thread) => asJsonObject(thread)) + : []; + const sessions = threads.map((thread) => { + const id = typeof thread.id === "string" ? thread.id : ""; + const preview = typeof thread.preview === "string" ? thread.preview : ""; + const name = typeof thread.name === "string" ? thread.name : ""; + const cwd = typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined; + const updatedAt = finiteNumber(thread.updatedAt) ?? finiteNumber(thread.createdAt); + return { + threadId: id, + title: sessionTitle(name || preview, id), + updatedAtMs: updatedAt === undefined ? null : Math.round(updatedAt * 1_000), + ...(cwd ? { cwd } : {}), + active: id === this.threadId, + available: Boolean(id), + ...(thread.status !== undefined ? { status: redactJson(thread.status) } : {}), + ...(thread.source !== undefined ? { source: redactJson(thread.source) } : {}), + }; + }).filter((session) => session.threadId); + return asJsonValue({ + sessions, + activeThreadId: this.threadId, + nextCursor: isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : null, + backwardsCursor: isRecord(result) && typeof result.backwardsCursor === "string" ? result.backwardsCursor : null, + }); + } + + async selectSession(params: JsonObject): Promise { + this.ensureStarted(); + const target = (this.stringParam(params, "threadId") ?? this.stringParam(params, "conversationId"))?.trim(); + if (!target) throw new Error("session/select requires threadId"); + this.ensureSessionChangeAllowed(); + if (this.sessionSwitching) throw new Error("a session switch is already in progress"); + + const previousThreadId = this.threadId; + const previousState = this.state; + this.sessionSwitching = true; + this.state = "syncing"; + this.historyComplete = false; + this.emit({ + type: "session.switching", + threadId: target, + payload: { previousThreadId, targetThreadId: target }, + }); + try { + const resumeResult = await this.resumeThreadForSelection(target); + const hydrated = await this.ensureThreadHistory(resumeResult); + this.commitThreadResult(resumeResult, true, hydrated); + const switched = previousThreadId !== target; + this.emit({ + type: "session.selected", + threadId: target, + payload: { previousThreadId, threadId: target, switched, available: true }, + }); + await this.publishHistorySnapshot(); + return asJsonValue({ + threadId: target, + previousThreadId, + switched, + available: true, + result: redactJson(resumeResult), + }); + } catch (error) { + this.state = previousState; + this.historyComplete = true; + throw error; + } finally { + this.sessionSwitching = false; + } + } + + async updateThreadSettings(params: JsonObject): Promise { + this.ensureStarted(); + const threadId = this.stringParam(params, "threadId") ?? this.threadId; + if (!threadId) throw new Error("thread/settings/update requires threadId"); + if (threadId !== this.threadId) throw new Error("thread/settings/update can only target the selected thread"); + const settings = normalizeThreadSettings(params); + const result = await this.rpc.request("thread/settings/update", { threadId, ...settings.wire }); + this.mergeThreadSettings(settings.display); + await this.publishAuthoritativeSnapshot(); + return result; + } + + async startTurn(params: JsonObject): Promise { + this.ensureStarted(); + const requestParams = this.normalizeTurnParams(params); + const result = await this.rpc.request("turn/start", requestParams); + const turn = isRecord(result) && isRecord(result.turn) ? result.turn : undefined; + const nextTurnId = turn && typeof turn.id === "string" + ? turn.id + : isRecord(result) && typeof result.turnId === "string" + ? result.turnId + : undefined; + if (nextTurnId) this.turnId = nextTurnId; + if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId; + this.state = "active"; + return result; + } + + async steerTurn(params: JsonObject): Promise { + this.ensureStarted(); + const requestParams = this.normalizeTurnParams(params, true); + const result = await this.rpc.request("turn/steer", requestParams); + const nextTurnId = isRecord(result) && typeof result.turnId === "string" + ? result.turnId + : isRecord(result) && isRecord(result.turn) && typeof result.turn.id === "string" + ? result.turn.id + : undefined; + if (nextTurnId) this.turnId = nextTurnId; + if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId; + this.state = "active"; + return result; + } + + async interruptTurn(params: JsonObject): Promise { + this.ensureStarted(); + const threadId = this.stringParam(params, "threadId") ?? this.threadId; + const turnId = this.stringParam(params, "turnId") ?? this.turnId; + if (!threadId || !turnId) throw new Error("turn/interrupt requires threadId and turnId"); + const result = await this.rpc.request("turn/interrupt", { threadId, turnId }); + this.state = "idle"; + // Do this eagerly rather than waiting for the asynchronous + // `turn/completed` notification. A caller may submit the next turn as + // soon as the interrupt response resolves. + this.turnId = null; + return result; + } + + async sendInput(text: string, params: JsonObject = {}): Promise { + const body: JsonObject = { ...params, text }; + if (this.turnId) return this.steerTurn({ ...body, expectedTurnId: this.turnId }); + return this.startTurn(body); + } + + async cancel(taskId?: string, params: JsonObject = {}): Promise { + return this.interruptTurn({ ...params, ...(taskId ? { turnId: taskId } : {}) }); + } + + async respondApproval( + requestId: JsonRpcId, + decision: "allow" | "deny" | "cancel", + reason?: string, + response?: JsonValue, + ): Promise { + this.ensureStarted(); + const key = jsonRpcIdKey(requestId); + const pending = this.pending.get(key); + if (!pending) throw new Error(`unknown or already resolved approval request: ${key}`); + const rawResponse = response === undefined + ? this.defaultApprovalResponse(pending.request.method, pending.request.params, decision, reason) + : asJsonValue(response); + validateAdapterResponse(pending.request.method, rawResponse, decision); + const result = normalizeServerResponse(pending.request.method, rawResponse); + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(key); + try { + this.rpc.respond(pending.request.id, result); + } catch (error) { + // Do not leave a request retryable forever when the child exits between + // the liveness check and the JSON-RPC write. + this.emit({ + type: pending.approval ? "approval.expired" : "input.expired", + threadId: pending.approval?.threadId, + turnId: pending.approval?.turnId, + requestId, + payload: { requestId: asJsonValue(requestId), reason: "app-server unavailable" }, + }); + throw error; + } + this.emit({ + type: pending.approval ? "approval.resolved" : "input.resolved", + threadId: pending.approval?.threadId, + turnId: pending.approval?.turnId, + requestId, + payload: { + requestId: asJsonValue(requestId), + decision, + ...(reason ? { reason } : {}), + }, + }); + return result; + } + + async denyPending(reason = "relay disconnected"): Promise { + const pendingIds = [...this.pending.values()].map((entry) => entry.request.id); + for (const requestId of pendingIds) { + try { + await this.respondApproval(requestId, "deny", reason); + } catch { + // The app-server may have resolved or exited between the snapshot and + // this fail-closed cleanup pass. + } + } + } + + async snapshot(): Promise { + const status = this.statusSnapshot(); + return { + threadId: this.threadId, + turnId: this.turnId, + state: this.state, + pendingApprovals: [...this.pending.values()] + .map((entry) => entry.approval) + .filter((approval): approval is PendingApproval => Boolean(approval)), + pendingRequests: [...this.pending.values()].map((entry) => ({ + requestId: entry.request.id, + method: entry.request.method, + params: redactJson(asJsonObject(entry.request.params)), + ...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}), + ...(entry.approval?.risk ? { risk: entry.approval.risk } : {}), + ...(entry.approval?.summary ? { summary: entry.approval.summary } : {}), + ...(entry.approval?.createdAt ? { createdAt: entry.approval.createdAt } : {}), + ...(entry.approval?.expiresAt ? { expiresAt: entry.approval.expiresAt } : {}), + })), + outputTail: this.outputTail, + messages: this.messages.map((message) => asJsonValue(message)), + status, + activity: status.activity, + turnStatus: status.turnStatus, + activeFlags: [...status.activeFlags], + startedAtMs: status.startedAtMs, + durationMs: status.durationMs, + elapsedMs: status.elapsedMs, + metadata: { + adapter: "codex-app-server", + mode: "async", + started: this.started, + historyComplete: this.historyComplete, + ...this.sessionMetadata, + availableModels: this.availableModels.map((model) => asJsonValue(model)), + models: this.availableModels.map((model) => asJsonValue(model)), + }, + }; + } + + onEvent(listener: (event: AgentEvent) => void): Disposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + async dispose(): Promise { + for (const [key, entry] of this.pending) { + if (entry.timer) clearTimeout(entry.timer); + // A disconnected host must never leave a command approval hanging. + try { + this.rpc.respond(entry.request.id, normalizeServerResponse( + entry.request.method, + this.defaultApprovalResponse(entry.request.method, entry.request.params, "deny", "bridge stopped"), + )); + } catch { + // The child may already have exited. + } + this.pending.delete(key); + } + for (const disposable of this.rpcDisposables) disposable.dispose(); + this.rpc.close(); + this.started = false; + this.state = "disconnected"; + this.threadId = null; + this.turnId = null; + this.messages = []; + this.outputTail = ""; + this.sessionMetadata = {}; + this.historyComplete = true; + this.sessionSwitching = false; + } + + private ensureStarted(): void { + if (!this.started || !this.rpc.running) throw new Error("Codex app-server is not started"); + } + + private ensureSessionChangeAllowed(): void { + if (this.turnId || this.pending.size) { + throw new Error("cannot change sessions while a turn or approval is active"); + } + } + + private async refreshAvailableModels(): Promise { + try { + const models: JsonValue[] = []; + let cursor: string | null = null; + for (let page = 0; page < 10; page += 1) { + const result = await this.rpc.request("model/list", { + limit: 100, + includeHidden: false, + ...(cursor ? { cursor } : {}), + }); + if (!isRecord(result)) break; + if (Array.isArray(result.data)) { + for (const model of result.data) { + if (isRecord(model) && typeof model.model === "string") models.push(redactJson(model)); + } + } + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : null; + if (!cursor) break; + } + this.availableModels = models; + } catch (error) { + // Older app-server builds may not expose the model catalog. Thread and + // turn control should remain usable with a manually supplied model. + this.options.logger?.debug?.("Unable to load app-server model catalog", error); + } + } + + private async resumeThreadForSelection(threadId: string): Promise { + try { + // Paginated history is the stable protocol for newer app-server builds. + // Request the first page in chronological order so the renderer can use + // one consistent ordering while older pages are appended. + return await this.rpc.request("thread/resume", { + threadId, + excludeTurns: true, + initialTurnsPage: { + limit: HISTORY_PAGE_SIZE, + sortDirection: "asc", + itemsView: "full", + }, + }); + } catch (error) { + if (isPaginationUnsupportedError(error)) { + // Older app-server versions reject the pagination fields. Retry with + // the legacy full-history shape before giving up. + try { + return await this.rpc.request("thread/resume", { threadId, excludeTurns: false }); + } catch (legacyError) { + if (!isActiveWriterError(legacyError)) throw legacyError; + return this.readThreadMetadata(threadId, legacyError); + } + } + if (isActiveWriterError(error)) { + // A thread currently owned by another app-server cannot be resumed by + // this process, but its metadata and paginated history are still + // readable. Keep the conversation view available and report the + // writer limitation through metadata rather than showing an empty + // session after a successful list click. + return this.readThreadMetadata(threadId, error); + } + throw error; + } + } + + private async readThreadMetadata(threadId: string, originalError: unknown): Promise { + try { + return await this.rpc.request("thread/read", { threadId, includeTurns: false }); + } catch (error) { + this.options.logger?.debug?.("Unable to read a thread after resume failed", error); + throw originalError instanceof Error ? originalError : error; + } + } + + private async ensureThreadHistory(result: JsonValue): Promise { + const response = isRecord(result) ? result : {}; + const thread = extractThread(result); + if (!thread) throw new Error("thread/resume returned no thread"); + const turns = Array.isArray(thread.turns) ? thread.turns : undefined; + const threadId = typeof thread.id === "string" ? thread.id : undefined; + if (!threadId) throw new Error("thread/resume returned a thread without id"); + + const paginated = thread.historyMode === "paginated" || isRecord(response.initialTurnsPage); + const hasHistoryEvidence = Boolean( + (typeof thread.preview === "string" && thread.preview.trim()) + || ((finiteNumber(thread.updatedAt) ?? 0) > (finiteNumber(thread.createdAt) ?? 0)), + ); + if (!paginated && turns && (turns.length > 0 || !hasHistoryEvidence)) { + this.historyComplete = true; + return thread; + } + + if (paginated) { + try { + const initialPage = isRecord(response.initialTurnsPage) + ? response.initialTurnsPage + : undefined; + const hydratedTurns = await this.loadPaginatedTurns(threadId, initialPage); + return { ...thread, turns: hydratedTurns }; + } catch (error) { + // Some transitional server builds advertise paginated threads but do + // not implement one of the page methods. Fall back to the legacy read + // endpoint so the session remains usable instead of appearing blank. + this.options.logger?.debug?.("Paginated thread hydration failed; trying thread/read", error); + try { + const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true }); + const hydrated = extractThread(readResult); + if (hydrated) { + this.historyComplete = true; + return hydrated; + } + } catch (readError) { + this.options.logger?.debug?.("Legacy thread/read fallback failed", readError); + } + this.historyComplete = false; + return { ...thread, turns: turns ?? [] }; + } + } + + if (turns && turns.length > 0) { + this.historyComplete = true; + return thread; + } + + const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true }); + const hydrated = extractThread(readResult); + if (!hydrated) throw new Error("thread/read returned no thread"); + this.historyComplete = true; + return hydrated; + } + + private async loadPaginatedTurns(threadId: string, initialPage?: JsonObject): Promise { + const byId = new Map(); + let page: JsonObject | undefined = initialPage; + let cursor: string | null = null; + let complete = true; + + for (let index = 0; index < MAX_HISTORY_TURN_PAGES; index += 1) { + if (!page) { + const response = await this.rpc.request("thread/turns/list", { + threadId, + limit: HISTORY_PAGE_SIZE, + sortDirection: "asc", + itemsView: "full", + ...(cursor ? { cursor } : {}), + }); + page = isRecord(response) ? response : {}; + } + + const pageData = Array.isArray(page.data) ? page.data : []; + for (const value of pageData) { + if (!isRecord(value)) continue; + const id = typeof value.id === "string" ? value.id : `turn-${byId.size}`; + byId.set(id, { ...value }); + if (byId.size >= MAX_HISTORY_TURNS) { + complete = false; + break; + } + } + if (byId.size >= MAX_HISTORY_TURNS) break; + const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null; + page = undefined; + cursor = next; + if (!cursor) break; + } + if (cursor) complete = false; + + const turns = sortHistoryTurns([...byId.values()]); + await this.hydrateTurnItems(threadId, turns, (value) => { + complete = complete && value; + }); + this.historyComplete = complete; + return turns; + } + + private async hydrateTurnItems( + threadId: string, + turns: JsonObject[], + markComplete: (complete: boolean) => void, + ): Promise { + for (const turn of turns) { + if (turn.itemsView === "full" || typeof turn.id !== "string") continue; + const items: JsonObject[] = Array.isArray(turn.items) + ? turn.items.filter(isRecord).map((item) => ({ ...(item as JsonObject) })) + : []; + const itemIds = new Set(items.map((item) => typeof item.id === "string" ? item.id : "")); + let cursor: string | null = null; + let complete = true; + try { + for (let pageIndex = 0; pageIndex < MAX_HISTORY_ITEM_PAGES; pageIndex += 1) { + const response = await this.rpc.request("thread/items/list", { + threadId, + turnId: turn.id, + limit: HISTORY_PAGE_SIZE, + sortDirection: "asc", + ...(cursor ? { cursor } : {}), + }); + const page = isRecord(response) ? response : {}; + for (const entry of Array.isArray(page.data) ? page.data : []) { + if (!isRecord(entry) || !isRecord(entry.item)) continue; + const item = { ...entry.item }; + const id = typeof item.id === "string" ? item.id : ""; + if (!id || !itemIds.has(id)) { + items.push(item); + if (id) itemIds.add(id); + } + } + const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null; + cursor = next; + if (!cursor) break; + if (pageIndex === MAX_HISTORY_ITEM_PAGES - 1) complete = false; + } + } catch (error) { + complete = false; + this.options.logger?.debug?.(`Unable to hydrate items for turn ${turn.id}`, error); + } + turn.items = items; + turn.itemsView = "full"; + markComplete(complete); + } + } + + private commitThreadResult(result: JsonValue, replaceHistory: boolean, hydratedThread?: JsonObject): void { + const thread = hydratedThread ?? extractThread(result); + if (!thread) throw new Error("app-server thread response did not include a thread"); + const nextThreadId = typeof thread.id === "string" ? thread.id : undefined; + if (!nextThreadId) throw new Error("app-server thread response did not include thread.id"); + + this.threadId = nextThreadId; + if (replaceHistory) { + this.messages = projectThreadMessages(thread); + this.outputTail = outputTailFromMessages(this.messages, this.options.maxOutputTailChars); + } + const activeTurn = latestActiveTurn(thread); + this.turnId = activeTurn && typeof activeTurn.id === "string" ? activeTurn.id : null; + this.state = this.turnId ? "active" : statusToState(thread.status); + if (this.state === "notLoaded" || this.state === "unknown") this.state = "idle"; + + const response = isRecord(result) ? result : {}; + const title = sessionTitle( + typeof thread.name === "string" ? thread.name : typeof thread.preview === "string" ? thread.preview : "", + nextThreadId, + ); + const cwd = typeof response.cwd === "string" + ? redactText(response.cwd) + : typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined; + const model = typeof response.model === "string" ? response.model : undefined; + const effort = response.reasoningEffort === null || typeof response.reasoningEffort === "string" + ? response.reasoningEffort + : undefined; + const threadSettings: JsonObject = { + ...(cwd ? { cwd } : {}), + ...(model ? { model } : {}), + ...(effort !== undefined ? { effort: asJsonValue(effort) } : {}), + ...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}), + ...(response.serviceTier !== undefined ? { serviceTier: redactJson(response.serviceTier) } : {}), + ...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}), + ...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}), + ...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}), + }; + this.sessionMetadata = { + thread: threadMetadata(thread), + title, + ...(cwd ? { cwd } : {}), + ...(model ? { model, latestModel: model } : {}), + ...(effort !== undefined ? { effort: asJsonValue(effort), latestReasoningEffort: asJsonValue(effort) } : {}), + ...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}), + ...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}), + ...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}), + ...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}), + threadSettings, + }; + } + + private mergeThreadSettings(settings: JsonObject): void { + const current = isRecord(this.sessionMetadata.threadSettings) + ? this.sessionMetadata.threadSettings + : {}; + const next = { ...current, ...redactJson(settings) as JsonObject }; + this.sessionMetadata.threadSettings = next; + for (const key of ["model", "modelProvider", "serviceTier", "approvalPolicy", "approvalsReviewer", "sandboxPolicy", "permissions", "cwd"] as const) { + if (settings[key] !== undefined) this.sessionMetadata[key] = redactJson(settings[key]); + } + if (settings.model !== undefined) this.sessionMetadata.latestModel = redactJson(settings.model); + if (settings.effort !== undefined) { + this.sessionMetadata.effort = redactJson(settings.effort); + this.sessionMetadata.latestReasoningEffort = redactJson(settings.effort); + } + } + + private async publishHistorySnapshot(): Promise { + const snapshot = await this.snapshot(); + this.emit({ + type: "output.snapshot", + threadId: this.threadId ?? undefined, + turnId: this.turnId ?? undefined, + payload: { + stream: "codex", + text: this.outputTail, + messages: this.messages.map((message) => asJsonValue(message)), + structureChanged: true, + historyComplete: this.historyComplete, + encoding: "utf8", + metadata: snapshot.metadata ?? {}, + status: snapshot.status ? asJsonValue(snapshot.status) : null, + }, + }); + await this.publishAuthoritativeSnapshot(snapshot); + } + + private async publishAuthoritativeSnapshot(existingSnapshot?: SessionSnapshot): Promise { + const snapshot = existingSnapshot ?? await this.snapshot(); + this.emit({ + type: "session.snapshot", + threadId: snapshot.threadId ?? undefined, + turnId: snapshot.turnId ?? undefined, + payload: asJsonObject(snapshot), + status: snapshot.status, + }); + } + + private statusSnapshot(): NonNullable { + const currentMessages = this.turnId + ? this.messages.filter((message) => isRecord(message) && message.turnId === this.turnId) + : []; + const startedAtValues = currentMessages + .map((message) => isRecord(message) ? finiteNumber(message.startedAtMs) : undefined) + .filter((value): value is number => value !== undefined); + const durationValues = currentMessages + .map((message) => isRecord(message) ? finiteNumber(message.durationMs) : undefined) + .filter((value): value is number => value !== undefined); + const startedAtMs = startedAtValues.length ? Math.min(...startedAtValues) : null; + const durationMs = durationValues.length ? Math.max(...durationValues) : null; + const pendingApprovals = [...this.pending.values()].some((entry) => Boolean(entry.approval)); + const pendingInput = [...this.pending.values()].some((entry) => !entry.approval); + const latest = currentMessages.length && isRecord(currentMessages[currentMessages.length - 1]) + ? currentMessages[currentMessages.length - 1] as JsonObject + : undefined; + let activity = this.turnId ? "thinking" : this.state; + if (pendingApprovals) activity = "waitingOnApproval"; + else if (pendingInput) activity = "waitingOnUserInput"; + else if (latest?.kind === "edit") activity = "editing"; + else if (latest?.itemType === "commandExecution") activity = "running"; + else if (latest?.kind === "reasoning" || latest?.kind === "plan") activity = "thinking"; + return { + activity, + turnStatus: this.turnId ? "inProgress" : this.state === "idle" ? "completed" : this.state, + activeFlags: [ + ...(pendingApprovals ? ["waitingOnApproval"] : []), + ...(pendingInput ? ["waitingOnUserInput"] : []), + ], + startedAtMs, + durationMs, + elapsedMs: this.turnId && startedAtMs !== null ? Math.max(0, Date.now() - startedAtMs) : null, + }; + } + + private upsertItem(item: JsonObject, turnId?: string, turn?: JsonObject, lifecycle: JsonObject = {}): void { + const projected = projectThreadItem(item, turnId, turn, lifecycle); + const itemId = typeof projected.itemId === "string" ? projected.itemId : undefined; + const index = itemId + ? this.messages.findIndex((message) => isRecord(message) + && message.itemId === itemId + && (turnId === undefined || message.turnId === turnId)) + : -1; + if (index >= 0) this.messages[index] = projected; + else this.messages.push(projected); + } + + private appendItemDelta(params: JsonObject, kind: "assistant" | "reasoning" | "plan" | "output", delta: string): void { + const itemId = this.extractString(params, "itemId"); + const turnId = this.extractString(params, "turnId"); + if (!itemId) return; + let index = this.messages.findIndex((message) => isRecord(message) + && message.itemId === itemId + && (turnId === undefined || message.turnId === turnId)); + if (index < 0) { + const placeholder: JsonObject = { + id: itemId, + itemId, + ...(turnId ? { turnId } : {}), + itemType: kind === "output" ? "commandExecution" : kind === "assistant" ? "agentMessage" : kind, + role: kind === "assistant" ? "assistant" : kind === "reasoning" ? "reasoning" : "tool", + kind: kind === "output" ? "tool" : kind, + text: "", + status: "inProgress", + }; + this.messages.push(placeholder); + index = this.messages.length - 1; + } + const current = isRecord(this.messages[index]) ? this.messages[index] as JsonObject : {}; + if (kind === "output") current.output = `${typeof current.output === "string" ? current.output : ""}${redactText(delta)}`; + else current.text = `${typeof current.text === "string" ? current.text : ""}${redactText(delta)}`; + this.messages[index] = current; + } + + private normalizeTurnParams(input: JsonObject, steering = false): JsonObject { + const params: JsonObject = { ...input }; + const threadId = this.stringParam(params, "threadId") ?? this.threadId; + if (!threadId) throw new Error(`${steering ? "turn/steer" : "turn/start"} requires threadId (start a thread first)`); + params.threadId = threadId; + + const suppliedInput = params.input; + if (typeof suppliedInput === "string") { + params.input = [this.textInput(suppliedInput)]; + } else if (Array.isArray(suppliedInput)) { + params.input = suppliedInput.map((item) => (typeof item === "string" ? this.textInput(item) : asJsonValue(item))); + } else { + const text = this.stringParam(params, "text") ?? this.stringParam(params, "message") ?? this.stringParam(params, "prompt"); + if (!text) throw new Error("turn request requires input or text"); + params.input = [this.textInput(text)]; + } + delete params.text; + delete params.message; + delete params.prompt; + if (steering) { + const expectedTurnId = this.stringParam(params, "expectedTurnId") ?? this.turnId; + if (!expectedTurnId) throw new Error("turn/steer requires expectedTurnId (no active turn)"); + params.expectedTurnId = expectedTurnId; + } + return params; + } + + private textInput(text: string): JsonObject { + return { type: "text", text, text_elements: [] }; + } + + private stringParam(params: JsonObject, key: string): string | undefined { + return typeof params[key] === "string" ? (params[key] as string) : undefined; + } + + private handleNotification(method: string, rawParams: JsonValue | undefined): void { + const params = asJsonObject(rawParams); + const threadId = this.extractString(params, "threadId") ?? this.extractNestedString(params, "thread", "id"); + const turnId = this.extractString(params, "turnId") ?? this.extractNestedString(params, "turn", "id"); + if (threadId && this.threadId && threadId !== this.threadId) { + this.options.logger?.debug?.(`Ignored late ${method} notification for non-selected thread ${threadId}`); + return; + } + const activeTurnId = this.turnId; + if (threadId && !this.threadId) this.threadId = threadId; + // A late completion for an earlier turn must not overwrite a newer turn + // that was started while the old completion notification was in flight. + // Token usage is thread telemetry, not a lifecycle transition. It can be + // delivered after `turn/completed`, so do not resurrect an old turn (or + // replace a newer active turn) just because the notification carries a + // turnId. + if (turnId + && method !== "thread/tokenUsage/updated" + && (method !== "turn/completed" || !activeTurnId || activeTurnId === turnId)) { + this.turnId = turnId; + } + + let type = "app-server.notification"; + let payload: JsonObject = { method, params: redactJson(params) as JsonObject }; + let outputText: string | undefined; + + switch (method) { + case "thread/started": + type = "session.created"; + payload = { thread: redactJson(params.thread ?? params) as JsonValue }; + if (isRecord(params.thread)) { + try { + this.commitThreadResult({ thread: params.thread }, true); + } catch (error) { + this.options.logger?.debug?.("Unable to hydrate thread/started notification", error); + this.state = "idle"; + } + } else { + this.state = "idle"; + } + break; + case "thread/name/updated": { + const title = this.extractString(params, "threadName")?.trim(); + if (title) this.sessionMetadata.title = redactText(title); + payload = redactJson(params) as JsonObject; + break; + } + case "thread/settings/updated": + payload = redactJson(params) as JsonObject; + if (isRecord(params.threadSettings)) this.mergeThreadSettings(params.threadSettings); + break; + case "thread/tokenUsage/updated": { + const tokenUsage = projectTokenUsage(params.tokenUsage); + // `redactJson` treats every key containing "token" as secret. Keep + // its redacted params for diagnostics, then add the numeric usage + // projection that the browser usage picker and relay snapshot need. + payload = redactJson(params) as JsonObject; + if (tokenUsage) { + this.sessionMetadata.tokenUsage = tokenUsage; + // The official extension names this field latestTokenUsageInfo; + // retain the shorter alias for existing relay/browser clients. + this.sessionMetadata.latestTokenUsageInfo = tokenUsage; + payload.tokenUsage = tokenUsage; + payload.latestTokenUsageInfo = tokenUsage; + // Persist usage through the same authoritative snapshot channel as + // thread settings so a browser reconnect does not fall back to the + // previous context-window value. + void this.publishAuthoritativeSnapshot().catch((error) => { + this.options.logger?.debug?.("Unable to publish token usage snapshot", error); + }); + } else { + // Do not let the redaction sentinel for malformed usage data look + // like a real update and clear a previously valid browser value. + delete payload.tokenUsage; + delete payload.latestTokenUsageInfo; + } + break; + } + case "thread/status/changed": + type = "session.state"; + payload = redactJson(params) as JsonObject; + this.state = statusToState(params.status); + break; + case "thread/closed": + case "thread/deleted": + type = "session.closed"; + payload = redactJson(params) as JsonObject; + this.state = "closed"; + this.threadId = null; + this.turnId = null; + this.messages = []; + this.outputTail = ""; + this.sessionMetadata = {}; + this.historyComplete = true; + break; + case "turn/started": + type = "task.started"; + payload = redactJson(params) as JsonObject; + this.state = "active"; + if (isRecord(params.turn) && Array.isArray(params.turn.items)) { + const turn = asJsonObject(params.turn); + for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn); + } + break; + case "turn/completed": { + const status = this.extractNestedString(params, "turn", "status"); + type = status === "interrupted" ? "task.cancelled" : "task.finished"; + payload = redactJson(params) as JsonObject; + // Do not let a stale completion transition a newer active turn to + // idle. Notifications are asynchronous and can arrive after the + // caller has already started the next turn. + if (!turnId || !activeTurnId || turnId === activeTurnId) { + this.state = "idle"; + this.turnId = null; + } + if (isRecord(params.turn) && Array.isArray(params.turn.items)) { + const turn = asJsonObject(params.turn); + for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn); + } + break; + } + case "item/agentMessage/delta": + type = "output.chunk"; + outputText = this.extractString(params, "delta"); + if (outputText) this.appendItemDelta(params, "assistant", outputText); + payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" }; + break; + case "item/plan/delta": + type = "output.chunk"; + outputText = this.extractString(params, "delta") ?? this.extractString(params, "text"); + if (outputText) this.appendItemDelta(params, "plan", outputText); + payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" }; + break; + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": + type = "output.chunk"; + outputText = this.extractString(params, "delta") ?? this.extractString(params, "text"); + if (outputText) this.appendItemDelta(params, "reasoning", outputText); + payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" }; + break; + case "command/exec/outputDelta": + case "process/outputDelta": + case "item/commandExecution/outputDelta": + type = "output.chunk"; + outputText = decodeOutput(params); + if (method === "item/commandExecution/outputDelta" && outputText) { + this.appendItemDelta(params, "output", outputText); + } + payload = { + stream: outputStream(params), + text: redactText(outputText), + encoding: "utf8", + }; + break; + case "item/fileChange/outputDelta": + type = "output.chunk"; + outputText = this.extractString(params, "delta"); + if (outputText) this.appendItemDelta(params, "output", outputText); + payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" }; + break; + case "item/started": + type = "item.started"; + payload = redactJson(params) as JsonObject; + if (isRecord(params.item)) { + this.upsertItem(params.item, turnId, undefined, { + ...(finiteNumber(params.startedAtMs) !== undefined ? { startedAtMs: finiteNumber(params.startedAtMs) as number } : {}), + status: "inProgress", + }); + } + break; + case "item/completed": + type = "item.completed"; + payload = redactJson(params) as JsonObject; + if (isRecord(params.item)) { + this.upsertItem(params.item, turnId, undefined, { + ...(finiteNumber(params.completedAtMs) !== undefined ? { completedAtMs: finiteNumber(params.completedAtMs) as number } : {}), + }); + } + break; + case "serverRequest/resolved": + payload = redactJson(params) as JsonObject; + if (isJsonRpcId(params.requestId)) { + const pending = this.pending.get(jsonRpcIdKey(params.requestId)); + type = pending?.approval ? "approval.resolved" : pending ? "input.resolved" : "approval.resolved"; + if (pending?.timer) clearTimeout(pending.timer); + this.pending.delete(jsonRpcIdKey(params.requestId)); + } else { + type = "approval.resolved"; + } + break; + case "error": + type = "error"; + payload = redactJson(params) as JsonObject; + break; + case "warning": + case "guardianWarning": + type = "warning"; + payload = redactJson(params) as JsonObject; + break; + default: + break; + } + + // Events and snapshots must expose the same redacted view. Keeping raw + // text in outputTail would leak credentials through `snapshot()` even + // though the corresponding output event was redacted. + if (outputText) this.appendOutput(outputText); + this.emit({ + type, + threadId, + turnId, + payload, + raw: redactJson({ method, params }) as JsonValue, + }); + } + + private handleServerRequest(request: JsonRpcRequest): void { + const params = asJsonObject(request.params); + const requestThreadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId"); + if (requestThreadId && this.threadId && requestThreadId !== this.threadId) { + // A resumed app-server can finish delivering an old request after the + // browser has selected another thread. Never expose or retain it as an + // approval for the selected conversation. + try { + if (APPROVAL_METHODS.has(request.method) || INPUT_REQUEST_METHODS.has(request.method)) { + this.rpc.respond(request.id, normalizeServerResponse( + request.method, + this.defaultApprovalResponse(request.method, request.params, "deny", "thread is no longer selected"), + )); + } else { + this.rpc.respondError(request.id, -32000, "thread is no longer selected"); + } + } catch { + // The child may have exited while the stale request was in flight. + } + this.options.logger?.debug?.(`Rejected stale ${request.method} request for non-selected thread ${requestThreadId}`); + return; + } + if (APPROVAL_METHODS.has(request.method)) { + const approval = this.toPendingApproval(request, params); + const entry: PendingRequest = { request, approval }; + if (this.options.approvalTimeoutMs > 0) { + entry.timer = setTimeout(() => this.expireApproval(request.id), this.options.approvalTimeoutMs); + approval.expiresAt = Date.now() + this.options.approvalTimeoutMs; + } + this.pending.set(jsonRpcIdKey(request.id), entry); + this.emit({ + type: "approval.requested", + threadId: approval.threadId, + turnId: approval.turnId, + requestId: request.id, + payload: { + ...approval.payload, + params: approval.payload, + requestId: asJsonValue(request.id), + method: request.method, + action: approval.action, + risk: approval.risk, + summary: approval.summary, + ...(approval.commandHash ? { commandHash: approval.commandHash } : {}), + ...(approval.expiresAt ? { expiresAt: approval.expiresAt } : {}), + }, + raw: redactJson(request) as JsonValue, + }); + return; + } + + if (INPUT_REQUEST_METHODS.has(request.method)) { + const entry: PendingRequest = { request }; + if (this.options.approvalTimeoutMs > 0) { + entry.timer = setTimeout(() => this.expirePendingRequest(request.id), this.options.approvalTimeoutMs); + } + this.pending.set(jsonRpcIdKey(request.id), entry); + this.emit({ + type: "input.requested", + threadId: this.extractString(params, "threadId"), + turnId: this.extractString(params, "turnId"), + requestId: request.id, + payload: { requestId: asJsonValue(request.id), method: request.method, params: redactJson(params) as JsonValue }, + raw: redactJson(request) as JsonValue, + }); + return; + } + + this.emit({ type: "server.request", requestId: request.id, payload: { method: request.method, params: redactJson(params) as JsonValue }, raw: redactJson(request) as JsonValue }); + void this.resolveServerRequest(request); + } + + private async resolveServerRequest(request: JsonRpcRequest): Promise { + try { + const result = await this.options.onServerRequest?.(request); + if (result !== undefined) { + this.rpc.respond(request.id, result); + } else if (this.options.autoRejectUnsupportedRequests !== false) { + this.rpc.respondError(request.id, -32601, `Unsupported app-server request: ${request.method}`); + } + } catch (error) { + this.rpc.respondError(request.id, -32000, error instanceof Error ? error.message : String(error)); + } + } + + private toPendingApproval(request: JsonRpcRequest, params: JsonObject): PendingApproval { + const threadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId"); + const turnId = this.extractString(params, "turnId"); + const itemId = this.extractString(params, "itemId") ?? this.extractString(params, "callId"); + const command = this.extractString(params, "command") ?? this.extractCommand(params); + const reason = this.extractString(params, "reason"); + const action = approvalAction(request.method); + const risk = approvalRisk(request.method, command, params.commandActions); + const summary = reason || command || `${action} requested by Codex`; + return { + requestId: request.id, + method: request.method, + threadId, + turnId, + itemId, + action, + risk, + summary: redactText(summary), + commandHash: hashJson(params), + createdAt: Date.now(), + payload: redactJson(params) as JsonObject, + }; + } + + private async expireApproval(requestId: JsonRpcId): Promise { + return this.expirePendingRequest(requestId); + } + + private async expirePendingRequest(requestId: JsonRpcId): Promise { + const key = jsonRpcIdKey(requestId); + const pending = this.pending.get(key); + if (!pending) return; + this.pending.delete(key); + try { + this.rpc.respond(requestId, normalizeServerResponse( + pending.request.method, + this.expiredApprovalResponse(pending.request.method, pending.request.params), + )); + } catch { + // The app-server may have exited while the timer was pending. + } + this.emit({ + type: pending.approval ? "approval.expired" : "input.expired", + threadId: pending.approval?.threadId, + turnId: pending.approval?.turnId, + requestId, + payload: { requestId: asJsonValue(requestId), reason: "approval expired" }, + }); + } + + private dropPendingRequests(reason: string): void { + const pendingEntries = [...this.pending.values()]; + this.pending.clear(); + for (const pending of pendingEntries) { + if (pending.timer) clearTimeout(pending.timer); + this.emit({ + type: pending.approval ? "approval.expired" : "input.expired", + threadId: pending.approval?.threadId, + turnId: pending.approval?.turnId, + requestId: pending.request.id, + payload: { requestId: asJsonValue(pending.request.id), reason }, + }); + } + } + + private defaultApprovalResponse(method: string, rawParams: JsonValue | undefined, decision: "allow" | "deny" | "cancel", reason?: string): JsonValue { + const params = asJsonObject(rawParams); + if (method === "item/permissions/requestApproval") { + return { + permissions: decision === "allow" ? (params.permissions ?? {}) : {}, + scope: "turn", + }; + } + if (method === "item/tool/requestUserInput") { + return { answers: {} }; + } + if (method === "mcpServer/elicitation/request") { + return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null }; + } + if (method === "applyPatchApproval" || method === "execCommandApproval") { + if (decision === "allow") return { decision: "approved" }; + if (decision === "cancel") return { decision: "abort" }; + return { decision: { denied: { rejection: reason || "Denied remotely" } } }; + } + return { decision: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline" }; + } + + private expiredApprovalResponse(method: string, rawParams: JsonValue | undefined): JsonValue { + if (method === "applyPatchApproval" || method === "execCommandApproval") { + // Preserve the legacy app-server wire decision for an actual timeout; + // `denied` is reserved for an explicit policy rejection. + return { decision: "timed_out" }; + } + return this.defaultApprovalResponse(method, rawParams, "deny", "approval expired"); + } + + private extractString(params: JsonObject, key: string): string | undefined { + return typeof params[key] === "string" ? (params[key] as string) : undefined; + } + + private extractNestedString(params: JsonObject, parent: string, key: string): string | undefined { + const nested = params[parent]; + return isRecord(nested) && typeof nested[key] === "string" ? (nested[key] as string) : undefined; + } + + private extractCommand(params: JsonObject): string | undefined { + const command = params.command; + if (Array.isArray(command)) return command.filter((item): item is string => typeof item === "string").join(" "); + // Newer command-approval requests may leave `command` null while + // providing parsed actions. Include every action command in the risk + // input so a dangerous subcommand cannot be hidden behind command:null. + if (Array.isArray(params.commandActions)) { + const commands = params.commandActions + .map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined) + .filter((item): item is string => Boolean(item)); + if (commands.length) return commands.join(" && "); + } + return undefined; + } + + private appendOutput(text: string): void { + // Keep this invariant at the storage boundary. New notification handlers + // can append raw text later without creating a snapshot-only secret leak. + const safeText = redactText(text); + this.outputTail = `${this.outputTail}${safeText}`; + if (this.outputTail.length > this.options.maxOutputTailChars) { + this.outputTail = this.outputTail.slice(-this.options.maxOutputTailChars); + } + } + + private emit(event: AgentEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (error) { + this.options.logger?.warn?.("Agent event listener failed", error); + } + } + } +} + +const THREAD_SORT_KEYS = new Set(["created_at", "updated_at", "recency_at", "section_position"]); +const THREAD_SOURCE_KINDS = new Set([ + "cli", "vscode", "exec", "appServer", "subAgent", "subAgentReview", + "subAgentCompact", "subAgentThreadSpawn", "subAgentOther", "unknown", +]); + +function normalizeThreadListParams(params: JsonObject): JsonObject { + const result: JsonObject = {}; + if (params.cursor === null || typeof params.cursor === "string") result.cursor = params.cursor; + const limit = finiteNumber(params.limit); + result.limit = Math.max(1, Math.min(100, Number.isInteger(limit) ? limit as number : 50)); + const sortKey = typeof params.sortKey === "string" ? params.sortKey : "updated_at"; + result.sortKey = THREAD_SORT_KEYS.has(sortKey) ? sortKey : "updated_at"; + result.sortDirection = params.sortDirection === "asc" ? "asc" : "desc"; + if (typeof params.archived === "boolean") result.archived = params.archived; + if (params.sectionId === null || typeof params.sectionId === "string") result.sectionId = params.sectionId; + if (typeof params.useStateDbOnly === "boolean") result.useStateDbOnly = params.useStateDbOnly; + const searchTerm = typeof params.searchTerm === "string" + ? params.searchTerm + : typeof params.query === "string" ? params.query : undefined; + if (searchTerm?.trim()) result.searchTerm = searchTerm.trim(); + if (typeof params.cwd === "string") result.cwd = params.cwd; + else if (Array.isArray(params.cwd) && params.cwd.every((value) => typeof value === "string")) { + result.cwd = asJsonValue(params.cwd); + } + if (Array.isArray(params.modelProviders) && params.modelProviders.every((value) => typeof value === "string")) { + result.modelProviders = asJsonValue(params.modelProviders); + } + if (Array.isArray(params.sourceKinds)) { + const sourceKinds = params.sourceKinds.filter((value): value is string => typeof value === "string" && THREAD_SOURCE_KINDS.has(value)); + if (sourceKinds.length) result.sourceKinds = sourceKinds; + } + return result; +} + +function normalizeThreadSettings(params: JsonObject): { wire: JsonObject; display: JsonObject } { + const source = isRecord(params.threadSettings) ? params.threadSettings : params; + const wire: JsonObject = {}; + const display: JsonObject = {}; + for (const key of ["model", "cwd", "effort", "serviceTier", "summary", "personality"] as const) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const value = source[key]; + if (value !== null && (typeof value !== "string" || !value.trim())) { + throw new Error(`thread settings ${key} must be a non-empty string or null`); + } + wire[key] = typeof value === "string" ? value.trim() : null; + display[key] = wire[key]; + } + for (const key of ["collaborationMode", "multiAgentMode"] as const) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const value = source[key]; + if (value !== null && typeof value !== "string" && !isRecord(value)) { + throw new Error(`thread settings ${key} must be a string, object, or null`); + } + wire[key] = asJsonValue(value); + display[key] = wire[key]; + } + for (const key of ["approvalPolicy", "approvalsReviewer"] as const) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const value = source[key]; + if (value !== null && typeof value !== "string") { + throw new Error(`thread settings ${key} must be a string or null`); + } + wire[key] = asJsonValue(value); + display[key] = wire[key]; + } + + const hasPermissions = Object.prototype.hasOwnProperty.call(source, "permissions"); + const hasSandboxPolicy = Object.prototype.hasOwnProperty.call(source, "sandboxPolicy"); + if (hasPermissions) { + const value = source.permissions; + if (value !== null && (typeof value !== "string" || !value.trim())) { + throw new Error("thread settings permissions must be a non-empty string or null"); + } + wire.permissions = typeof value === "string" ? value.trim() : null; + display.permissions = wire.permissions; + } + if (hasSandboxPolicy) { + const value = source.sandboxPolicy; + if (value !== null && typeof value !== "string" && !isRecord(value)) { + throw new Error("thread settings sandboxPolicy must be a string, object, or null"); + } + display.sandboxPolicy = asJsonValue(value); + if (!hasPermissions) { + if (typeof value === "string") { + const permission = LEGACY_SANDBOX_PERMISSIONS[value]; + if (!permission) throw new Error(`unsupported legacy sandbox policy: ${value}`); + wire.permissions = permission; + display.permissions = permission; + } else { + wire.sandboxPolicy = asJsonValue(value); + } + } + } + if (!Object.keys(wire).length) throw new Error("thread settings update requires at least one setting"); + return { wire, display }; +} + +const LEGACY_SANDBOX_PERMISSIONS: Record = { + "read-only": ":read-only", + "workspace-write": ":workspace", + "danger-full-access": ":danger-full-access", +}; + +function isActiveWriterError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + return /active\s+writer|writer\s+lock|already\s+has\s+an\s+active\s+writer|thread\s+.*(?:locked|lock)|lock\s+.*thread/i.test(message); +} + +function isPaginationUnsupportedError(error: unknown): boolean { + if (isActiveWriterError(error)) return false; + const code = isRecord(error) && typeof error.code === "number" ? error.code : undefined; + if (code === -32602 || code === -32601) return true; + const message = error instanceof Error ? error.message : String(error ?? ""); + return /unknown\s+(?:field|parameter|method)|method\s+.*not\s+found|invalid\s+(?:param(?:eter)?s?|field)|unexpected\s+(?:field|property)|unsupported\s+(?:pagination|initialTurnsPage|excludeTurns|itemsView)/i.test(message); +} + +function sortHistoryTurns(turns: JsonObject[]): JsonObject[] { + return turns + .map((turn, index) => ({ turn, index })) + .sort((left, right) => { + const leftTime = [left.turn.startedAt, left.turn.createdAt, left.turn.completedAt] + .map(finiteNumber) + .find((value): value is number => value !== undefined); + const rightTime = [right.turn.startedAt, right.turn.createdAt, right.turn.completedAt] + .map(finiteNumber) + .find((value): value is number => value !== undefined); + if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return leftTime - rightTime; + if (leftTime !== undefined && rightTime === undefined) return -1; + if (leftTime === undefined && rightTime !== undefined) return 1; + const leftId = typeof left.turn.id === "string" ? left.turn.id : ""; + const rightId = typeof right.turn.id === "string" ? right.turn.id : ""; + return leftId.localeCompare(rightId) || left.index - right.index; + }) + .map(({ turn }) => turn); +} + +function extractThread(result: JsonValue): JsonObject | undefined { + if (!isRecord(result)) return undefined; + if (isRecord(result.thread)) return result.thread; + return typeof result.id === "string" ? result : undefined; +} + +function threadMetadata(thread: JsonObject): JsonObject { + const result = redactJson(thread); + if (!isRecord(result)) return {}; + return { ...result, turns: [] }; +} + +const TOKEN_USAGE_FIELDS = [ + "totalTokens", + "inputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "outputTokens", + "reasoningOutputTokens", +] as const; + +/** Keep only the numeric portion of the official token-usage projection. */ +function projectTokenUsage(value: unknown): JsonObject | undefined { + if (!isRecord(value)) return undefined; + const source = isRecord(value.info) + ? value.info + : isRecord(value.tokenUsage) + ? value.tokenUsage + : isRecord(value.token_usage) + ? value.token_usage + : value; + const total = projectTokenUsageBreakdown( + source.total + ?? source.total_token_usage + ?? source.totalTokenUsage, + ); + const last = projectTokenUsageBreakdown( + source.last + ?? source.last_token_usage + ?? source.lastTokenUsage, + ); + const modelContextWindow = tokenNumber( + source.modelContextWindow + ?? source.model_context_window + ?? source.contextWindow + ?? source.context_window, + ); + if (!total && !last && modelContextWindow === undefined) return undefined; + return { + ...(total ? { total } : {}), + ...(last ? { last } : {}), + ...(modelContextWindow !== undefined ? { modelContextWindow } : {}), + }; +} + +function projectTokenUsageBreakdown(value: unknown): JsonObject | undefined { + if (!isRecord(value)) return undefined; + const aliases: Record<(typeof TOKEN_USAGE_FIELDS)[number], string[]> = { + totalTokens: ["totalTokens", "total_tokens"], + inputTokens: ["inputTokens", "input_tokens"], + cachedInputTokens: ["cachedInputTokens", "cached_input_tokens"], + cacheWriteInputTokens: ["cacheWriteInputTokens", "cache_write_input_tokens"], + outputTokens: ["outputTokens", "output_tokens"], + reasoningOutputTokens: ["reasoningOutputTokens", "reasoning_output_tokens"], + }; + const result: JsonObject = {}; + for (const field of TOKEN_USAGE_FIELDS) { + for (const alias of aliases[field]) { + const number = tokenNumber(value[alias]); + if (number === undefined) continue; + result[field] = number; + break; + } + } + return Object.keys(result).length ? result : undefined; +} + +function tokenNumber(value: unknown): number | undefined { + if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined; + if (typeof value !== "string" || !/^\d+(?:\.\d+)?$/.test(value.trim())) return undefined; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : undefined; +} + +function latestActiveTurn(thread: JsonObject): JsonObject | undefined { + if (!Array.isArray(thread.turns)) return undefined; + for (let index = thread.turns.length - 1; index >= 0; index -= 1) { + const turn = thread.turns[index]; + if (isRecord(turn) && turn.status === "inProgress") return turn; + } + return undefined; +} + +function projectThreadMessages(thread: JsonObject): JsonValue[] { + if (!Array.isArray(thread.turns)) return []; + const messages: JsonValue[] = []; + for (const turn of thread.turns) { + if (!isRecord(turn) || !Array.isArray(turn.items)) continue; + const turnId = typeof turn.id === "string" ? turn.id : undefined; + for (const item of turn.items) { + if (isRecord(item)) messages.push(projectThreadItem(item, turnId, turn)); + } + } + return messages; +} + +function projectThreadItem( + item: JsonObject, + turnId?: string, + turn?: JsonObject, + lifecycle: JsonObject = {}, +): JsonObject { + const safeItem = redactJson(item); + const projected: JsonObject = isRecord(safeItem) ? { ...safeItem } : {}; + const itemType = typeof item.type === "string" ? item.type : "unknown"; + const itemId = typeof item.id === "string" ? item.id : undefined; + const turnStatus = typeof turn?.status === "string" ? turn.status : undefined; + const startedAtMs = finiteNumber(lifecycle.startedAtMs) ?? secondsToMs(turn?.startedAt); + const completedAtMs = finiteNumber(lifecycle.completedAtMs) ?? secondsToMs(turn?.completedAt); + const durationMs = finiteNumber(item.durationMs) ?? finiteNumber(turn?.durationMs); + const status = typeof lifecycle.status === "string" + ? lifecycle.status + : typeof item.status === "string" ? item.status : undefined; + + Object.assign(projected, { + ...(itemId ? { id: itemId, itemId } : {}), + ...(turnId ? { turnId } : {}), + itemType, + ...(status ? { status } : {}), + ...(turnStatus ? { turnStatus } : {}), + ...(startedAtMs !== undefined ? { startedAtMs } : {}), + ...(completedAtMs !== undefined ? { completedAtMs } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }); + + switch (itemType) { + case "userMessage": + projected.role = "user"; + projected.kind = "user"; + projected.text = userInputText(item.content); + break; + case "agentMessage": + projected.role = "assistant"; + projected.kind = "assistant"; + projected.text = typeof item.text === "string" ? redactText(item.text) : ""; + break; + case "reasoning": + projected.role = "reasoning"; + projected.kind = "reasoning"; + projected.text = stringArrayText(item.summary) || stringArrayText(item.content); + break; + case "plan": + projected.role = "reasoning"; + projected.kind = "plan"; + projected.text = typeof item.text === "string" ? redactText(item.text) : ""; + break; + case "commandExecution": + projected.role = "tool"; + projected.kind = "tool"; + projected.command = typeof item.command === "string" ? redactText(item.command) : ""; + projected.text = typeof item.command === "string" ? redactText(item.command) : ""; + projected.output = typeof item.aggregatedOutput === "string" ? redactText(item.aggregatedOutput) : ""; + projected.label = "Command"; + projected.uiType = "commandExecution"; + break; + case "fileChange": { + projected.role = "tool"; + projected.kind = "edit"; + const paths = Array.isArray(item.changes) + ? item.changes.filter(isRecord).map((change) => { + const value = asJsonObject(change); + return typeof value.path === "string" ? redactText(value.path) : ""; + }).filter(Boolean) + : []; + projected.text = paths.join("\n"); + projected.label = "File changes"; + projected.uiType = "fileChange"; + break; + } + case "collabAgentToolCall": + projected.role = "tool"; + projected.kind = "tool"; + projected.text = typeof item.prompt === "string" ? redactText(item.prompt) : typeof item.tool === "string" ? item.tool : ""; + projected.action = typeof item.tool === "string" ? item.tool : ""; + projected.uiType = "collabAgentToolCall"; + break; + case "subAgentActivity": + projected.role = "tool"; + projected.kind = "tool"; + projected.text = typeof item.agentPath === "string" ? redactText(item.agentPath) : ""; + projected.activityKind = typeof item.kind === "string" ? item.kind : ""; + projected.uiType = "subAgentActivity"; + break; + case "webSearch": + projected.role = "tool"; + projected.kind = "tool"; + projected.text = typeof item.query === "string" ? redactText(item.query) : ""; + projected.label = "Web search"; + projected.uiType = "webSearch"; + break; + case "imageView": + projected.role = "tool"; + projected.kind = "tool"; + projected.text = typeof item.path === "string" ? redactText(item.path) : ""; + projected.label = "Image"; + break; + case "contextCompaction": + projected.role = "tool"; + projected.kind = "tool"; + projected.text = "Context compacted"; + projected.uiType = "contextCompaction"; + break; + default: + projected.role = "tool"; + projected.kind = "tool"; + projected.text = genericItemText(item); + break; + } + return projected; +} + +function userInputText(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value.map((input) => { + if (!isRecord(input)) return ""; + if (input.type === "text" && typeof input.text === "string") return redactText(input.text); + if (input.type === "skill" && typeof input.name === "string") return `$${redactText(input.name)}`; + if (input.type === "mention" && typeof input.name === "string") return `@${redactText(input.name)}`; + if ((input.type === "image" || input.type === "audio") && typeof input.url === "string") return redactText(input.url); + if ((input.type === "localImage" || input.type === "localAudio") && typeof input.path === "string") return redactText(input.path); + return ""; + }).filter(Boolean).join("\n"); +} + +function stringArrayText(value: unknown): string { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string").map(redactText).join("\n") + : ""; +} + +function genericItemText(item: JsonObject): string { + for (const key of ["text", "query", "command", "name", "tool"] as const) { + if (typeof item[key] === "string") return redactText(item[key] as string); + } + if (item.output !== undefined) return redactText(stableStringify(redactJson(item.output))); + if (item.result !== undefined) return redactText(stableStringify(redactJson(item.result))); + return ""; +} + +function outputTailFromMessages(messages: JsonValue[], maxChars: number): string { + const chunks: string[] = []; + for (const message of messages) { + if (!isRecord(message)) continue; + if (typeof message.text === "string" && message.text) chunks.push(message.text); + if (typeof message.output === "string" && message.output) chunks.push(message.output); + } + const output = redactText(chunks.join("\n\n")); + return output.length > maxChars ? output.slice(-maxChars) : output; +} + +function sessionTitle(value: string, threadId: string): string { + const title = redactText(value).replace(/\s+/g, " ").trim(); + if (title) return title.slice(0, 160); + return `Session ${threadId.slice(0, 8)}`; +} + +function secondsToMs(value: unknown): number | undefined { + const seconds = finiteNumber(value); + return seconds === undefined ? undefined : Math.round(seconds * 1_000); +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Keep browser/embedding responses aligned with app-server response schemas. */ +function normalizeServerResponse(method: string, response: JsonValue): JsonValue { + if (method === "item/permissions/requestApproval") { + const source = isRecord(response) ? response : {}; + const requested = isRecord(source.permissions) ? source.permissions : {}; + const permissions: JsonObject = {}; + for (const [key, value] of Object.entries(requested)) { + // Request profiles use null to mean "not requested"; granted profiles + // omit those fields instead of sending an invalid explicit null. + if (value !== null && value !== undefined) permissions[key] = asJsonValue(value); + } + const normalized: JsonObject = { + permissions, + scope: source.scope === "session" ? "session" : "turn", + }; + if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview; + return normalized; + } + // Tool user-input responses are wrapped in an `answers` object. MCP + // elicitation has a different schema (`action`, `content`, `_meta`) and + // must be forwarded unchanged; wrapping it would make app-server reject + // an otherwise valid approval response. + if (method === "mcpServer/elicitation/request") return response; + if (method === "item/tool/requestUserInput") { + if (isRecord(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response; + return { answers: isRecord(response) ? response : {} }; + } + return response; +} + +/** Validate a response immediately before it crosses the app-server boundary. */ +function validateAdapterResponse( + method: string, + response: JsonValue, + decision: "allow" | "deny" | "cancel", +): void { + if (!isRecord(response)) throw new Error("app-server response must be a JSON object"); + + if (method === "item/permissions/requestApproval") { + if (!isRecord(response.permissions) + || (response.scope !== "turn" && response.scope !== "session") + || (response.strictAutoReview !== undefined && typeof response.strictAutoReview !== "boolean")) { + throw new Error("invalid permissions approval response"); + } + return; + } + + if (method === "item/tool/requestUserInput") { + const answers = response.answers; + if (!isRecord(answers)) throw new Error("invalid tool input response"); + return; + } + + if (method === "mcpServer/elicitation/request") { + if (!Object.prototype.hasOwnProperty.call(response, "action")) { + throw new Error("MCP elicitation response requires action"); + } + const action = approvalDecisionKindForMethod(response.action, method); + if (!action || action !== decision) throw new Error("MCP elicitation action conflicts with decision"); + return; + } + + // Approval callbacks all use a `decision` field. Unknown or mixed tagged + // objects are rejected by the method-aware classifier before write. + if (!hasApprovalDecisionField(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) { + throw new Error("approval response requires decision"); + } + const responseDecision = approvalDecisionKindForMethod(response.decision, method); + if (!responseDecision) throw new Error("unsupported approval response decision"); + if (responseDecision !== decision) throw new Error(`approval response implies ${responseDecision}, but decision is ${decision}`); +} + +function statusToState(status: unknown): string { + if (typeof status === "string") return status; + if (isRecord(status) && typeof status.type === "string") return status.type; + return "unknown"; +} + +function approvalAction(method: string): string { + switch (method) { + case "item/commandExecution/requestApproval": + case "execCommandApproval": + return "command.execution"; + case "item/fileChange/requestApproval": + case "applyPatchApproval": + return "file.change"; + case "item/permissions/requestApproval": + return "permissions.grant"; + default: + return "approval"; + } +} + +function approvalRisk(method: string, command?: string, commandActions?: JsonValue): PendingApproval["risk"] { + // A permission profile can expand filesystem or network access for the + // current turn/session, so treat it like an explicit high-impact command. + if (method.includes("permissions")) return "high"; + if (method.includes("command") || method === "execCommandApproval") { + const suspicious = /(?:rm\s+-rf|sudo|curl|wget|ssh|password|token|secret)/i; + if (Array.isArray(commandActions)) { + // `commandActions` is parsed display data, not a proof of safety. Keep + // every request carrying it high-risk, scan all extracted commands, and + // treat unknown/malformed actions as high-risk as well. This prevents a + // dangerous subcommand from being hidden behind command:null. + const actions = commandActions; + const actionCommands = actions + .map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined) + .filter((item): item is string => Boolean(item)); + const malformed = actions.some((action) => { + if (!isRecord(action) || typeof action.command !== "string") return true; + return action.type !== "read" + && action.type !== "listFiles" + && action.type !== "search" + && action.type !== "unknown"; + }); + const combined = [command, ...actionCommands].filter((item): item is string => Boolean(item)).join(" && "); + if (malformed || !actionCommands.length || suspicious.test(combined) || actions.some((action) => isRecord(action) && action.type === "unknown")) { + return "high"; + } + return "high"; + } + if (command && suspicious.test(command)) return "high"; + // An unparseable command approval is fail-closed. A missing command can + // otherwise be misclassified as medium and approved by the default host + // capability policy. + if (!command) return "high"; + return "medium"; + } + if (method.includes("fileChange") || method === "applyPatchApproval") return "medium"; + return "unknown"; +} + +function outputStream(params: JsonObject): string { + const stream = params.stream; + if (stream === "stderr" || stream === "stdout" || stream === "codex") return stream; + return "stdout"; +} + +function decodeOutput(params: JsonObject): string { + if (typeof params.delta === "string") return params.delta; + if (typeof params.deltaBase64 === "string") { + try { + return Buffer.from(params.deltaBase64, "base64").toString("utf8"); + } catch { + return "[invalid base64 output]"; + } + } + return ""; +} + +const SECRET_KEY = /(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i; +const SECRET_VALUE = /(?:Bearer\s+)[A-Za-z0-9._~+\-/]+=*|(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})/g; + +function redactText(text: string): string { + return text + .replace(SECRET_VALUE, "[REDACTED]") + .replace(/([?&](?:token|key|secret|password|api[_-]?key)=)[^&\s]+/gi, "$1[REDACTED]") + .replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]"); +} + +function redactJson(value: unknown): JsonValue { + if (Array.isArray(value)) return value.map((item) => redactJson(item)); + if (isRecord(value)) { + const result: JsonObject = {}; + for (const [key, child] of Object.entries(value)) { + result[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redactJson(child); + } + return result; + } + if (typeof value === "string") return redactText(value); + return asJsonValue(value); +} + +function hashJson(value: JsonValue): string { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value: JsonValue): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key] ?? null)}`).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/aether-vscodex/vscode-extension/src/codexIpc.ts b/aether-vscodex/vscode-extension/src/codexIpc.ts new file mode 100644 index 000000000..32842d045 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/codexIpc.ts @@ -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; +} + +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 = (value: T) => void; +export interface IpcSubscription { dispose(): void; } + +function subscribe(set: Set>, listener: Listener): IpcSubscription { + set.add(listener); + return { dispose: () => set.delete(listener) }; +} + +function isRecord(value: unknown): value is Record { + 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(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> & CodexIpcClientOptions; + private socket: net.Socket | undefined; + private decoder: IpcFrameDecoder; + private connectPromise: Promise | undefined; + private reconnectTimer: NodeJS.Timeout | undefined; + private disposed = false; + private clientId = INITIALIZING_CLIENT_ID; + private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); + private readonly followed = new Map(); + private readonly streams = new Map(); + private readonly messageListeners = new Set>(); + private readonly broadcastListeners = new Set>(); + private readonly streamListeners = new Set>(); + private readonly errorListeners = new Set>(); + private readonly closeListeners = new Set>(); + private readonly discoveryHandler?: (request: IpcRequest) => boolean | Promise; + + 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 { return this.followed; } + + onMessage(listener: Listener): IpcSubscription { return subscribe(this.messageListeners, listener); } + onBroadcast(listener: Listener): IpcSubscription { return subscribe(this.broadcastListeners, listener); } + onStreamEvent(listener: Listener): IpcSubscription { return subscribe(this.streamListeners, listener); } + onError(listener: Listener): IpcSubscription { return subscribe(this.errorListeners, listener); } + onClose(listener: Listener): IpcSubscription { return subscribe(this.closeListeners, listener); } + + async connect(): Promise { + 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((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 { + 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 { + 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 { + 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((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 { + 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 { + 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 { + 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 { + 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 { + return this.updateThreadSettings(conversationId, threadSettings, options); + } + + async interruptTurn(conversationId: string, options: FollowerInterruptOptions = {}): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const response = await this.requestFollower(method, conversationId, params, options); + return response.result; + } + + async dispose(): Promise { + 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 { + 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 { + 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(listener: Listener, 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)); +} diff --git a/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts b/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts new file mode 100644 index 000000000..e7fbd3f18 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts @@ -0,0 +1,4253 @@ +import { createHash, randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + CODEX_IPC_METHOD_VERSIONS, + CodexIpcClient, + CodexIpcClientOptions, + ConversationStreamState, + ConversationStreamEvent, + IpcBroadcast, + IpcSubscription, +} from "./codexIpc"; +import { + AgentAdapter, + AgentEvent, + AgentStatusSnapshot, + asJsonObject, + asJsonValue, + Disposable, + isJsonRpcId, + isRecord, + JsonObject, + JsonRpcId, + JsonValue, + Logger, + PendingApproval, + SessionSnapshot, + SessionListEntry, + SessionListResult, + SubagentSnapshot, + jsonRpcIdKey, +} from "./protocol"; + +const APPROVAL_METHODS = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "applyPatchApproval", + "execCommandApproval", +]); +const INPUT_METHODS = new Set([ + "item/tool/requestUserInput", + "mcpServer/elicitation/request", +]); +const TERMINAL_TURN_STATES = new Set([ + "completed", + "complete", + "failed", + "cancelled", + "canceled", + "interrupted", + "error", + "done", +]); +const HISTORY_LOAD_RETRY_DELAY_MS = 50; +const VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS = 250; +const VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS = 3; +/** Keep the relay alive while a user opens/selects the first official panel. */ +const WAITING_SESSION_DISCOVERY_DELAY_MS = 1_000; +/** A waiting poll is a fallback; route broadcasts handle the common fast path. */ +const WAITING_SESSION_DISCOVERY_MAX_CANDIDATES = 8; + +export interface CodexIpcAgentAdapterOptions extends Omit { + /** Existing conversation id. Empty/undefined enables local discovery. */ + threadId?: string; + hostId?: string; + autoDiscoverThread?: boolean; + /** Workspace paths used to rank auto-discovered sessions. */ + preferredCwds?: string[]; + /** Ask the owner for older paginated history after the initial snapshot. */ + loadCompleteHistory?: boolean; + /** Follow route changes in the official VS Code Codex panel. */ + followVscodeSession?: boolean; + /** Coalesce the official panel's old=false/new=true route broadcasts. */ + vscodeSessionFollowDebounceMs?: number; + ownerDiscoveryTimeoutMs?: number; + followTimeoutMs?: number; + maxOutputTailChars?: number; + approvalTimeoutMs?: number; + logger?: Logger; + /** Invoke the existing official VS Code command for a fresh Codex panel. */ + openNewSession?: () => Promise; + /** Inject a client in tests. The adapter owns it unless disabled below. */ + client?: CodexIpcClient; + disposeClient?: boolean; +} + +interface RequestEntry { + requestId: JsonRpcId; + method: string; + params: JsonObject; + threadId?: string; + turnId?: string; + createdAt: number; + expiresAt?: number; + approval?: PendingApproval; +} + +interface TurnInfo { + id?: string; + status: string; + active: boolean; + startedAt?: number; + durationMs?: number | null; + workedDurationMs?: number | null; + firstTurnWorkItemStartedAtMs?: number | null; + finalAssistantStartedAtMs?: number | null; + completedAtMs?: number | null; + error?: JsonValue; + /** The raw turn record, used to classify the currently running work item. */ + raw?: Record; +} + +/** + * AgentAdapter backed by the private IPC follower protocol used by the + * official OpenAI Codex VS Code extension. It never starts or kills a codex + * process: all work is routed to the owner of an already-open conversation. + */ +export class CodexIpcAgentAdapter implements AgentAdapter { + private readonly options: Required> & CodexIpcAgentAdapterOptions; + private readonly client: CodexIpcClient; + private readonly listeners = new Set<(event: AgentEvent) => void>(); + private readonly subscriptions: IpcSubscription[] = []; + private readonly pending = new Map(); + private readonly pendingTimers = new Map(); + private readonly pendingExpiryAt = new Map(); + private readonly optimisticallyResolved = new Set(); + private threadId: string | null = null; + private ownerClientId: string | null = null; + private revision: number | null = null; + private conversationState: JsonObject = {}; + private turnId: string | null = null; + private state = "disconnected"; + private status: AgentStatusSnapshot = { + activity: "idle", + turnStatus: "idle", + activeFlags: [], + startedAtMs: null, + durationMs: null, + workedDurationMs: null, + elapsedMs: null, + firstTurnWorkItemStartedAtMs: null, + finalAssistantStartedAtMs: null, + }; + private started = false; + private renderedOutput = ""; + private renderedOutputLength = 0; + private renderedOutputWasTruncated = false; + private renderedMessageShape = ""; + private outputTail = ""; + private outputMessages: RenderedConversationMessage[] = []; + private subagents: SubagentSnapshot[] = []; + private renderedSubagentShape = ""; + /** Fingerprint of the display-safe metadata projection last sent to peers. */ + private renderedMetadataShape = ""; + private snapshotSeen = false; + private historyComplete?: boolean; + private historyLoadRequested = false; + private historyLoadAttempts = 0; + private historyLoadGeneration = 0; + private historyLoadRetryTimer?: NodeJS.Timeout; + private disposed = false; + /** Serialize session navigation so two browser clicks cannot overlap. */ + private sessionSwitching = false; + /** Invalidates in-flight navigation when dispose/socket close begins. */ + private sessionLifecycleGeneration = 0; + /** IPC client that owns the official Codex panel route being mirrored. */ + private vscodeRouteClientId: string | null = null; + /** Untrusted old=false halves; only a matching same-source true can bind. */ + private readonly vscodeRouteCandidates = new Map(); + /** Last route that source reported as active, independent of our attachment. */ + private vscodeRouteActiveThreadId: string | null = null; + /** Official routing emits old=false before new=true, even when the picker stays open. */ + private vscodeRouteAwaitingSelection = false; + /** Latest route selected in that official panel, coalesced across rapid clicks. */ + private pendingVscodeThreadId: string | null = null; + private pendingVscodeFollowAttempts = 0; + private pendingVscodeFollowGeneration = 0; + private vscodeRouteGeneration = 0; + private activeVscodeSelection?: { target: string; generation: number }; + private vscodeSessionFollowTimer?: NodeJS.Timeout; + /** Serialize attachability probes with navigation so probe cleanup cannot unfollow a newly selected thread. */ + private sessionOperationTail: Promise = Promise.resolve(); + /** True while the IPC/relay host is usable but no conversation is attached. */ + private waitingForSession = false; + private waitingDiscoveryTimer?: NodeJS.Timeout; + private waitingDiscoveryInFlight?: Promise; + private waitingAttachPromise?: Promise; + private waitingAttachTarget: string | null = null; + private queuedWaitingAttachTarget: string | null = null; + private snapshotWaiter?: { threadId: string; ownerClientId?: string; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout }; + private revisionWaiter?: { threadId: string; ownerClientId: string; revision: number; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout }; + + constructor(options: CodexIpcAgentAdapterOptions = {}) { + this.options = { + ...options, + hostId: options.hostId ?? "local", + autoDiscoverThread: options.autoDiscoverThread ?? true, + followVscodeSession: options.followVscodeSession ?? true, + vscodeSessionFollowDebounceMs: Math.max(0, options.vscodeSessionFollowDebounceMs ?? 150), + // Owner discovery for an active local VS Code client normally returns + // in a few milliseconds. A short default keeps stale rollout files from + // making bridge startup look hung; callers can raise this explicitly. + ownerDiscoveryTimeoutMs: options.ownerDiscoveryTimeoutMs ?? 2_500, + followTimeoutMs: options.followTimeoutMs ?? 8_000, + maxOutputTailChars: options.maxOutputTailChars ?? 32_000, + approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000, + disposeClient: options.disposeClient ?? true, + }; + this.client = options.client ?? new CodexIpcClient({ + ...options, + clientType: "codex-remote-collab-follower", + canHandleRequest: () => false, + autoReconnect: false, + }); + this.subscriptions.push(this.client.onBroadcast((frame) => this.handleBroadcast(frame))); + this.subscriptions.push(this.client.onStreamEvent((event) => this.handleStreamEvent(event))); + this.subscriptions.push(this.client.onError((error) => this.options.logger?.debug?.("Codex IPC error", error.message))); + this.subscriptions.push(this.client.onClose((error) => this.handleClose(error))); + } + + async start(): Promise { + if (this.started) return; + if (this.disposed) throw new Error("Codex IPC follower is disposed"); + this.clearWaitingDiscoveryTimer(); + this.waitingForSession = false; + this.resetHistoryLoading(); + this.renderedOutput = ""; + this.renderedOutputLength = 0; + this.renderedOutputWasTruncated = false; + this.renderedMessageShape = ""; + this.outputTail = ""; + this.outputMessages = []; + this.subagents = []; + this.renderedSubagentShape = ""; + this.renderedMetadataShape = ""; + const configured = this.options.threadId?.trim(); + await this.client.connect(); + if (configured) { + try { + await this.attachThread(configured); + return; + } catch (error) { + // A remembered thread can belong to another Codex window (or to a + // previous run). When auto-discovery is enabled, keep startup useful + // by falling back to the most recent live VS Code owner. + if (!isMissingSessionOwnerError(error)) throw error; + if (!this.options.autoDiscoverThread) { + this.enterWaitingForSession(); + return; + } + this.options.logger?.warn?.(`Configured Codex conversation ${configured} could not be followed; trying auto-discovery`, error); + const fallback = await this.discoverThreadId(new Set([configured])); + if (fallback) { + try { + await this.attachThread(fallback); + return; + } catch (fallbackError) { + if (!isMissingSessionOwnerError(fallbackError)) throw fallbackError; + } + } + this.enterWaitingForSession(); + return; + } + } + + const selectedThread = await this.discoverThreadId(); + if (!selectedThread) { + // A fresh VS Code window can have a live IPC socket before the user has + // opened a Codex conversation. Keep the follower (and therefore the + // relay/WebSocket) alive so the first later panel navigation can attach + // without restarting the bridge. + this.enterWaitingForSession(); + return; + } + await this.attachThread(selectedThread); + } + + private async attachThread(selectedThread: string, options: { fromWaiting?: boolean } = {}): Promise { + const fromWaiting = options.fromWaiting === true; + const lifecycleGeneration = this.sessionLifecycleGeneration; + const routeGeneration = this.vscodeRouteGeneration; + const wasStarted = this.started; + const wasWaiting = this.waitingForSession; + const previousRouteClientId = this.vscodeRouteClientId; + const previousRouteActiveThreadId = this.vscodeRouteActiveThreadId; + const previousRouteAwaitingSelection = this.vscodeRouteAwaitingSelection; + const owner = await this.client.findThreadOwner(selectedThread, this.options.hostId, this.options.ownerDiscoveryTimeoutMs); + if (!owner) { + throw new Error(`找不到会话 ${selectedThread} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`); + } + this.threadId = selectedThread; + this.ownerClientId = owner; + // An owner can be Codex Desktop while the visible VS Code webview follows + // it, so ownerClientId is not always the route source. Bind the route + // source on the first observed old=false/new=true navigation pair instead. + if (!this.vscodeRouteActiveThreadId) this.vscodeRouteActiveThreadId = selectedThread; + this.state = "syncing"; + if (!wasStarted) this.started = true; + // A normal startup announces the connection before waiting for the first + // stream snapshot. A waiting host already announced its IPC connection; + // emitting a second `connection.opened` would make the browser reset its + // connection indicator unnecessarily. + if (!wasStarted) this.emit({ type: "connection.opened", threadId: selectedThread, payload: { mode: "attach", ownerClientId: owner } }); + // Do not expose a provisional thread as interactive while its first + // authoritative snapshot is still in flight. + this.waitingForSession = false; + try { + const waitForSnapshot = this.waitForSnapshot(selectedThread, this.options.followTimeoutMs, owner); + void waitForSnapshot.catch(() => undefined); + await this.client.followConversation(selectedThread, true, { + hostId: this.options.hostId, + targetClientIds: [owner], + }); + await waitForSnapshot; + this.state = this.deriveSessionState(); + this.waitingForSession = false; + this.clearWaitingDiscoveryTimer(); + if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded(); + this.options.logger?.info?.(`Attached to existing Codex conversation ${selectedThread}`); + } catch (error) { + // A failed follow must leave the adapter retryable and must not keep a + // stale thread/owner that could receive a later remote command. + this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error))); + try { await this.client.followConversation(selectedThread, false, { hostId: this.options.hostId, targetClientIds: [owner] }); } catch { /* best effort */ } + const restoreWaiting = (fromWaiting || wasWaiting) + && !this.disposed + && lifecycleGeneration === this.sessionLifecycleGeneration; + // A transient attach attempt made from the waiting state must not tear + // down the IPC socket or relay. Restore the waiting projection and let + // the discovery loop try again after the official panel is ready. + this.started = restoreWaiting ? wasStarted : false; + this.state = restoreWaiting ? "waiting_for_host" : "disconnected"; + this.waitingForSession = restoreWaiting; + this.threadId = null; + this.ownerClientId = null; + if (!restoreWaiting) { + this.vscodeRouteClientId = null; + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteAwaitingSelection = false; + } else if (this.vscodeRouteGeneration === routeGeneration) { + this.vscodeRouteClientId = previousRouteClientId; + this.vscodeRouteActiveThreadId = previousRouteActiveThreadId; + this.vscodeRouteAwaitingSelection = previousRouteAwaitingSelection; + } + this.vscodeRouteCandidates.clear(); + this.resetConversationProjection(); + throw error; + } + } + + /** Attach mode deliberately has no thread creation operation. */ + async startThread(): Promise { + throw new Error("attach mode does not create a new thread; open an existing Codex conversation in VS Code"); + } + + /** + * Open a new conversation through the already-installed VS Code Codex + * extension. The callback is injected by the extension entrypoint; this + * follower never starts another Codex process. + */ + async newSession(): Promise { + // Opening the official new-session panel is also the recovery action for + // `waiting_for_host`; it does not require an existing conversation owner. + this.ensureStarted(); + if (!this.options.openNewSession) { + throw new Error("当前 VS Code Codex 扩展不支持从远程打开新会话"); + } + return asJsonValue(await this.options.openNewSession()); + } + + async startTurn(params: JsonObject): Promise { + this.ensureInteractiveReady(); + const input = extractInput(params); + const request = pickTurnRequest(params); + const result = await this.client.startTurn(this.threadId as string, input, { + request, + context: pickTurnContext(params), + clientUserMessageId: stringValue(params.clientUserMessageId), + ownerClientId: this.ownerClientId as string, + timeoutMs: this.options.followTimeoutMs, + }); + const unwrapped = unwrapFollowerResult(result); + const nextTurn = extractTurnId(unwrapped); + if (nextTurn) { + this.turnId = nextTurn; + this.state = "active"; + } + return asJsonValue(unwrapped); + } + + async steerTurn(params: JsonObject): Promise { + this.ensureInteractiveReady(); + const expected = stringValue(params.expectedTurnId) ?? this.turnId; + if (!expected) throw new Error("turn/steer requires an active turn"); + const result = await this.client.steerTurn(this.threadId as string, extractInput(params), { + clientUserMessageId: stringValue(params.clientUserMessageId) ?? randomUUID(), + serviceTier: params.serviceTier === null || typeof params.serviceTier === "string" ? params.serviceTier : undefined, + attachments: Array.isArray(params.attachments) ? params.attachments : [], + additionalContext: isRecord(params.additionalContext) ? asJsonObject(params.additionalContext) : undefined, + restoreMessage: params.restoreMessage === null || params.restoreMessage !== undefined ? asJsonValue(params.restoreMessage) : undefined, + ownerClientId: this.ownerClientId as string, + timeoutMs: this.options.followTimeoutMs, + }); + this.turnId = expected; + this.state = "active"; + return asJsonValue(unwrapFollowerResult(result)); + } + + /** Persist model/reasoning settings on the already-open official thread. */ + async updateThreadSettings(params: JsonObject): Promise { + this.ensureInteractiveReady(); + const threadSettings = pickThreadSettingsUpdate(params); + const result = await this.client.updateThreadSettings( + this.threadId as string, + threadSettings, + { + ownerClientId: this.ownerClientId as string, + timeoutMs: this.options.followTimeoutMs, + }, + ); + return asJsonValue(unwrapFollowerResult(result)); + } + + /** + * Return the local VS Code conversations that this follower can attach to. + * + * The official extension obtains this list from its app-server client via + * `thread/list`. That request is intentionally not exposed by the private + * IPC router, so the bridge uses local rollout/index metadata only to find + * candidates. A candidate is returned after live owner discovery and, for a + * non-active conversation, a matching follower snapshot. Closed, stale, or + * desktop-owned rollouts are omitted instead of being shown as selectable + * history that attach mode cannot actually open. + */ + async listSessions(params: JsonObject = {}): Promise { + // Session discovery is useful precisely while no conversation is attached + // (for example immediately after a fresh VS Code window opens). + this.ensureStarted(); + const releaseSessionOperation = await this.acquireSessionOperation(); + try { + return await this.listAttachableSessions(params); + } finally { + releaseSessionOperation(); + } + } + + private async listAttachableSessions(params: JsonObject): Promise { + const limitValue = numberValue(params.limit); + const limit = Math.max(1, Math.min(100, Number.isInteger(limitValue) ? limitValue as number : 50)); + const codexHome = resolveCodexHome(this.options); + const [candidates, index] = await Promise.all([ + recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []), + readSessionIndex(path.join(codexHome, "session_index.jsonl")), + ]); + const byId = new Map(); + for (const candidate of candidates) { + const indexed = index.get(candidate.id); + byId.set(candidate.id, { + ...candidate, + ...(indexed?.title && !candidate.title ? { title: indexed.title } : {}), + ...(indexed?.updatedAtMs !== undefined && indexed.updatedAtMs > (candidate.updatedAtMs ?? 0) + ? { updatedAtMs: indexed.updatedAtMs } : {}), + ...(indexed?.cwd && !candidate.cwd ? { cwd: indexed.cwd } : {}), + }); + } + // A configured/current thread can be valid even while its rollout has + // rotated away. Keep it in the picker so the active row is never lost. + if (this.threadId && !byId.has(this.threadId)) { + const title = stringValue(this.conversationState.title) + ?? stringValue(this.conversationState.name) + ?? stringValue(this.conversationState.threadTitle); + const cwd = stringValue(this.conversationState.cwd); + byId.set(this.threadId, { + id: this.threadId, + mtime: Date.now(), + updatedAtMs: Date.now(), + priority: 0, + ...(title ? { title } : {}), + ...(cwd ? { cwd } : {}), + }); + } + + const compareCandidates = (a: Candidate, b: Candidate) => (b.updatedAtMs ?? b.mtime) - (a.updatedAtMs ?? a.mtime) + || a.priority - b.priority; + const ordered = [...byId.values()] + .sort(compareCandidates) + .slice(0, limit); + // `limit` bounds expensive owner/snapshot probes, but a newer stale + // rollout must never consume the only slot and hide the active attachment. + const activeCandidate = this.threadId ? byId.get(this.threadId) : undefined; + if (activeCandidate && !ordered.some((candidate) => candidate.id === activeCandidate.id)) { + if (ordered.length >= limit) ordered[ordered.length - 1] = activeCandidate; + else ordered.push(activeCandidate); + ordered.sort(compareCandidates); + } + const sessions: SessionListEntry[] = []; + // Owner discovery is deliberately bounded/concurrent: stale rollout files + // are common, and one slow stale check must not block all other rows. + const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750)); + // A discovery response only proves that *some* client knows the thread. + // Require a short, targeted snapshot probe before advertising a non-active + // row as selectable; desktop-owned/stale threads can otherwise look live + // and leave the browser blank after a failed switch. + const snapshotProbeTimeout = Math.max(250, Math.min(this.options.followTimeoutMs, 750)); + for (let offset = 0; offset < ordered.length; offset += 8) { + const batch = ordered.slice(offset, offset + 8); + const checked = await Promise.all(batch.map(async (candidate) => { + let owner: string | null = candidate.id === this.threadId ? this.ownerClientId : null; + if (!owner) { + try { + owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout); + } catch (error) { + this.options.logger?.debug?.(`Session owner discovery failed for ${candidate.id}`, error); + } + } + let available = false; + if (owner) { + available = candidate.id === this.threadId + || await this.probeSessionSnapshot(candidate.id, owner, snapshotProbeTimeout); + } + if (!available) return null; + const title = sanitizeSessionTitle(candidate.title) ?? `会话 ${candidate.id.slice(0, 8)}`; + return { + threadId: candidate.id, + title, + updatedAtMs: candidate.updatedAtMs ?? candidate.mtime, + ...(candidate.cwd ? { cwd: redactText(candidate.cwd) } : {}), + active: candidate.id === this.threadId, + available: true, + } satisfies SessionListEntry; + })); + for (const entry of checked) { + if (entry) sessions.push(entry); + } + // Route navigation has priority over populating more picker rows. The + // probes in this batch have already cleaned up their temporary follows; + // stop here so selectSession can acquire the shared operation lock + // instead of waiting behind dozens of stale rollout candidates. + if (this.sessionSwitching || this.pendingVscodeThreadId || this.activeVscodeSelection) break; + } + const result: SessionListResult = { + sessions, + activeThreadId: this.threadId, + }; + return asJsonValue(result); + } + + /** Attach to another already-open VS Code Codex conversation. */ + async selectSession(params: JsonObject): Promise { + this.ensureStarted(); + const origin = stringValue(params.origin) === "vscode" ? "vscode" : "web"; + const expectedRouteGeneration = origin === "vscode" + ? numberValue(params.vscodeRouteGeneration) + : undefined; + const target = (stringValue(params.threadId) ?? stringValue(params.conversationId))?.trim(); + if (!target) throw new Error("session/select requires threadId"); + if (target === this.threadId) { + return asJsonValue({ threadId: target, previousThreadId: target, switched: false, available: true }); + } + if (this.sessionSwitching) throw new Error("a session switch is already in progress"); + if (this.turnId || this.pending.size) { + throw new Error("cannot switch sessions while a turn or approval is active"); + } + const lifecycleGeneration = this.sessionLifecycleGeneration; + this.sessionSwitching = true; + const releaseSessionOperation = await this.acquireSessionOperation(); + const previousThreadId = this.threadId; + const previousOwnerClientId = this.ownerClientId; + const previousState = this.state; + // Keep a copy of the last owner-validated old-session state before changing + // the active conversation. It is a deterministic fallback if the target + // cannot produce a snapshot (for example, an already-running desktop + // writer). + let previousConversationState: ConversationStreamState | undefined; + let owner: string | null = null; + let attachmentChanged = false; + try { + this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration); + owner = await this.client.findThreadOwner(target, this.options.hostId, this.options.ownerDiscoveryTimeoutMs); + if (!owner) throw new Error(`找不到会话 ${target} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`); + this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration); + // A local turn/approval may have appeared while owner discovery was in + // flight. Re-check immediately before detaching the old projection. + if (this.turnId || this.pending.size) { + throw new Error("cannot switch sessions while a turn or approval is active"); + } + // Rollback must use the adapter's last owner-validated projection. The + // lower-level IPC cache sees every same-conversation snapshot before + // this adapter can reject a stale/unknown owner, so reading that cache + // here could resurrect content we deliberately ignored. + previousConversationState = previousThreadId && previousOwnerClientId + ? { + conversationId: previousThreadId, + hostId: this.options.hostId, + ownerClientId: previousOwnerClientId, + revision: this.revision ?? 0, + conversationState: cloneObject(this.conversationState), + } + : undefined; + this.emit({ + type: "session.switching", + threadId: target, + payload: { previousThreadId: previousThreadId ?? null, targetThreadId: target }, + }); + + // Keep the old follow alive until the new owner has supplied an + // authoritative snapshot. Events are filtered by `this.threadId`, so a + // stale old event cannot overwrite the target projection during attach. + this.threadId = target; + this.ownerClientId = owner; + this.state = "syncing"; + this.waitingForSession = false; + this.resetConversationProjection(); + attachmentChanged = true; + const waitForSnapshot = this.waitForSnapshot(target, this.options.followTimeoutMs, owner); + // If follow itself fails, the catch path rejects the waiter. Attach a + // handler immediately so that rejection can never become unhandled. + void waitForSnapshot.catch(() => undefined); + await this.client.followConversation(target, true, { + hostId: this.options.hostId, + targetClientIds: [owner], + }); + await waitForSnapshot; + this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration); + // Revisions are scoped to an owner. A handoff can happen after the first + // snapshot, so confirm the owner again before committing/unfollowing A. + const confirmedOwner = await this.client.findThreadOwner( + target, + this.options.hostId, + this.options.ownerDiscoveryTimeoutMs, + ); + if (confirmedOwner !== owner) { + throw new Error(`Codex conversation ${target} owner changed while switching`); + } + this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration); + if (previousThreadId && previousOwnerClientId) { + try { + await this.client.followConversation(previousThreadId, false, { + hostId: this.options.hostId, + targetClientIds: [previousOwnerClientId], + }); + } catch (error) { + this.options.logger?.debug?.(`Unable to unfollow previous session ${previousThreadId}`, error); + } + } + this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration); + this.state = this.deriveSessionState(); + this.waitingForSession = false; + this.clearWaitingDiscoveryTimer(); + if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded(); + this.emit({ + type: "session.selected", + threadId: target, + payload: { + threadId: target, + activeThreadId: target, + previousThreadId: previousThreadId ?? null, + switched: true, + available: true, + }, + }); + return asJsonValue({ threadId: target, previousThreadId: previousThreadId ?? null, switched: true, available: true }); + } catch (error) { + this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error))); + if (!attachmentChanged) throw error; + const lifecycleCurrent = lifecycleGeneration === this.sessionLifecycleGeneration + && !this.disposed + && this.started; + // Best-effort cleanup of the target subscription, then restore the old + // attachment so a failed switch does not strand the bridge disconnected. + if (lifecycleCurrent) { + try { + await this.client.followConversation(target, false, { + hostId: this.options.hostId, + ...(owner ? { targetClientIds: [owner] } : {}), + }); + } catch { /* best effort */ } + } + // dispose()/onClose owns the final disconnected state. An interrupted + // navigation must never publish rollback snapshots or reconnect after it. + if (!lifecycleCurrent) throw error; + this.threadId = previousThreadId; + this.ownerClientId = previousOwnerClientId; + this.state = previousState; + this.waitingForSession = !previousThreadId; + this.resetConversationProjection(); + // Move Relay/Web back before re-publishing the old snapshot. Browser + // command errors arrive after adapter events, so relying on only the + // command envelope would make the restored old projection look like an + // out-of-route event and leave the early target snapshot mounted. + if (previousThreadId) { + this.emit({ + type: "session.selected", + threadId: previousThreadId, + payload: { + threadId: previousThreadId, + activeThreadId: previousThreadId, + previousThreadId: target, + targetThreadId: target, + switched: false, + available: true, + failed: true, + origin, + }, + }); + } + const restoredFromCache = this.restoreCachedConversationProjection( + previousConversationState, + previousOwnerClientId, + ); + if (restoredFromCache && previousThreadId) { + // Re-assert the old follow without waiting for another snapshot. The + // cached state is already authoritative and keeps the bridge usable + // even when the owner does not answer a duplicate follow request. + try { + await this.client.followConversation(previousThreadId, true, { + hostId: this.options.hostId, + targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined, + }); + } catch (restoreError) { + this.options.logger?.debug?.("Unable to re-follow previous Codex session after switch failure", restoreError); + } + } else if (previousThreadId && previousOwnerClientId) { + try { + const restoreWaiter = this.waitForSnapshot(previousThreadId, this.options.followTimeoutMs, previousOwnerClientId); + void restoreWaiter.catch(() => undefined); + await this.client.followConversation(previousThreadId, true, { + hostId: this.options.hostId, + targetClientIds: [previousOwnerClientId], + }); + await restoreWaiter; + this.state = this.deriveSessionState(); + } catch (restoreError) { + this.options.logger?.warn?.("Unable to restore previous Codex session after switch failure", restoreError); + } + } + if (!previousThreadId && this.started && !this.disposed) this.scheduleWaitingDiscovery(); + throw error; + } finally { + releaseSessionOperation(); + this.sessionSwitching = false; + } + } + + /** Acquire a FIFO lock shared by session probes and attachment changes. */ + private async acquireSessionOperation(): Promise<() => void> { + const previous = this.sessionOperationTail; + let release!: () => void; + this.sessionOperationTail = new Promise((resolve) => { release = resolve; }); + await previous; + return release; + } + + private assertSessionSelectionCurrent( + target: string, + origin: "web" | "vscode", + lifecycleGeneration: number, + expectedRouteGeneration?: number, + ): void { + if (lifecycleGeneration !== this.sessionLifecycleGeneration || this.disposed || !this.started) { + throw new Error("session selection was cancelled because the IPC session closed"); + } + if (origin === "vscode" + && (expectedRouteGeneration === undefined + || expectedRouteGeneration !== this.vscodeRouteGeneration + || this.vscodeRouteActiveThreadId !== target)) { + throw new Error("session selection was superseded by a newer VS Code route"); + } + } + + async interruptTurn(params: JsonObject = {}): Promise { + this.ensureInteractiveReady(); + const expected = stringValue(params.turnId) ?? stringValue(params.expectedTurnId) ?? this.turnId; + if (!expected) throw new Error("turn/interrupt requires an active turn"); + const mode = stringValue(params.mode) ?? "user-stop"; + const result = await this.client.interruptTurn(this.threadId as string, { + mode, + expectedTurnId: expected, + ownerClientId: this.ownerClientId as string, + timeoutMs: this.options.followTimeoutMs, + }); + this.turnId = null; + this.state = "idle"; + const elapsed = this.status.elapsedMs ?? this.status.durationMs ?? null; + this.status = { + ...this.status, + activity: "interrupted", + turnStatus: "interrupted", + activeFlags: [], + durationMs: elapsed, + workedDurationMs: this.status.workedDurationMs ?? elapsed, + elapsedMs: elapsed, + }; + this.emit({ type: "task.cancelled", threadId: this.threadId ?? undefined, turnId: expected, payload: { mode } }); + return asJsonValue(unwrapFollowerResult(result)); + } + + async sendInput(text: string, params: JsonObject = {}): Promise { + const body = { ...params, text }; + return this.turnId ? this.steerTurn(body) : this.startTurn(body); + } + + async cancel(taskId?: string, params: JsonObject = {}): Promise { + return this.interruptTurn({ ...params, ...(taskId ? { turnId: taskId } : {}) }); + } + + async respondApproval( + requestId: JsonRpcId, + decision: "allow" | "deny" | "cancel", + reason?: string, + response?: JsonValue, + ): Promise { + return this.resolvePendingResponse(requestId, decision, reason, response, false); + } + + private async resolvePendingResponse( + requestId: JsonRpcId, + decision: "allow" | "deny" | "cancel", + reason: string | undefined, + response: JsonValue | undefined, + allowDuringSessionSwitch: boolean, + ): Promise { + this.ensureAttached(); + if (this.sessionSwitching && !allowDuringSessionSwitch) { + throw new Error("Codex session switch is still in progress"); + } + const key = jsonRpcIdKey(requestId); + const pending = this.pending.get(key); + if (!pending) throw new Error(`unknown or already resolved request: ${key}`); + const wire = this.toWireResponse(pending, decision, reason, response); + // Remove before awaiting the owner so a repeated browser click cannot send + // the same approval twice. If the IPC request fails, restore it for retry. + this.pending.delete(key); + this.clearPendingTimer(key); + this.optimisticallyResolved.add(key); + let result: JsonValue | undefined; + try { + result = await this.sendPendingResponse(pending, wire); + } catch (error) { + this.optimisticallyResolved.delete(key); + this.pending.set(key, pending); + this.schedulePendingExpiry(key, pending); + throw error; + } + this.emit({ + type: pending.approval ? "approval.resolved" : "input.resolved", + threadId: pending.threadId ?? this.threadId ?? undefined, + turnId: pending.turnId, + requestId, + payload: { requestId: asJsonValue(requestId), method: pending.method, decision }, + }); + return asJsonValue(result); + } + + async denyPending(reason = "relay disconnected"): Promise { + const entries = [...this.pending.values()]; + await Promise.all(entries.map(async (entry) => { + try { + await this.resolvePendingResponse(entry.requestId, "deny", reason, undefined, true); + } catch { /* fail closed when owner is gone */ } + })); + } + + private async sendPendingResponse(entry: RequestEntry, wire: JsonValue): Promise { + const conversationId = this.threadId as string; + const options = { ownerClientId: this.ownerClientId as string, timeoutMs: this.options.followTimeoutMs }; + if (entry.method === "item/commandExecution/requestApproval" || entry.method === "execCommandApproval") { + return this.client.respondCommandApproval(conversationId, entry.requestId, wire, options); + } + if (entry.method === "item/fileChange/requestApproval" || entry.method === "applyPatchApproval") { + return this.client.respondFileApproval(conversationId, entry.requestId, wire, options); + } + if (entry.method === "item/permissions/requestApproval") { + return this.client.respondPermissionsApproval(conversationId, entry.requestId, wire, options); + } + if (entry.method === "item/tool/requestUserInput") { + return this.client.respondUserInput(conversationId, entry.requestId, wire, options); + } + if (entry.method === "mcpServer/elicitation/request") { + return this.client.respondMcpElicitation(conversationId, entry.requestId, wire, options); + } + throw new Error(`unsupported follower request method: ${entry.method}`); + } + + private schedulePendingExpiry(key: string, entry: RequestEntry): void { + if (!entry.expiresAt || this.options.approvalTimeoutMs <= 0) { + // A later snapshot can omit an expiry that was present in an earlier + // request record. Do not leave the old timer alive in that case. + this.clearPendingTimer(key); + return; + } + const existing = this.pendingExpiryAt.get(key); + if (existing === entry.expiresAt && this.pendingTimers.has(key)) return; + this.clearPendingTimer(key); + const delay = Math.max(0, entry.expiresAt - Date.now()); + this.pendingExpiryAt.set(key, entry.expiresAt); + this.pendingTimers.set(key, setTimeout(() => { + this.pendingTimers.delete(key); + this.pendingExpiryAt.delete(key); + void this.expirePending(key); + }, delay)); + } + + private clearPendingTimer(key: string): void { + const timer = this.pendingTimers.get(key); + if (timer) clearTimeout(timer); + this.pendingTimers.delete(key); + this.pendingExpiryAt.delete(key); + } + + private async expirePending(key: string): Promise { + const entry = this.pending.get(key); + if (!entry || this.disposed || !this.started) return; + this.pending.delete(key); + this.optimisticallyResolved.add(key); + try { + const wire = this.toWireResponse(entry, "deny", "approval expired"); + await this.sendPendingResponse(entry, wire); + } catch (error) { + this.options.logger?.debug?.(`Unable to send expiry response for ${key}`, error); + } + this.emit({ + type: entry.approval ? "approval.expired" : "input.expired", + threadId: entry.threadId ?? this.threadId ?? undefined, + turnId: entry.turnId, + requestId: entry.requestId, + payload: { requestId: asJsonValue(entry.requestId), method: entry.method, reason: "approval expired" }, + }); + } + + async snapshot(): Promise { + const requests = [...this.pending.values()]; + return { + threadId: this.threadId, + turnId: this.turnId, + state: this.state, + status: { ...this.status, activeFlags: [...this.status.activeFlags] }, + activity: this.status.activity, + turnStatus: this.status.turnStatus, + activeFlags: [...this.status.activeFlags], + startedAtMs: this.status.startedAtMs ?? null, + durationMs: this.status.durationMs ?? null, + workedDurationMs: this.status.workedDurationMs ?? null, + elapsedMs: this.status.elapsedMs ?? null, + pendingApprovals: requests.map((entry) => entry.approval).filter((entry): entry is PendingApproval => Boolean(entry)), + pendingRequests: requests.map((entry) => ({ + requestId: entry.requestId, + method: entry.method, + params: redactJson(entry.params), + ...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}), + ...(entry.approval?.risk ? { risk: entry.approval.risk } : {}), + ...(entry.approval?.summary ? { summary: entry.approval.summary } : {}), + createdAt: entry.createdAt, + ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}), + })), + outputTail: this.outputTail, + messages: asJsonValue(this.outputMessages) as JsonValue[], + subagents: this.subagents.map((subagent) => ({ ...subagent })), + metadata: { + adapter: "codex-ipc-follower", + mode: "attach", + privateProtocol: true, + socketPath: this.client.socketPath, + waitingForSession: this.waitingForSession, + attachReady: Boolean(this.threadId && this.ownerClientId && !this.waitingForSession && this.state !== "syncing"), + ...(this.ownerClientId ? { ownerClientId: this.ownerClientId } : {}), + ...(this.revision !== null ? { revision: this.revision } : {}), + ...(typeof this.conversationState.cwd === "string" ? { cwd: this.conversationState.cwd } : {}), + ...(typeof this.conversationState.title === "string" ? { title: this.conversationState.title } : {}), + ...(typeof this.conversationState.source === "string" ? { source: this.conversationState.source } : {}), + ...projectSessionMetadata(this.conversationState), + activity: this.status.activity, + turnStatus: this.status.turnStatus, + activeFlags: asJsonValue(this.status.activeFlags), + startedAtMs: this.status.startedAtMs ?? null, + durationMs: this.status.durationMs ?? null, + workedDurationMs: this.status.workedDurationMs ?? null, + elapsedMs: this.status.elapsedMs ?? null, + firstTurnWorkItemStartedAtMs: this.status.firstTurnWorkItemStartedAtMs ?? null, + finalAssistantStartedAtMs: this.status.finalAssistantStartedAtMs ?? null, + historyComplete: !hasIncompleteHistory(this.conversationState), + }, + }; + } + + onEvent(listener: (event: AgentEvent) => void): Disposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + async dispose(): Promise { + if (this.disposed) return; + this.sessionLifecycleGeneration += 1; + this.clearWaitingDiscoveryTimer(); + this.waitingForSession = false; + this.clearSnapshotWaiter(new Error("Codex IPC follower is stopping")); + this.clearRevisionWaiter(new Error("Codex IPC follower is stopping")); + // While the private IPC socket is still writable, close every outstanding + // owner request explicitly so stopping the bridge cannot strand an approval + // dialog in the official Codex UI. + if (this.started && this.pending.size) await this.denyPending("bridge stopped"); + this.disposed = true; + this.started = false; + this.state = "disconnected"; + this.clearPendingVscodeFollow(); + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteAwaitingSelection = false; + this.vscodeRouteClientId = null; + this.vscodeRouteCandidates.clear(); + this.activeVscodeSelection = undefined; + this.resetHistoryLoading(); + this.clearSnapshotWaiter(new Error("Codex IPC follower disposed")); + this.clearRevisionWaiter(new Error("Codex IPC follower disposed")); + for (const timer of this.pendingTimers.values()) clearTimeout(timer); + this.pendingTimers.clear(); + this.pendingExpiryAt.clear(); + if (this.threadId) { + try { + await this.client.followConversation(this.threadId, false, { + hostId: this.options.hostId, + targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined, + }); + } catch { /* socket may already be closed */ } + } + for (const entry of this.pending.values()) { + if (entry.approval) this.emit({ type: "approval.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } }); + else this.emit({ type: "input.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } }); + } + this.pending.clear(); + this.optimisticallyResolved.clear(); + for (const subscription of this.subscriptions.splice(0)) subscription.dispose(); + if (this.options.disposeClient) await this.client.dispose(); + } + + private async discoverThreadId(excluded = new Set(), maxCandidates = 64): Promise { + if (!this.options.autoDiscoverThread) return undefined; + const codexHome = resolveCodexHome(this.options); + const candidates = await recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []); + if (!candidates.length) return undefined; + // Owner discovery is the authority: a rollout file can remain on disk + // after its VS Code owner is gone. An older conversation can still be the + // one currently open in the official panel, so inspect enough candidates + // to cover its bounded recent-chat view. Eight-way batches with a short + // timeout keep the worst-case startup budget no larger than the former + // 12-candidate, four-way scan. + const limited = candidates.filter((candidate) => !excluded.has(candidate.id)).slice(0, Math.max(1, maxCandidates)); + const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750)); + for (let offset = 0; offset < limited.length; offset += 8) { + const batch = limited.slice(offset, offset + 8); + const checks = await Promise.all(batch.map(async (candidate) => { + try { + const owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout); + return owner ? candidate : undefined; + } catch (error) { + this.options.logger?.debug?.(`Thread owner discovery failed for ${candidate.id}`, error); + return undefined; + } + })); + const selected = checks.find((candidate): candidate is Candidate => Boolean(candidate)); + if (selected) { + this.options.logger?.info?.(`Auto-discovered VS Code Codex conversation ${selected.id}`); + return selected.id; + } + } + return undefined; + } + + /** + * Keep the relay/IPC connection usable while the official panel has no + * selected conversation yet. The first route broadcast is handled as a + * fast path; this bounded poll covers sessions opened by older extension + * builds that do not emit the route notification. + */ + private enterWaitingForSession(): void { + if (this.disposed) return; + const wasStarted = this.started; + const wasWaiting = this.waitingForSession; + this.started = true; + this.waitingForSession = true; + this.state = "waiting_for_host"; + this.threadId = null; + this.ownerClientId = null; + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteClientId = null; + this.vscodeRouteAwaitingSelection = false; + this.vscodeRouteCandidates.clear(); + this.resetConversationProjection(); + // RelayHost normally synthesizes this event after adapter.start(). Emit it + // here as well so the adapter can be used directly and so a transition + // back to waiting clears any provisional target in existing browsers. + if (!wasWaiting) { + this.emit({ type: "connection.opened", payload: { mode: "attach", waitingForSession: true } }); + if (wasStarted) void this.emitSnapshot(); + } + this.scheduleWaitingDiscovery(); + } + + private scheduleWaitingDiscovery(delayMs = WAITING_SESSION_DISCOVERY_DELAY_MS): void { + if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryTimer) return; + this.waitingDiscoveryTimer = setTimeout(() => { + this.waitingDiscoveryTimer = undefined; + void this.runWaitingDiscovery(); + }, Math.max(0, delayMs)); + } + + private clearWaitingDiscoveryTimer(): void { + if (this.waitingDiscoveryTimer) clearTimeout(this.waitingDiscoveryTimer); + this.waitingDiscoveryTimer = undefined; + } + + private async runWaitingDiscovery(): Promise { + if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryInFlight) return; + const generation = this.sessionLifecycleGeneration; + const routeGeneration = this.vscodeRouteGeneration; + const officialRouteHasPriority = () => this.options.followVscodeSession + && (this.vscodeRouteGeneration !== routeGeneration + || Boolean(this.vscodeRouteClientId && this.vscodeRouteActiveThreadId)); + const task = (async () => { + // Polling is only a fallback for panels/builds that do not broadcast their + // current route. Once the official panel supplies a route, never let a + // slower filesystem/owner lookup replace it with an older recent thread. + if (officialRouteHasPriority()) return; + const configured = this.options.threadId?.trim(); + if (configured && await this.tryAttachWaitingThread(configured)) return; + if (officialRouteHasPriority()) return; + if (!this.options.autoDiscoverThread) return; + const selected = await this.discoverThreadId( + configured ? new Set([configured]) : new Set(), + WAITING_SESSION_DISCOVERY_MAX_CANDIDATES, + ); + if (selected && !officialRouteHasPriority()) await this.tryAttachWaitingThread(selected); + })(); + this.waitingDiscoveryInFlight = task; + try { + await task; + } catch (error) { + if (this.started && !this.disposed) { + this.options.logger?.debug?.("Waiting for a VS Code Codex session", error); + } + } finally { + if (this.waitingDiscoveryInFlight === task) this.waitingDiscoveryInFlight = undefined; + // A socket close/dispose may have happened while discovery was in + // flight. The generation check prevents a stale completion from + // scheduling a new timer on a dead adapter. + if (generation === this.sessionLifecycleGeneration && this.started && this.waitingForSession && !this.disposed) { + this.scheduleWaitingDiscovery(); + } + } + } + + private async tryAttachWaitingThread(target: string): Promise { + const normalizedTarget = target.trim(); + if (!normalizedTarget || this.disposed || !this.started) return false; + if (this.waitingAttachPromise) { + if (this.waitingAttachTarget !== normalizedTarget) this.queuedWaitingAttachTarget = normalizedTarget; + return this.waitingAttachTarget === normalizedTarget ? this.waitingAttachPromise : false; + } + if (!this.waitingForSession) return false; + this.waitingAttachTarget = normalizedTarget; + const task = (async () => { + const release = await this.acquireSessionOperation(); + this.sessionSwitching = true; + try { + if (this.disposed || !this.started || !this.waitingForSession) return false; + await this.attachThread(normalizedTarget, { fromWaiting: true }); + return true; + } catch (error) { + if (this.started && !this.disposed) { + this.options.logger?.debug?.(`Unable to attach waiting VS Code session ${normalizedTarget}`, error); + // attachThread restores the waiting projection for a failed + // fromWaiting attempt. Keep the retry timer alive for the next poll. + this.scheduleWaitingDiscovery(); + } + return false; + } finally { + this.sessionSwitching = false; + release(); + } + })(); + this.waitingAttachPromise = task; + let attached = false; + try { + attached = await task; + return attached; + } finally { + if (this.waitingAttachPromise === task) this.waitingAttachPromise = undefined; + this.waitingAttachTarget = null; + const queued = this.queuedWaitingAttachTarget; + this.queuedWaitingAttachTarget = null; + if (queued && !this.disposed && this.started) { + if (this.waitingForSession) { + void this.tryAttachWaitingThread(queued); + } else if (queued !== this.threadId) { + // The official panel can move A -> B while A's first snapshot is in + // flight. Preserve the latest route and reuse the normal deferred + // switching path so an active turn on A is never detached early. + this.pendingVscodeThreadId = queued; + this.pendingVscodeFollowAttempts = 0; + this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration; + this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs); + } + } + } + } + + /** + * Verify that a discovered owner can actually stream this conversation. + * Owner discovery may return a desktop client (or a stale handoff) that + * knows the id but cannot provide the follower snapshot needed for attach. + * The temporary follow is isolated to a direct stream listener and is always + * removed before returning, so probing never changes the active projection. + */ + private async probeSessionSnapshot(conversationId: string, ownerClientId: string, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + let resolveSnapshot!: (available: boolean) => void; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + timer = setTimeout(() => resolve(false), timeoutMs); + }); + const subscription = this.client.onStreamEvent((event) => { + if (event.kind === "snapshot" + && event.conversationId === conversationId + && event.ownerClientId === ownerClientId) { + resolveSnapshot(true); + } + }); + try { + await this.client.followConversation(conversationId, true, { + hostId: this.options.hostId, + targetClientIds: [ownerClientId], + }); + return await snapshot; + } catch (error) { + this.options.logger?.debug?.(`Session snapshot probe failed for ${conversationId}`, error); + return false; + } finally { + if (timer) clearTimeout(timer); + subscription.dispose(); + // A user may select this row while the probe is waiting. In that case + // the temporary follow has become the active attachment; do not tear it + // down from the list request's cleanup path. + if (!(this.threadId === conversationId && this.ownerClientId === ownerClientId)) { + try { + await this.client.followConversation(conversationId, false, { + hostId: this.options.hostId, + targetClientIds: [ownerClientId], + }); + } catch (error) { + this.options.logger?.debug?.(`Unable to clean up session snapshot probe ${conversationId}`, error); + } + } + } + } + + /** + * Observe the official panel's route without reading its DOM. The official + * webview broadcasts old=false followed by new=true from one stable IPC + * client. Requiring that pair avoids treating reconnect status replays or a + * different Codex window's isolated true broadcast as a user navigation. + */ + private handleBroadcast(frame: IpcBroadcast): void { + if (!this.options.followVscodeSession || !this.started || this.disposed) return; + if (frame.method === "client-status-changed") { + this.handleRouteClientStatus(frame); + return; + } + if (frame.method !== "thread-stream-following-changed") return; + if (this.options.strictVersions !== false + && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"]) return; + // Status replies sent to a newly connected follower describe retained + // subscriptions, not a fresh route change in the VS Code panel. + if (frame.targetClientIds?.length) return; + if (!isRecord(frame.params)) return; + const conversationId = stringValue(frame.params.conversationId)?.trim(); + const hostId = stringValue(frame.params.hostId) ?? "local"; + const sourceClientId = frame.sourceClientId?.trim(); + if (!conversationId || hostId !== this.options.hostId || !sourceClientId) return; + if (sourceClientId === this.client.getClientId()) return; + + // There is no trusted old route while the first conversation is missing. + // Treat an untargeted `following:true` as a candidate hint, then verify it + // through owner discovery and an authoritative snapshot before attaching. + if (this.waitingForSession || this.waitingAttachPromise) { + if (frame.params.following !== true) return; + if (this.vscodeRouteClientId && sourceClientId !== this.vscodeRouteClientId) return; + this.vscodeRouteClientId = sourceClientId; + if (this.vscodeRouteActiveThreadId !== conversationId) { + this.vscodeRouteActiveThreadId = conversationId; + this.vscodeRouteGeneration += 1; + } + void this.tryAttachWaitingThread(conversationId); + return; + } + + // Bind only after one source completes old=false -> new=true. A different + // remote follower can emit an isolated false while disposing, and that + // must not permanently steal the trusted route source. + if (!this.vscodeRouteClientId) { + if (frame.params.following === false) { + if (conversationId === this.vscodeRouteActiveThreadId) { + this.vscodeRouteCandidates.set(sourceClientId, conversationId); + } + return; + } + if (frame.params.following !== true) return; + if (this.vscodeRouteCandidates.get(sourceClientId) !== this.vscodeRouteActiveThreadId) return; + this.vscodeRouteClientId = sourceClientId; + this.vscodeRouteCandidates.clear(); + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteAwaitingSelection = true; + this.vscodeRouteGeneration += 1; + } + if (sourceClientId !== this.vscodeRouteClientId) return; + + if (frame.params.following === false) { + if (conversationId !== this.vscodeRouteActiveThreadId) return; + this.vscodeRouteGeneration += 1; + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteAwaitingSelection = true; + if (this.activeVscodeSelection?.target === conversationId) { + this.clearSnapshotWaiter(new Error(`VS Code moved away from conversation ${conversationId}`)); + } + // A -> B -> C can happen faster than B's snapshot. Cancel the queued B + // as soon as the official route reports that B is no longer active. + if (this.pendingVscodeThreadId === conversationId) this.clearPendingVscodeFollow(); + return; + } + if (frame.params.following !== true) return; + + if (conversationId === this.vscodeRouteActiveThreadId) return; + if (!this.vscodeRouteAwaitingSelection) { + // Initial/reconnect following status. It may confirm the current route, + // but an isolated true is intentionally never treated as navigation. + if (conversationId === this.threadId) this.vscodeRouteActiveThreadId = conversationId; + return; + } + this.vscodeRouteAwaitingSelection = false; + this.vscodeRouteActiveThreadId = conversationId; + this.vscodeRouteGeneration += 1; + if (conversationId === this.threadId) { + this.clearPendingVscodeFollow(); + return; + } + this.pendingVscodeThreadId = conversationId; + this.pendingVscodeFollowAttempts = 0; + this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration; + this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs); + } + + /** + * The official webview gets a new IPC client id after its socket reconnects. + * Drop only the trusted route-source binding when that client disconnects; + * the next same-source false -> true pair can then establish the replacement. + */ + private handleRouteClientStatus(frame: IpcBroadcast): void { + if (this.options.strictVersions !== false && frame.version !== 0) return; + if (!isRecord(frame.params)) return; + const clientId = (stringValue(frame.params.clientId) ?? frame.sourceClientId)?.trim(); + const status = stringValue(frame.params.status)?.trim().toLowerCase(); + if (!clientId || status !== "disconnected") return; + this.vscodeRouteCandidates.delete(clientId); + if (clientId !== this.vscodeRouteClientId) return; + + this.options.logger?.debug?.(`VS Code Codex route client ${clientId} disconnected; awaiting a replacement source`); + this.vscodeRouteClientId = null; + this.vscodeRouteCandidates.clear(); + this.vscodeRouteAwaitingSelection = false; + // Keep the route identity, rather than forcing it back to the currently + // committed relay thread. A disconnect may occur between B's true signal + // and B's snapshot; the replacement panel will later leave B with false. + this.vscodeRouteActiveThreadId = this.activeVscodeSelection?.target + ?? this.vscodeRouteActiveThreadId + ?? this.threadId; + this.vscodeRouteGeneration += 1; + this.clearPendingVscodeFollow(); + if (this.activeVscodeSelection) { + this.clearSnapshotWaiter(new Error("VS Code Codex route client disconnected during session selection")); + } + } + + private schedulePendingVscodeFollow(delayMs: number): void { + if (!this.pendingVscodeThreadId || this.disposed || !this.started) return; + if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer); + this.vscodeSessionFollowTimer = setTimeout(() => { + this.vscodeSessionFollowTimer = undefined; + void this.applyPendingVscodeFollow(); + }, Math.max(0, delayMs)); + } + + private async applyPendingVscodeFollow(): Promise { + const target = this.pendingVscodeThreadId; + const routeGeneration = this.pendingVscodeFollowGeneration; + if (!target || this.disposed || !this.started) return; + if (this.vscodeRouteActiveThreadId !== target || routeGeneration !== this.vscodeRouteGeneration) { + if (this.pendingVscodeThreadId === target) this.clearPendingVscodeFollow(); + return; + } + if (target === this.threadId) { + this.clearPendingVscodeFollow(); + return; + } + // Never detach a turn or approval that is still owned by the old thread. + // Keep only the latest official target and retry once that state is idle. + if (this.sessionSwitching || this.turnId || this.pending.size) { + this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS); + return; + } + + this.pendingVscodeThreadId = null; + this.pendingVscodeFollowGeneration = 0; + this.activeVscodeSelection = { target, generation: routeGeneration }; + try { + await this.selectSession({ + threadId: target, + origin: "vscode", + vscodeRouteGeneration: routeGeneration, + }); + this.pendingVscodeFollowAttempts = 0; + this.options.logger?.info?.(`Followed VS Code Codex panel to conversation ${target}`); + } catch (error) { + this.options.logger?.debug?.(`Unable to follow VS Code Codex panel to ${target}`, error); + if (!this.disposed + && this.started + && !this.pendingVscodeThreadId + && this.vscodeRouteActiveThreadId === target + && this.vscodeRouteGeneration === routeGeneration + && this.pendingVscodeFollowAttempts < VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS) { + this.pendingVscodeFollowAttempts += 1; + this.pendingVscodeThreadId = target; + this.pendingVscodeFollowGeneration = routeGeneration; + } + } finally { + if (this.activeVscodeSelection?.target === target + && this.activeVscodeSelection.generation === routeGeneration) { + this.activeVscodeSelection = undefined; + } + if (this.pendingVscodeThreadId) { + this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS); + } + } + } + + private clearPendingVscodeFollow(): void { + if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer); + this.vscodeSessionFollowTimer = undefined; + this.pendingVscodeThreadId = null; + this.pendingVscodeFollowAttempts = 0; + this.pendingVscodeFollowGeneration = 0; + } + + private handleStreamEvent(event: ConversationStreamEvent): void { + if (!this.threadId || event.conversationId !== this.threadId) return; + // Following is targeted at the owner discovered during attach. The IPC + // router normally filters these broadcasts, but a handoff/old socket can + // still surface another client's event; applying it would overwrite the + // active conversation and could satisfy a revision waiter incorrectly. + if (this.ownerClientId && event.ownerClientId && event.ownerClientId !== this.ownerClientId) { + this.options.logger?.debug?.(`Ignoring stream event from unexpected Codex owner ${event.ownerClientId}`); + return; + } + if (event.kind === "desync") { + this.options.logger?.warn?.(`Codex IPC stream desynchronized at revision ${event.receivedBaseRevision}; requesting snapshot`); + this.client.followConversation(this.threadId, true, { hostId: this.options.hostId, targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined }).catch((error) => this.options.logger?.warn?.("Unable to recover IPC snapshot", error)); + return; + } + this.ownerClientId = event.ownerClientId || this.ownerClientId; + this.revision = event.revision; + if (this.revisionWaiter + && this.revisionWaiter.threadId === event.conversationId + && this.revisionWaiter.ownerClientId === event.ownerClientId + && event.revision >= this.revisionWaiter.revision) { + this.clearRevisionWaiter(); + } + this.conversationState = cloneObject(event.conversationState); + this.processConversationState(event.kind === "snapshot"); + if (event.kind === "snapshot" && this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded(); + if (event.kind === "snapshot" + && this.snapshotWaiter?.threadId === event.conversationId + && (!this.snapshotWaiter.ownerClientId || this.snapshotWaiter.ownerClientId === event.ownerClientId)) { + this.clearSnapshotWaiter(); + } + } + + private processConversationState(initial: boolean): void { + if (initial) this.snapshotSeen = true; + const previousTurnId = this.turnId; + const previousState = this.state; + const previousStatus = this.status; + const nextTurn = deriveTurn(this.conversationState); + // Request records and runtime flags are part of the same conversation + // snapshot. Derive status after extracting them so an approval/input wait + // is visible immediately with the corresponding state patch. + const nextRequests = extractRequests(this.conversationState, this.options.approvalTimeoutMs); + this.status = deriveStatusSnapshot(this.conversationState, nextTurn, nextRequests); + this.turnId = nextTurn.active ? nextTurn.id ?? null : null; + this.state = nextTurn.active ? "active" : this.deriveSessionState(); + const nextHistoryComplete = !hasIncompleteHistory(this.conversationState); + const historyChanged = this.historyComplete !== undefined && this.historyComplete !== nextHistoryComplete; + // Settings updates are broadcast as conversation-state patches and may not + // change output, turn status, or pending requests. Fingerprint only the + // redacted projection exposed by `snapshot()` so those patches still reach + // the relay without leaking opaque/private state or causing per-token + // snapshot spam. + const metadataShape = stableStringify(projectSessionMetadata(this.conversationState)); + const metadataChanged = metadataShape !== this.renderedMetadataShape; + + const previousPending = new Map(this.pending); + const nextKeys = new Set(nextRequests.map((entry) => jsonRpcIdKey(entry.requestId))); + for (const key of this.optimisticallyResolved) { + if (!nextKeys.has(key)) this.optimisticallyResolved.delete(key); + } + const previousKeys = new Set(previousPending.keys()); + for (const key of this.pendingTimers.keys()) { + if (!nextKeys.has(key)) this.clearPendingTimer(key); + } + this.pending.clear(); + for (const rawEntry of nextRequests) { + const prior = previousPending.get(jsonRpcIdKey(rawEntry.requestId)); + // Some official request records omit timestamps. Keep the first-seen + // deadline across patches instead of extending it on every output delta. + const entry = prior + ? { ...rawEntry, createdAt: prior.createdAt, expiresAt: prior.expiresAt ?? rawEntry.expiresAt } + : rawEntry; + const key = jsonRpcIdKey(entry.requestId); + if (this.optimisticallyResolved.has(key)) continue; + this.pending.set(key, entry); + this.schedulePendingExpiry(key, entry); + if (!previousKeys.has(key)) this.emitRequest(entry); + } + for (const key of previousKeys) { + if (nextKeys.has(key) || this.optimisticallyResolved.has(key)) continue; + const old = previousPending.get(key); + if (old) this.emit({ type: old.approval ? "approval.resolved" : "input.resolved", threadId: old.threadId ?? this.threadId ?? undefined, turnId: old.turnId, requestId: old.requestId, payload: { requestId: asJsonValue(old.requestId), method: old.method } }); + } + + const rendered = renderConversationOutput(this.conversationState, this.options.maxOutputTailChars); + const output = rendered.text; + const messageShape = renderedMessageShape(rendered.messages); + const messagesChanged = messageShape !== this.renderedMessageShape; + const messagesPatch = messagesChanged + ? renderedMessagesPatch(this.outputMessages, rendered.messages) + : undefined; + const subagentShape = renderedSubagentShape(rendered.subagents); + const subagentsChanged = subagentShape !== this.renderedSubagentShape; + if (output !== this.renderedOutput || rendered.totalLength !== this.renderedOutputLength || messagesChanged || subagentsChanged) { + const delta = this.renderedOutput + ? appendOnlyOutputDelta( + this.renderedOutput, + this.renderedOutputLength, + output, + rendered.totalLength, + this.renderedOutputWasTruncated, + ) + : undefined; + if (!this.renderedOutput || delta === undefined || (!delta && (messagesChanged || subagentsChanged))) { + this.emit({ type: "output.snapshot", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: { stream: "codex", text: output, messages: asJsonValue(rendered.messages), subagents: asJsonValue(rendered.subagents), structureChanged: true, encoding: "utf8" } }); + } else { + if (delta || subagentsChanged) this.emit({ + type: "output.chunk", + threadId: this.threadId ?? undefined, + turnId: this.turnId ?? undefined, + payload: { + stream: "codex", + text: delta, + // Keep the append-only field for older relays, but include the + // authoritative projection so a browser can preserve item + // boundaries while reasoning/commands/edits stream in. + outputTail: output, + ...(messagesPatch ? { messagesPatch: asJsonValue(messagesPatch) } : {}), + subagents: asJsonValue(rendered.subagents), + structureChanged: messagesChanged || subagentsChanged, + encoding: "utf8", + }, + }); + } + this.renderedOutput = output; + this.renderedOutputLength = rendered.totalLength; + this.renderedOutputWasTruncated = rendered.truncated; + this.renderedMessageShape = messageShape; + this.renderedSubagentShape = subagentShape; + this.outputTail = output; + this.outputMessages = rendered.messages; + this.subagents = rendered.subagents; + } + + if (!initial && !previousTurnId && this.turnId) { + this.emit({ type: "task.started", threadId: this.threadId ?? undefined, turnId: this.turnId, payload: statusPayload(this.status) }); + } else if (!initial && previousTurnId && !this.turnId) { + const cancelled = new Set(["cancelled", "canceled", "interrupted"]).has(normalizeStatus(nextTurn.status)); + this.emit({ type: cancelled ? "task.cancelled" : "task.finished", threadId: this.threadId ?? undefined, turnId: previousTurnId, payload: statusPayload(this.status) }); + } else if (!initial && !sameStatus(previousStatus, this.status)) { + // A turn can remain active while moving from reasoning to a command or + // file edit, and can enter/leave an approval wait without changing its + // id. Publish a dedicated status event so remote viewers do not have to + // infer activity from output timing. + this.emit({ type: "task.status", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: statusPayload(this.status) }); + } + + this.historyComplete = nextHistoryComplete; + this.renderedMetadataShape = metadataShape; + if (initial || metadataChanged || historyChanged || previousTurnId !== this.turnId || previousState !== this.state || !sameStatus(previousStatus, this.status) || nextRequests.length !== previousKeys.size || subagentsChanged) { + void this.emitSnapshot(); + } + } + + private async loadCompleteHistoryIfNeeded(): Promise { + if (this.historyLoadRequested || this.historyLoadRetryTimer || this.historyLoadAttempts >= 2 || !this.threadId || !this.ownerClientId || !hasIncompleteHistory(this.conversationState)) return; + this.clearHistoryLoadRetryTimer(); + // Keep the request target stable. A stream event from another owner can + // arrive while the owner is loading history; using the mutable fields + // below would otherwise wait for (or acknowledge) the wrong stream. + const threadId = this.threadId; + const ownerClientId = this.ownerClientId; + const generation = this.historyLoadGeneration; + this.historyLoadRequested = true; + this.historyLoadAttempts += 1; + try { + const result = await this.client.loadCompleteHistory(threadId, { + ownerClientId, + timeoutMs: this.options.followTimeoutMs, + }); + // The socket may close (or the owner may hand the conversation to a new + // client) while the request is in flight. Do not install a fresh waiter + // after dispose/close, where nobody could clear it. + if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return; + const requestedRevision = extractRevision(result); + // The owner acknowledges the load request before broadcasting its new + // state. Wait for that revision so the relay's first snapshot contains + // the complete history rather than the old paginated tail. + if (requestedRevision !== undefined && (this.revision ?? 0) < requestedRevision) { + await this.waitForRevision(threadId, ownerClientId, requestedRevision, this.options.followTimeoutMs); + } + // A session switch can resolve the old revision waiter while installing + // a new history request. Never let that old continuation clear or retry + // the new conversation's loading state. + if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return; + this.historyLoadRequested = false; + if (this.started + && this.threadId === threadId + && this.ownerClientId === ownerClientId + && hasIncompleteHistory(this.conversationState) + && this.historyLoadAttempts < 2) { + void this.loadCompleteHistoryIfNeeded(); + } + } catch (error) { + // History loading is an optional read enhancement. The live tail remains + // usable when an older extension does not implement this request. + this.options.logger?.debug?.("Unable to load complete Codex history", error); + if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return; + this.historyLoadRequested = false; + if (isTransientHistoryLoadError(error) + && this.historyLoadAttempts < 2 + && hasIncompleteHistory(this.conversationState)) { + this.scheduleHistoryLoadRetry(threadId, ownerClientId, generation); + } + } + } + + private scheduleHistoryLoadRetry(threadId: string, ownerClientId: string, generation: number): void { + if (this.historyLoadRetryTimer || !this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return; + this.historyLoadRetryTimer = setTimeout(() => { + this.historyLoadRetryTimer = undefined; + if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation) + || this.historyLoadRequested + || this.historyLoadAttempts >= 2 + || !hasIncompleteHistory(this.conversationState)) return; + void this.loadCompleteHistoryIfNeeded(); + }, HISTORY_LOAD_RETRY_DELAY_MS); + } + + private isCurrentHistoryLoad(threadId: string, ownerClientId: string, generation: number): boolean { + return !this.disposed + && this.started + && this.historyLoadGeneration === generation + && this.threadId === threadId + && this.ownerClientId === ownerClientId; + } + + private clearHistoryLoadRetryTimer(): void { + if (!this.historyLoadRetryTimer) return; + clearTimeout(this.historyLoadRetryTimer); + this.historyLoadRetryTimer = undefined; + } + + private resetHistoryLoading(): void { + this.clearHistoryLoadRetryTimer(); + this.historyLoadRequested = false; + this.historyLoadAttempts = 0; + this.historyLoadGeneration += 1; + } + + private emitRequest(entry: RequestEntry): void { + const isInput = INPUT_METHODS.has(entry.method); + this.emit({ + type: isInput ? "input.requested" : APPROVAL_METHODS.has(entry.method) ? "approval.requested" : "server.requested", + threadId: entry.threadId ?? this.threadId ?? undefined, + turnId: entry.turnId, + requestId: entry.requestId, + payload: { + requestId: asJsonValue(entry.requestId), + method: entry.method, + params: redactJson(entry.params), + ...(entry.approval ? { + action: entry.approval.action, + risk: entry.approval.risk, + summary: entry.approval.summary, + ...(entry.approval.commandHash ? { commandHash: entry.approval.commandHash } : {}), + } : {}), + ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}), + }, + raw: redactJson({ requestId: entry.requestId, method: entry.method, params: entry.params }), + }); + } + + private async emitSnapshot(): Promise { + const snapshot = await this.snapshot(); + this.emit({ type: "session.snapshot", threadId: snapshot.threadId ?? undefined, turnId: snapshot.turnId ?? undefined, payload: asJsonObject(snapshot) }); + } + + private deriveSessionState(): string { + const runtime = this.conversationState.threadRuntimeStatus; + if (isRecord(runtime) && typeof runtime.type === "string") { + const status = normalizeStatus(runtime.type); + if (!TERMINAL_TURN_STATES.has(status) && status !== "idle" && status !== "ready") return runtime.type; + } + return this.snapshotSeen ? "idle" : "syncing"; + } + + private waitForSnapshot(threadId: string, timeoutMs: number, ownerClientId?: string): Promise { + this.clearSnapshotWaiter(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.snapshotWaiter?.threadId === threadId + && this.snapshotWaiter.ownerClientId === ownerClientId) this.snapshotWaiter = undefined; + reject(new Error(`Timed out waiting for a snapshot from VS Code conversation ${threadId}`)); + }, timeoutMs); + this.snapshotWaiter = { threadId, ownerClientId, resolve, reject, timer }; + }); + } + + /** Rehydrate the adapter's last owner-validated state after failed navigation. */ + private restoreCachedConversationProjection( + cached: ConversationStreamState | undefined, + fallbackOwnerClientId: string | null, + ): boolean { + if (!cached || !cached.conversationState) return false; + this.ownerClientId = cached.ownerClientId || fallbackOwnerClientId; + this.revision = cached.revision; + this.conversationState = cloneObject(cached.conversationState); + this.processConversationState(true); + this.state = this.deriveSessionState(); + return true; + } + + private waitForRevision(threadId: string, ownerClientId: string, revision: number, timeoutMs: number): Promise { + this.clearRevisionWaiter(); + if (this.threadId === threadId && this.ownerClientId === ownerClientId && (this.revision ?? 0) >= revision) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.revisionWaiter?.threadId === threadId + && this.revisionWaiter.ownerClientId === ownerClientId + && this.revisionWaiter.revision === revision) this.revisionWaiter = undefined; + reject(new Error(`Timed out waiting for Codex conversation revision ${revision}`)); + }, timeoutMs); + this.revisionWaiter = { threadId, ownerClientId, revision, resolve, reject, timer }; + }); + } + + private clearSnapshotWaiter(error?: Error): void { + const waiter = this.snapshotWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.snapshotWaiter = undefined; + if (error) waiter.reject(error); + else waiter.resolve(); + } + + private clearRevisionWaiter(error?: Error): void { + const waiter = this.revisionWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.revisionWaiter = undefined; + if (error) waiter.reject(error); + else waiter.resolve(); + } + + /** Clear all projections that belong to the previous conversation. */ + private resetConversationProjection(): void { + this.clearRevisionWaiter(); + this.revision = null; + this.conversationState = {}; + this.turnId = null; + this.status = { + activity: "idle", + turnStatus: "idle", + activeFlags: [], + startedAtMs: null, + durationMs: null, + workedDurationMs: null, + elapsedMs: null, + firstTurnWorkItemStartedAtMs: null, + finalAssistantStartedAtMs: null, + }; + this.renderedOutput = ""; + this.renderedOutputLength = 0; + this.renderedOutputWasTruncated = false; + this.renderedMessageShape = ""; + this.outputTail = ""; + this.outputMessages = []; + this.subagents = []; + this.renderedSubagentShape = ""; + this.renderedMetadataShape = ""; + this.snapshotSeen = false; + this.historyComplete = undefined; + this.resetHistoryLoading(); + } + + private handleClose(error?: Error): void { + if (!this.started || this.disposed) return; + const closedThreadId = this.threadId; + const closedOwnerClientId = this.ownerClientId; + this.sessionLifecycleGeneration += 1; + this.clearWaitingDiscoveryTimer(); + this.waitingForSession = false; + this.started = false; + this.state = "disconnected"; + this.clearPendingVscodeFollow(); + this.vscodeRouteActiveThreadId = null; + this.vscodeRouteAwaitingSelection = false; + this.vscodeRouteClientId = null; + this.vscodeRouteCandidates.clear(); + this.activeVscodeSelection = undefined; + this.clearSnapshotWaiter(error ?? new Error("Codex IPC socket closed")); + this.clearRevisionWaiter(error ?? new Error("Codex IPC socket closed")); + for (const timer of this.pendingTimers.values()) clearTimeout(timer); + this.pendingTimers.clear(); + this.pendingExpiryAt.clear(); + const pending = [...this.pending.values()]; + this.pending.clear(); + this.optimisticallyResolved.clear(); + // Do not retain a conversation projection after the private IPC owner has + // gone away. A later snapshot request must report a disconnected, empty + // follower rather than stale messages that can no longer be controlled. + this.resetConversationProjection(); + this.threadId = null; + this.ownerClientId = null; + this.status = { + ...this.status, + activity: "idle", + turnStatus: "disconnected", + activeFlags: [], + }; + for (const entry of pending) { + this.emit({ + type: entry.approval ? "approval.expired" : "input.expired", + threadId: entry.threadId ?? closedThreadId ?? undefined, + turnId: entry.turnId, + requestId: entry.requestId, + payload: { requestId: asJsonValue(entry.requestId), reason: error?.message ?? "IPC socket closed" }, + }); + } + this.emit({ + type: "connection.closed", + threadId: closedThreadId ?? undefined, + payload: { + message: error?.message ?? "IPC socket closed", + ...(closedOwnerClientId ? { ownerClientId: closedOwnerClientId } : {}), + }, + }); + } + + private ensureAttached(): void { + if (!this.started) throw new Error("Codex remote bridge is not attached to a VS Code Codex session"); + if (this.waitingForSession || this.state === "waiting_for_host") { + throw new Error("waiting_for_session: 请先在 VS Code 打开一个 Codex 会话"); + } + if (!this.threadId || !this.ownerClientId) throw new Error("No existing Codex conversation owner is attached"); + } + + private ensureStarted(): void { + if (!this.started) throw new Error("Codex remote bridge is not connected to the VS Code IPC host"); + } + + private ensureInteractiveReady(): void { + this.ensureAttached(); + if (this.sessionSwitching || this.state === "syncing") throw new Error("Codex session switch is still in progress"); + } + + private emit(event: AgentEvent): void { + // Every relay event carries the latest typed execution projection. Keep + // the same values in payload for older consumers that only inspect the + // untyped event envelope. + const eventStatus = event.status ?? this.status; + const normalized: AgentEvent = { + ...event, + status: { ...eventStatus, activeFlags: [...eventStatus.activeFlags] }, + payload: { ...statusPayload(eventStatus), ...event.payload }, + }; + for (const listener of this.listeners) { + try { + listener(normalized); + } catch (error) { + this.options.logger?.warn?.("Codex IPC adapter listener failed", error); + } + } + } + + private toWireResponse(entry: RequestEntry, decision: "allow" | "deny" | "cancel", reason?: string, response?: JsonValue): JsonValue { + if (entry.method === "item/permissions/requestApproval") { + const supplied = isRecord(response) ? response : {}; + const requested = isRecord(supplied.permissions) ? supplied.permissions : decision === "allow" && isRecord(entry.params.permissions) ? entry.params.permissions : {}; + const permissions: JsonObject = {}; + for (const [key, value] of Object.entries(requested)) if (value !== null && value !== undefined) permissions[key] = asJsonValue(value); + return { permissions, scope: supplied.scope === "session" ? "session" : "turn", ...(typeof supplied.strictAutoReview === "boolean" ? { strictAutoReview: supplied.strictAutoReview } : {}) }; + } + if (entry.method === "item/tool/requestUserInput") { + return normalizeUserInputResponse(response); + } + if (entry.method === "mcpServer/elicitation/request") { + if (isRecord(response) && typeof response.action === "string") return response; + return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null }; + } + const suppliedDecision = isRecord(response) && Object.prototype.hasOwnProperty.call(response, "decision") ? response.decision : undefined; + if (suppliedDecision !== undefined) return normalizeFollowerDecision(entry.method, suppliedDecision, decision, reason); + if (entry.method === "applyPatchApproval" || entry.method === "execCommandApproval") { + if (decision === "allow") return "approved"; + if (decision === "cancel") return "abort"; + return { denied: { rejection: reason || "Denied remotely" } }; + } + return decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline"; + } +} + +function normalizeFollowerDecision(method: string, supplied: unknown, fallback: "allow" | "deny" | "cancel", reason?: string): JsonValue { + // The relay accepts compatibility aliases, while the private follower + // methods use the app-server's method-specific wire vocabulary. + if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") { + if (supplied === "approved" || supplied === "approved_for_session" || supplied === "approved_mcp_policy_amendment") { + return supplied === "approved" ? "accept" : supplied === "approved_for_session" ? "acceptForSession" : "accept"; + } + if (supplied === "denied" || supplied === "deny") return "decline"; + if (supplied === "abort") return "cancel"; + return asJsonValue(supplied); + } + if (method === "applyPatchApproval" || method === "execCommandApproval") { + if (supplied === "accept") return "approved"; + if (supplied === "acceptForSession") return "approved_for_session"; + if (supplied === "decline" || supplied === "deny") return { denied: { rejection: reason || "Denied remotely" } }; + if (supplied === "cancel") return "abort"; + return asJsonValue(supplied); + } + // If a caller supplied a generic wrapper with no method-specific alias, + // retain the ordinary fallback selected by the adapter. + return asJsonValue(supplied ?? (fallback === "allow" ? "accept" : fallback === "cancel" ? "cancel" : "decline")); +} + +/** Normalize the official tool-input shape: answers[id].answers is a string array. */ +function normalizeUserInputResponse(response: JsonValue | undefined): JsonObject { + const hasOuterAnswers = isRecord(response) && isRecord(response.answers); + const source = hasOuterAnswers ? response.answers as Record : isRecord(response) ? response : {}; + const answers: JsonObject = {}; + for (const [questionId, raw] of Object.entries(source)) { + if (isRecord(raw) && Array.isArray(raw.answers)) { + answers[questionId] = { answers: raw.answers.map(asJsonValue) }; + } else if (Array.isArray(raw)) { + answers[questionId] = { answers: raw.map(asJsonValue) }; + } else if (typeof raw === "string") { + answers[questionId] = { answers: [raw] }; + } else if (raw !== undefined && raw !== null) { + answers[questionId] = { answers: [asJsonValue(raw)] }; + } else { + answers[questionId] = { answers: [] }; + } + } + return { answers }; +} + +interface Candidate { + id: string; + mtime: number; + updatedAtMs?: number; + cwd?: string; + title?: string; + priority: number; +} + +async function recentVscodeThreadCandidates(root: string, preferredCwds: string[] = []): Promise { + const files: Array<{ file: string; mtime: number; id: string }> = []; + async function visit(directory: string, depth: number): Promise { + if (depth > 3) return; + let entries; + try { entries = await fs.readdir(directory, { withFileTypes: true }); } catch { return; } + await Promise.all(entries.map(async (entry) => { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) return visit(full, depth + 1); + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return; + // Codex has used UUIDv7 rollouts (the current hyphenated form), compact + // UUIDs, and 26-character ULIDs across desktop/VS Code builds. Keep the + // suffix strict so an arbitrary prompt-like filename cannot become a + // selectable session. + const match = entry.name.match(/(?:^|-)((?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[0-9a-z]{26}))(?:_[^/]*)?\.jsonl$/i); + if (!match) return; + try { const stat = await fs.stat(full); files.push({ file: full, mtime: stat.mtimeMs, id: match[1] }); } catch { /* race */ } + })); + } + await visit(root, 0); + files.sort((a, b) => b.mtime - a.mtime); + const result: Candidate[] = []; + // Rollout directories can contain many old sessions. Read metadata from a + // generous bounded set so an active VS Code session is not hidden merely + // because desktop history was written more recently. + for (const file of files.slice(0, 2_000)) { + try { + const firstLine = await readFirstLine(file.file); + let payload: Record = {}; + try { + const record = JSON.parse(firstLine) as unknown; + if (isRecord(record)) { + // Current rollouts wrap metadata in `payload`; older writers put the + // same fields on the first record itself. Accept only records that + // actually contain session identity fields so an arbitrary event + // record cannot become a false discovery candidate. + const candidate = isRecord(record.payload) ? record.payload : record; + if (["originator", "source", "thread_source", "cwd"].some((key) => Object.prototype.hasOwnProperty.call(candidate, key))) { + payload = candidate; + } + } + } catch { + // Incomplete rollout records are ignored below. A regex fallback is + // intentionally omitted so text inside a long prompt cannot identify + // an unrelated session as a VS Code conversation. + } + const originator = stringValue(payload.originator); + const source = stringValue(payload.source); + const threadSource = stringValue(payload.thread_source); + const cwd = stringValue(payload.cwd); + const title = stringValue(payload.title) + ?? stringValue(payload.thread_title) + ?? stringValue(payload.threadTitle) + ?? stringValue(payload.name) + ?? stringValue(payload.thread_name); + const updatedAtMs = timestampMs(payload.updated_at_ms) + ?? timestampMs(payload.updatedAtMs) + ?? timestampMs(payload.updated_at) + ?? timestampMs(payload.updatedAt) + ?? timestampMs(payload.timestamp); + // Subagent rollouts can use the same `codex_vscode` originator as their + // user-facing parent, but they are not conversations the operator opened. + if (threadSource === "subagent") continue; + // `source: vscode` is also written by Codex Desktop tasks hosted from a + // VS Code-shaped workspace. An explicit non-VS-Code originator must win + // so attach mode never follows a desktop task merely because it shares + // the same IPC router. Keep compatibility with older official rollouts + // that omitted originator altogether. + const official = originator === "codex_vscode"; + const unattributedVscode = !originator && (source === "vscode" || threadSource === "vscode"); + const isVscode = official || unattributedVscode; + if (!isVscode) continue; + // Never select a rollout produced by this bridge's legacy spawn mode. + if (originator === "codex-remote-collab") continue; + const cwdMatch = Boolean(cwd && preferredCwds.some((candidate) => samePath(candidate, cwd))); + const id = file.id; + // The bridge runs inside a VS Code workspace, so an owner in that + // workspace is a stronger signal than the rollout writer's originator. + const priority = cwdMatch ? (official ? 0 : 1) : (official ? 2 : 3); + if (!result.some((candidate) => candidate.id === id)) { + result.push({ + id, + mtime: file.mtime, + ...(updatedAtMs !== undefined ? { updatedAtMs } : {}), + ...(cwd ? { cwd } : {}), + ...(title ? { title } : {}), + priority, + }); + } + } catch { /* ignore incomplete/rotated rollout files */ } + } + result.sort((a, b) => a.priority - b.priority || b.mtime - a.mtime); + return result; +} + +/** Read enough of a rollout file to reach its first JSONL record, bounded. */ +async function readFirstLine(fileName: string, maxBytes = 4 * 1024 * 1024): Promise { + const handle = await fs.open(fileName, "r"); + const parts: Buffer[] = []; + let offset = 0; + try { + while (offset < maxBytes) { + const size = Math.min(64 * 1024, maxBytes - offset); + const chunk = Buffer.alloc(size); + const read = await handle.read(chunk, 0, size, offset); + if (!read.bytesRead) break; + const piece = chunk.subarray(0, read.bytesRead); + const newline = piece.indexOf(0x0a); + if (newline >= 0) { + parts.push(piece.subarray(0, newline)); + return Buffer.concat(parts).toString("utf8"); + } + parts.push(piece); + offset += read.bytesRead; + if (read.bytesRead < size) break; + } + return Buffer.concat(parts).toString("utf8"); + } finally { + await handle.close(); + } +} + +interface SessionIndexEntry { + title?: string; + updatedAtMs?: number; + cwd?: string; +} + +/** Read the bounded local index used by the official Codex recent-chat list. */ +async function readSessionIndex(fileName: string, maxBytes = 8 * 1024 * 1024): Promise> { + let raw: string; + try { + raw = await readBoundedText(fileName, maxBytes); + } catch { + return new Map(); + } + const result = new Map(); + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line) as unknown; + if (!isRecord(record)) continue; + const id = stringValue(record.id) ?? stringValue(record.session_id) ?? stringValue(record.thread_id); + if (!id) continue; + const title = stringValue(record.thread_name) + ?? stringValue(record.title) + ?? stringValue(record.name) + ?? stringValue(record.preview); + const updatedAtMs = timestampMs(record.updated_at_ms) + ?? timestampMs(record.updatedAtMs) + ?? timestampMs(record.updated_at) + ?? timestampMs(record.updatedAt) + ?? timestampMs(record.last_updated_at); + const cwd = stringValue(record.cwd) ?? stringValue(record.workspace) ?? stringValue(record.workspacePath); + result.set(id, { + ...(title ? { title } : {}), + ...(updatedAtMs !== undefined ? { updatedAtMs } : {}), + ...(cwd ? { cwd } : {}), + }); + } catch { + // A partially-written last line must not make the rest of the index + // unavailable. + } + } + return result; +} + +async function readBoundedText(fileName: string, maxBytes: number): Promise { + const handle = await fs.open(fileName, "r"); + const parts: Buffer[] = []; + let offset = 0; + try { + while (offset < maxBytes) { + const size = Math.min(64 * 1024, maxBytes - offset); + const chunk = Buffer.alloc(size); + const read = await handle.read(chunk, 0, size, offset); + if (!read.bytesRead) break; + parts.push(chunk.subarray(0, read.bytesRead)); + offset += read.bytesRead; + if (read.bytesRead < size) break; + } + return Buffer.concat(parts).toString("utf8"); + } finally { + await handle.close(); + } +} + +function sanitizeSessionTitle(value: string | undefined): string | undefined { + if (!value) return undefined; + const title = redactText(value).replace(/\s+/g, " ").trim().slice(0, 240); + return title || undefined; +} + +function samePath(left: string, right: string): boolean { + try { return path.resolve(left) === path.resolve(right); } catch { return left === right; } +} + + +function resolveCodexHome(options: CodexIpcAgentAdapterOptions): string { + const env = options.env ?? process.env; + const configured = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(options.homeDir ?? os.homedir(), ".codex"); + if (configured === "~") return options.homeDir ?? os.homedir(); + if (configured.startsWith("~/")) return path.join(options.homeDir ?? os.homedir(), configured.slice(2)); + return configured; +} + +function isMissingSessionOwnerError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /找不到会话\s+.+\s+的 VS Code Codex owner/.test(message) + || /no existing codex conversation owner is attached/i.test(message); +} + +function extractInput(params: JsonObject): string | JsonValue[] { + if (typeof params.text === "string") return params.text; + if (typeof params.message === "string") return params.message; + if (typeof params.prompt === "string") return params.prompt; + if (typeof params.input === "string") return params.input; + if (Array.isArray(params.input) && params.input.length) return params.input; + throw new Error("turn request requires text or a non-empty input array"); +} + +function pickTurnRequest(params: JsonObject): JsonObject { + const request: JsonObject = {}; + if (Array.isArray(params.attachments)) request.attachments = asJsonValue(params.attachments); + // The owner inherits all existing thread settings. Only forward fields that + // are part of the app-server turn/start request, never relay UI-only keys. + for (const key of ["model", "serviceTier", "effort", "summary", "personality", "collaborationMode", "approvalPolicy", "approvalsReviewer", "permissions", "sandboxPolicy", "runtimeWorkspaceRoots", "cwd", "outputSchema", "multiAgentMode"]) { + if (params[key] !== undefined) request[key] = asJsonValue(params[key]); + } + return request; +} + +/** + * The model picker changes durable next-turn settings, not one turn request. + * Accept the relay-friendly flat form and the official nested form while + * forwarding only the fields required by the picker. + */ +function pickThreadSettingsUpdate(params: JsonObject): JsonObject { + const source = isRecord(params.threadSettings) ? params.threadSettings : params; + const settings: JsonObject = {}; + + if (Object.prototype.hasOwnProperty.call(source, "model")) { + if (typeof source.model !== "string" || !source.model.trim()) { + throw new Error("thread settings model must be a non-empty string"); + } + settings.model = source.model.trim(); + } + + if (Object.prototype.hasOwnProperty.call(source, "effort")) { + if (source.effort !== null && (typeof source.effort !== "string" || !source.effort.trim())) { + throw new Error("thread settings effort must be a non-empty string or null"); + } + settings.effort = typeof source.effort === "string" ? source.effort.trim() : null; + } + + if (Object.prototype.hasOwnProperty.call(source, "multiAgentMode")) { + if (source.multiAgentMode !== null && typeof source.multiAgentMode !== "string") { + throw new Error("thread settings multiAgentMode must be a string or null"); + } + settings.multiAgentMode = asJsonValue(source.multiAgentMode); + } + + // The official permissions control updates the same durable thread + // settings envelope as the model picker. Keep the projection explicit so a + // browser cannot smuggle arbitrary UI state into the owner request, while + // retaining object-valued policies used by newer app-server builds. + for (const key of ["sandboxPolicy", "approvalPolicy"] as const) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const value = source[key]; + if (value !== null && typeof value !== "string" && !isRecord(value)) { + throw new Error(`thread settings ${key} must be a string, object, or null`); + } + settings[key] = asJsonValue(value); + } + if (Object.prototype.hasOwnProperty.call(source, "approvalsReviewer")) { + const value = source.approvalsReviewer; + if (value !== null && typeof value !== "string") { + throw new Error("thread settings approvalsReviewer must be a string or null"); + } + settings.approvalsReviewer = asJsonValue(value); + } + if (Object.prototype.hasOwnProperty.call(source, "runtimeWorkspaceRoots")) { + const value = source.runtimeWorkspaceRoots; + if (value !== null && (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))) { + throw new Error("thread settings runtimeWorkspaceRoots must be an array of strings or null"); + } + settings.runtimeWorkspaceRoots = asJsonValue(value); + } + if (Object.prototype.hasOwnProperty.call(source, "permissions")) { + const value = source.permissions; + if (value !== null && typeof value !== "string" && !isRecord(value)) { + throw new Error("thread settings permissions must be a string, object, or null"); + } + settings.permissions = asJsonValue(value); + } + + if (Object.keys(settings).length === 0) { + throw new Error("thread settings update requires model, effort, multiAgentMode, sandboxPolicy, approvalPolicy, permissions, or approvalsReviewer"); + } + return settings; +} + +function pickTurnContext(params: JsonObject): JsonObject { + const context = isRecord(params.context) ? asJsonObject(params.context) : {}; + if (context.inheritThreadSettings === undefined) context.inheritThreadSettings = true; + if (Array.isArray(params.commentAttachments)) context.commentAttachments = asJsonValue(params.commentAttachments); + if (Array.isArray(params.mcpAppModelContextAttachments)) context.mcpAppModelContextAttachments = asJsonValue(params.mcpAppModelContextAttachments); + return context; +} + +function unwrapFollowerResult(value: JsonValue | undefined): JsonValue { + if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, "result")) return asJsonValue(value.result); + return asJsonValue(value); +} + +function extractRevision(value: unknown): number | undefined { + const direct = numberValue(value); + if (direct !== undefined) return direct; + if (!isRecord(value)) return undefined; + for (const key of ["revision", "streamRevision", "stateRevision"]) { + const revision = numberValue(value[key]); + if (revision !== undefined) return revision; + } + return Object.prototype.hasOwnProperty.call(value, "result") ? extractRevision(value.result) : undefined; +} + +function extractTurnId(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + if (typeof value.turnId === "string") return value.turnId; + if (isRecord(value.turn) && typeof value.turn.id === "string") return value.turn.id; + if (isRecord(value.result)) return extractTurnId(value.result); + return undefined; +} + +function deriveTurn(state: JsonObject): TurnInfo { + const candidates: TurnInfo[] = []; + const turns = Array.isArray(state.turns) ? state.turns : []; + for (const value of turns) if (isRecord(value)) candidates.push(turnInfo(value)); + const history = isRecord(state.turnHistory) && isRecord(state.turnHistory.history) && isRecord(state.turnHistory.history.entitiesByKey) + ? Object.values(state.turnHistory.history.entitiesByKey) : []; + for (const value of history) if (isRecord(value) && (value.turnId !== undefined || value.status !== undefined || value.items !== undefined)) candidates.push(turnInfo(value)); + const runtime = isRecord(state.threadRuntimeStatus) ? state.threadRuntimeStatus : undefined; + const runtimeType = normalizeStatus(runtime?.type); + const active = candidates.filter((entry) => entry.active).sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); + if (active[0]) return active[0]; + const selected = candidates.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0))[0]; + if (runtime && runtimeType && runtimeType !== "idle" && runtimeType !== "ready" && typeof runtime.turnId === "string") { + return { id: runtime.turnId, status: runtimeType, active: true }; + } + return selected ?? { status: runtimeType || "idle", active: false }; +} + +function turnInfo(value: Record): TurnInfo { + const id = typeof value.id === "string" ? value.id : typeof value.turnId === "string" ? value.turnId : undefined; + const status = normalizeStatus(typeof value.status === "string" ? value.status : isRecord(value.status) && typeof value.status.type === "string" ? value.status.type : "unknown"); + const startedAt = timestampMs(value.turnStartedAtMs) + ?? timestampMs(value.startedAtMs) + ?? timestampMs(value.startedAt) + ?? timestampMs(value.createdAtMs) + ?? timestampMs(value.createdAt); + const durationMs = numberValue(value.durationMs) ?? numberValue(value.duration); + const completedAtMs = timestampMs(value.completedAtMs) ?? timestampMs(value.completedAt); + const commandStarts = isRecord(value.commandExecutionStartedAtMsById) + ? value.commandExecutionStartedAtMsById + : {}; + const items = Array.isArray(value.items) ? value.items.filter(isRecord) : []; + const firstTurnWorkItemStartedAtMs = timestampMs(value.firstTurnWorkItemStartedAtMs) + ?? timestampMs(value.firstWorkItemStartedAtMs) + ?? timestampMs(value.firstTurnWorkItemStartedAt) + ?? inferFirstWorkItemStartedAtMs(items, commandStarts); + const finalAssistantStartedAtMs = timestampMs(value.finalAssistantStartedAtMs) + ?? timestampMs(value.finalAssistantStartedAt) + ?? inferFinalAssistantStartedAtMs(items, commandStarts); + const active = !TERMINAL_TURN_STATES.has(status) && status !== "idle" && status !== "unknown"; + const explicitWorkedDurationMs = numberValue(value.workedDurationMs) + ?? numberValue(value.workDurationMs) + ?? numberValue(value.workDuration); + const workedCompletedAtMs = finalAssistantStartedAtMs + ?? (!active && completedAtMs !== undefined ? completedAtMs : undefined); + const workedDurationMs = explicitWorkedDurationMs + ?? (firstTurnWorkItemStartedAtMs !== undefined && workedCompletedAtMs !== undefined + ? Math.max(0, workedCompletedAtMs - firstTurnWorkItemStartedAtMs) + : undefined); + return { + id, + status, + active, + ...(startedAt !== undefined ? { startedAt } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(workedDurationMs !== undefined ? { workedDurationMs } : {}), + ...(completedAtMs !== undefined ? { completedAtMs } : {}), + ...(firstTurnWorkItemStartedAtMs !== undefined ? { firstTurnWorkItemStartedAtMs } : {}), + ...(finalAssistantStartedAtMs !== undefined ? { finalAssistantStartedAtMs } : {}), + ...(value.error !== undefined ? { error: asJsonValue(value.error) } : {}), + raw: value, + }; +} + +/** + * Infer the timestamps used by the official "worked for" row when an older + * conversation snapshot does not carry the denormalized turn fields. Persisted + * rollout records use snake_case command metadata, so both spellings are + * intentionally accepted here. + */ +function inferFirstWorkItemStartedAtMs( + items: Record[], + commandStarts: Record, +): number | undefined { + for (const item of items) { + if (isNonWorkItem(item)) continue; + const id = stringValue(item.id); + const timestamp = itemTimestamp(item) + ?? (id ? timestampMs(commandStarts[id]) : undefined); + if (timestamp !== undefined) return timestamp; + } + return undefined; +} + +function inferFinalAssistantStartedAtMs( + items: Record[], + commandStarts: Record, +): number | undefined { + let fallback: number | undefined; + for (const item of items) { + if (!isAssistantItem(item)) continue; + const id = stringValue(item.id); + const timestamp = itemTimestamp(item) + ?? (id ? timestampMs(commandStarts[id]) : undefined); + if (timestamp === undefined) continue; + fallback = timestamp; + const phase = normalizeStatus(item.phase); + if (phase === "final_answer" || phase === "finalanswer") return timestamp; + } + return fallback; +} + +function itemTimestamp(item: Record): number | undefined { + return timestampMs(item.startedAtMs) + ?? timestampMs(item.started_at_ms) + ?? timestampMs(item.startedAt) + ?? timestampMs(item.started_at) + ?? timestampMs(item.createdAtMs) + ?? timestampMs(item.created_at_ms) + ?? timestampMs(item.createdAt) + ?? timestampMs(item.created_at); +} + +function normalizedItemType(item: Record): string { + return String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); +} + +function isAssistantItem(item: Record): boolean { + const type = normalizedItemType(item); + return type === "agentmessage" || type === "assistantmessage"; +} + +function isNonWorkItem(item: Record): boolean { + const type = normalizedItemType(item); + return type === "usermessage" + || type === "steeringusermessage" + || type === "realtimetranscript" + || type === "worktreeinit" + || type === "sleep"; +} + +/** + * Normalize the official turn/runtime records into a stable status shape for + * relay clients. This intentionally tolerates both raw webview state + * (`turnStartedAtMs`, `inProgress`) and serialized history summaries + * (`startedAt`, `completed`) used by different extension versions. + */ +function deriveStatusSnapshot(state: JsonObject, turn: TurnInfo, requests: RequestEntry[]): AgentStatusSnapshot { + const runtime = isRecord(state.threadRuntimeStatus) ? state.threadRuntimeStatus : undefined; + const activeFlags = Array.isArray(runtime?.activeFlags) + ? runtime.activeFlags.filter((flag): flag is string => typeof flag === "string") + : []; + const activity = classifyActivity(turn, activeFlags, requests, runtime); + const startedAtMs = turn.startedAt ?? null; + const durationMs = turn.durationMs + ?? (!turn.active && turn.completedAtMs !== undefined && turn.completedAtMs !== null && turn.startedAt !== undefined + ? Math.max(0, turn.completedAtMs - turn.startedAt) + : null); + // The official worked-for indicator starts at the first actual work item, + // not at the user-message/turn start, and ends when the final assistant + // message begins. Keep this separate from the broader turn duration. + const workStartedAtMs = turn.firstTurnWorkItemStartedAtMs ?? turn.startedAt ?? undefined; + const workedCompletedAtMs = turn.finalAssistantStartedAtMs + ?? (!turn.active ? turn.completedAtMs : undefined) + ?? undefined; + const workedDurationMs = turn.workedDurationMs + ?? (workStartedAtMs !== undefined && workedCompletedAtMs !== undefined + ? Math.max(0, workedCompletedAtMs - workStartedAtMs) + : turn.active && workStartedAtMs !== undefined + ? Math.max(0, Date.now() - workStartedAtMs) + : null); + const elapsedMs = turn.active && workStartedAtMs !== undefined + ? Math.max(0, Date.now() - workStartedAtMs) + : durationMs; + const status: AgentStatusSnapshot = { + activity, + turnStatus: turn.status, + activeFlags, + startedAtMs, + durationMs, + workedDurationMs, + elapsedMs, + firstTurnWorkItemStartedAtMs: turn.firstTurnWorkItemStartedAtMs ?? null, + finalAssistantStartedAtMs: turn.finalAssistantStartedAtMs ?? null, + ...(turn.error !== undefined ? { error: turn.error } : {}), + }; + return status; +} + +function classifyActivity( + turn: TurnInfo, + activeFlags: string[], + requests: RequestEntry[], + runtime?: Record, +): string { + const flags = new Set(activeFlags.map((flag) => normalizeStatus(flag))); + if (flags.has("waiting_on_approval") || flags.has("waiting_for_approval") || flags.has("waitingonapproval") || requests.some((entry) => Boolean(entry.approval))) { + return "waiting_approval"; + } + if (flags.has("waiting_on_user_input") || flags.has("waiting_for_user_input") || flags.has("waitingonuserinput") || requests.some((entry) => INPUT_METHODS.has(entry.method))) { + return "waiting_input"; + } + if (!turn.active) return terminalActivity(turn.status); + + const item = latestActiveWorkItem(turn.raw); + if (item) { + const type = normalizeStatus(item.type ?? item.kind ?? ""); + if (type === "read" || type.includes("fileread") || type.includes("readfile") || readPathsFromActions(commandActionsValue(item)).length > 0) return "reading"; + if (type.includes("filechange") || type.includes("file_change") || type.includes("patch") || type.includes("edit")) return "editing"; + if (type.includes("reasoning") || type.includes("think")) return "thinking"; + if (type.includes("command") || type.includes("exec") || type.includes("process") || type.includes("tool")) return "running"; + const phase = normalizeStatus(item.phase); + if (phase.includes("reason") || phase.includes("think")) return "thinking"; + } + + const runtimeType = normalizeStatus(runtime?.type); + if (runtimeType.includes("reason") || runtimeType.includes("think")) return "thinking"; + if (runtimeType.includes("edit") || runtimeType.includes("patch")) return "editing"; + return "running"; +} + +function latestActiveWorkItem(turn?: Record): Record | undefined { + if (!turn || !Array.isArray(turn.items)) return undefined; + for (let index = turn.items.length - 1; index >= 0; index -= 1) { + const item = turn.items[index]; + if (!isRecord(item)) continue; + const status = normalizeStatus(isRecord(item.status) ? item.status.type : item.status); + if (status && status !== "unknown" && TERMINAL_TURN_STATES.has(status)) continue; + return item; + } + return undefined; +} + +function terminalActivity(status: string): string { + const normalized = normalizeStatus(status); + if (normalized === "failed" || normalized === "error") return "failed"; + if (normalized === "cancelled" || normalized === "canceled" || normalized === "interrupted") return "interrupted"; + if (normalized === "completed" || normalized === "complete" || normalized === "done") return "completed"; + return normalized === "idle" || normalized === "ready" ? "idle" : "idle"; +} + +function statusPayload(status: AgentStatusSnapshot): JsonObject { + return { + // `status` is the legacy scalar alias; `turnStatus` retains the explicit + // name so clients can distinguish it from the coarse `activity` value. + status: status.turnStatus, + turnStatus: status.turnStatus, + activity: status.activity, + activeFlags: asJsonValue(status.activeFlags), + startedAtMs: status.startedAtMs ?? null, + durationMs: status.durationMs ?? null, + workedDurationMs: status.workedDurationMs ?? null, + elapsedMs: status.elapsedMs ?? null, + firstTurnWorkItemStartedAtMs: status.firstTurnWorkItemStartedAtMs ?? null, + finalAssistantStartedAtMs: status.finalAssistantStartedAtMs ?? null, + ...(status.error !== undefined ? { error: status.error } : {}), + }; +} + +function sameStatus(a: AgentStatusSnapshot, b: AgentStatusSnapshot): boolean { + return a.activity === b.activity + && a.turnStatus === b.turnStatus + && JSON.stringify(a.activeFlags) === JSON.stringify(b.activeFlags) + && a.startedAtMs === b.startedAtMs + && a.durationMs === b.durationMs + && a.workedDurationMs === b.workedDurationMs + && a.firstTurnWorkItemStartedAtMs === b.firstTurnWorkItemStartedAtMs + && a.finalAssistantStartedAtMs === b.finalAssistantStartedAtMs + && JSON.stringify(a.error) === JSON.stringify(b.error); +} + +function timestampMs(value: unknown): number | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(trimmed)) { + const number = Number(trimmed); + if (Number.isFinite(number)) { + return number > 0 && number < 1_000_000_000_000 ? number * 1000 : number; + } + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + const number = numberValue(value); + if (number === undefined) return undefined; + // Serialized history in older extension builds uses epoch seconds while + // the live turn fields end in `AtMs`. Normalize both to milliseconds. + return number > 0 && number < 1_000_000_000_000 ? number * 1000 : number; +} + +function extractRequests(state: JsonObject, approvalTimeoutMs: number): RequestEntry[] { + const values: Array<{ id?: JsonRpcId; value: Record }> = []; + const seenRecords = new Set>(); + const collect = (raw: unknown, hintedId?: JsonRpcId, depth = 0): void => { + if (depth > 6 || raw === null || raw === undefined) return; + if (Array.isArray(raw)) { + for (const value of raw) collect(value, undefined, depth + 1); + return; + } + if (!isRecord(raw)) return; + if (seenRecords.has(raw)) return; + seenRecords.add(raw); + const nested = isRecord(raw.request) ? raw.request : raw; + const method = requestMethodOf(nested) ?? requestMethodOf(raw); + const requestId = requestIdOf(nested) ?? requestIdOf(raw) ?? hintedId; + if (method && isJsonRpcId(requestId)) values.push({ id: requestId, value: raw }); + // Official conversation snapshots can retain pending records inside a + // turn item (permission-request/userInput/mcp-server-elicitation), while + // older builds expose the same records under `requests`. Traverse only + // protocol containers so arbitrary message content is never interpreted as + // an approval request. + for (const key of ["requests", "pendingRequests", "pendingApprovals", "turns", "turnHistory", "history", "items", "request"]) { + const child = raw[key]; + if (child === undefined) continue; + if (isRecord(child) && !Array.isArray(child)) { + for (const [childKey, value] of Object.entries(child)) { + collect(value, isJsonRpcId(childKey) ? childKey : undefined, depth + 1); + } + } else collect(child, undefined, depth + 1); + } + }; + collect(state); + const result: RequestEntry[] = []; + const seenRequests = new Set(); + for (const item of values) { + const request = isRecord(item.value.request) ? item.value.request : item.value; + const requestId = item.id ?? requestIdOf(request); + const method = requestMethodOf(request) ?? requestMethodOf(item.value); + if (!isJsonRpcId(requestId) || !method) continue; + const dedupeKey = `${jsonRpcIdKey(requestId)}\u001f${method}`; + if (seenRequests.has(dedupeKey)) continue; + seenRequests.add(dedupeKey); + const params = asJsonObject(request.params ?? item.value.params ?? (request === item.value ? item.value : {})); + // `params.startedAtMs` is the app-server's authoritative timestamp. The + // outer fields are compatibility fallbacks for older normalized snapshots + // and can represent when a UI record was inserted rather than when the + // approval actually started. + const createdAt = timestampMs(params.startedAtMs) + ?? timestampMs(params.started_at_ms) + ?? timestampMs(params.startedAt) + ?? timestampMs(params.started_at) + ?? timestampMs(request.startedAtMs) + ?? timestampMs(request.started_at_ms) + ?? timestampMs(request.startedAt) + ?? timestampMs(request.started_at) + ?? timestampMs(item.value.startedAtMs) + ?? timestampMs(item.value.started_at_ms) + ?? timestampMs(item.value.startedAt) + ?? timestampMs(item.value.started_at) + ?? timestampMs(item.value.createdAtMs) + ?? timestampMs(item.value.created_at_ms) + ?? timestampMs(item.value.createdAt) + ?? timestampMs(item.value.created_at) + ?? Date.now(); + const expiresAt = timestampMs(item.value.expiresAtMs) + ?? timestampMs(item.value.expires_at_ms) + ?? timestampMs(item.value.expiresAt) + ?? timestampMs(item.value.expires_at) + ?? timestampMs(request.expiresAtMs) + ?? timestampMs(request.expires_at_ms) + ?? timestampMs(request.expiresAt) + ?? timestampMs(request.expires_at) + ?? timestampMs(params.expiresAtMs) + ?? timestampMs(params.expires_at_ms) + ?? timestampMs(params.expiresAt) + ?? timestampMs(params.expires_at) + ?? (approvalTimeoutMs > 0 ? createdAt + approvalTimeoutMs : undefined); + const entry: RequestEntry = { + requestId, + method, + params, + threadId: stringValue(params.threadId) ?? stringValue(params.conversationId), + turnId: stringValue(params.turnId), + createdAt, + ...(expiresAt ? { expiresAt } : {}), + }; + if (APPROVAL_METHODS.has(method)) entry.approval = toPendingApproval(entry); + result.push(entry); + } + return result; +} + +function requestMethodOf(value: Record): string | undefined { + if (typeof value.method === "string" && value.method) return value.method; + const type = String(value.type ?? value.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); + if (type.includes("permissionrequest")) return "item/permissions/requestApproval"; + if (type.includes("commandexecutionrequest") || type === "execapproval") return "item/commandExecution/requestApproval"; + if (type.includes("filechangerequest") || type === "patchapproval") return "item/fileChange/requestApproval"; + if (type === "exec" && value.approvalRequestId !== undefined && (!isRecord(value.output) || value.output.exitCode === undefined)) return "execCommandApproval"; + if (type === "patch" && value.approvalRequestId !== undefined && value.success === undefined) return "applyPatchApproval"; + if (type.includes("userinput") && value.completed !== true) return "item/tool/requestUserInput"; + if (type.includes("mcpserverelicitation") && value.completed !== true) return "mcpServer/elicitation/request"; + return undefined; +} + +function requestIdOf(value: Record): JsonRpcId | undefined { + const id = value.requestId ?? value.id; + return isJsonRpcId(id) ? id : undefined; +} + +function toPendingApproval(entry: RequestEntry): PendingApproval { + const command = typeof entry.params.command === "string" ? entry.params.command : extractCommand(entry.params); + const action = entry.method.includes("fileChange") || entry.method === "applyPatchApproval" ? "file.change" : entry.method.includes("permissions") ? "permissions.grant" : "command.execution"; + const risk: PendingApproval["risk"] = entry.method.includes("permissions") + ? "high" + : entry.method.includes("command") || entry.method === "execCommandApproval" + ? (!command || /(?:rm\s+-rf|sudo|curl|wget|ssh|password|token|secret)/i.test(command) ? "high" : "medium") + : "medium"; + const summary = stringValue(entry.params.reason) ?? command ?? `${action} requested by Codex`; + return { + requestId: entry.requestId, + method: entry.method, + threadId: entry.threadId, + turnId: entry.turnId, + itemId: stringValue(entry.params.itemId) ?? stringValue(entry.params.callId), + action, + risk, + summary: redactText(summary), + commandHash: hashJson(entry.params), + createdAt: entry.createdAt, + ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}), + payload: redactJson(entry.params) as JsonObject, + }; +} + +function extractCommand(params: JsonObject): string | undefined { + if (Array.isArray(params.command)) return params.command.filter((value): value is string => typeof value === "string").join(" "); + const actions = params.commandActions ?? params.command_actions ?? params.parsedCmd ?? params.parsed_cmd; + const actionList = commandActionList(actions); + if (actionList.length) { + const commands = actionList.map(commandActionText).filter((value): value is string => Boolean(value)); + return commands.length ? commands.join(" && ") : undefined; + } + return undefined; +} + +interface RenderedConversationMessage { + id?: string; + turnId?: string; + itemId?: string; + role: "user" | "assistant" | "reasoning" | "tool" | "error"; + kind: "user" | "assistant" | "reasoning" | "plan" | "tool" | "edit" | "error"; + text: string; + label?: string; + itemType?: string; + status?: string; + turnStatus?: string; + startedAtMs?: number; + completedAtMs?: number; + durationMs?: number; + /** Duration of the official worked-for activity group for this turn. */ + workedDurationMs?: number; + command?: string; + /** Parsed command actions emitted by the official command renderer. */ + commandActions?: JsonValue[]; + cwd?: string | null; + shellName?: string | null; + exitCode?: number; + phase?: string; + breaksPreviousAdjacency?: boolean; + /** Official collabAgentToolCall projection. */ + action?: string; + senderThreadId?: string; + receiverThreadIds?: string[]; + /** Official webview compatibility alias. */ + receiverThreads?: string[]; + prompt?: string | null; + model?: string | null; + reasoningEffort?: string | null; + agentsStates?: JsonObject; + /** Official subAgentActivity projection. */ + agentThreadId?: string; + agentPath?: string; + displayName?: string | null; + displayStatus?: string; + activityKind?: string; + /** Semantic name used by the official webview converter. */ + uiType?: string; + /** Friendly paths extracted from a parsed `read` command action. */ + readPaths?: string[]; + /** Raw tool/file output kept separate from the compact activity summary. */ + output?: string; +} + +interface ItemDisplayProjection { + outputText: string; + projectionId?: string; + startedAtMs?: number; + completedAtMs?: number; + durationMs?: number; + workedDurationMs?: number; + role: RenderedConversationMessage["role"]; + kind: RenderedConversationMessage["kind"]; + text: string; + label?: string; + itemType?: string; + status?: string; + command?: string; + commandActions?: JsonValue[]; + cwd?: string | null; + shellName?: string | null; + action?: string; + senderThreadId?: string; + receiverThreadIds?: string[]; + receiverThreads?: string[]; + prompt?: string | null; + model?: string | null; + reasoningEffort?: string | null; + agentsStates?: JsonObject; + agentThreadId?: string; + agentPath?: string; + displayName?: string | null; + displayStatus?: string; + activityKind?: string; + uiType?: string; + readPaths?: string[]; + output?: string; +} + +function renderedMessageShape(messages: RenderedConversationMessage[]): string { + return messages.map((message, index) => [ + message.id ?? `index:${index}`, + message.turnId ?? "", + message.itemId ?? "", + message.role, + message.kind, + message.itemType ?? "", + message.text, + message.label ?? "", + message.status ?? "", + message.turnStatus ?? "", + message.startedAtMs ?? "", + message.completedAtMs ?? "", + message.durationMs ?? "", + message.workedDurationMs ?? "", + message.command ?? "", + JSON.stringify(message.commandActions ?? []), + message.cwd ?? "", + message.shellName ?? "", + message.exitCode ?? "", + message.phase ?? "", + message.action ?? "", + message.senderThreadId ?? "", + JSON.stringify(message.receiverThreadIds ?? []), + message.prompt ?? "", + message.model ?? "", + message.reasoningEffort ?? "", + JSON.stringify(message.agentsStates ?? {}), + message.agentThreadId ?? "", + message.agentPath ?? "", + message.displayName ?? "", + message.displayStatus ?? "", + message.activityKind ?? "", + message.uiType ?? "", + JSON.stringify(message.readPaths ?? []), + message.output ?? "", + message.breaksPreviousAdjacency ? "break" : "", + ].join("\u001f")).join("\u001e"); +} + +/** + * Encode a suffix replacement instead of repeating the complete structured + * history on every streaming text patch. Initial/output snapshots still carry + * the full projection, so reconnect and late-join hydration stay lossless. + */ +function renderedMessagesPatch( + previous: RenderedConversationMessage[], + next: RenderedConversationMessage[], +): JsonObject | undefined { + const sharedLength = Math.min(previous.length, next.length); + let start = 0; + while (start < sharedLength + && stableStringify(asJsonValue(previous[start])) === stableStringify(asJsonValue(next[start]))) start += 1; + if (start === previous.length && start === next.length) return undefined; + return { + start, + deleteCount: previous.length - start, + messages: asJsonValue(next.slice(start)), + }; +} + +function renderedSubagentShape(subagents: SubagentSnapshot[]): string { + return JSON.stringify(subagents); +} + +function renderConversationOutput(state: JsonObject, maxChars: number): { text: string; totalLength: number; truncated: boolean; messages: RenderedConversationMessage[]; subagents: SubagentSnapshot[] } { + const chunks: string[] = []; + const messages: RenderedConversationMessage[] = []; + const seen = new Map(); + const add = (text: string, id?: string, message?: RenderedConversationMessage): void => { + const safe = redactText(text); + if (!safe) return; + if (id && seen.has(id)) { + // The same turn is commonly present in both the canonical history and + // the active-page list. Keep the latest item text when a streaming item + // was updated, instead of dropping the active-page update entirely. + const position = seen.get(id) as number; + chunks[position] = safe; + if (message) messages[position] = { ...message, text: message.text ? redactText(message.text) : safe }; + return; + } + if (id) seen.set(id, chunks.length); + chunks.push(safe); + if (message) messages.push({ ...message, text: redactText(message.text || safe) }); + }; + const consumeTurn = (turn: Record): void => { + const turnKey = stringValue(turn.id) ?? stringValue(turn.turnId); + const turnStatus = statusValue(turn.status); + const turnStartedAtMs = timestampMs(turn.turnStartedAtMs) + ?? timestampMs(turn.startedAtMs) + ?? timestampMs(turn.startedAt) + ?? timestampMs(turn.createdAtMs) + ?? timestampMs(turn.createdAt); + const turnDurationMs = numberValue(turn.durationMs) ?? numberValue(turn.duration); + const firstTurnWorkItemStartedAtMs = timestampMs(turn.firstTurnWorkItemStartedAtMs) + ?? timestampMs(turn.firstWorkItemStartedAtMs) + ?? timestampMs(turn.firstTurnWorkItemStartedAt); + const finalAssistantStartedAtMs = timestampMs(turn.finalAssistantStartedAtMs) + ?? timestampMs(turn.finalAssistantStartedAt); + const commandStarts = isRecord(turn.commandExecutionStartedAtMsById) + ? turn.commandExecutionStartedAtMsById + : {}; + const items = Array.isArray(turn.items) ? turn.items.filter(isRecord) : []; + const inferredFirstWorkItemStartedAtMs = firstTurnWorkItemStartedAtMs + ?? inferFirstWorkItemStartedAtMs(items, commandStarts); + const inferredFinalAssistantStartedAtMs = finalAssistantStartedAtMs + ?? inferFinalAssistantStartedAtMs(items, commandStarts); + const workedCompletedAtMs = inferredFinalAssistantStartedAtMs + ?? (!TERMINAL_TURN_STATES.has(normalizeStatus(turnStatus)) + ? undefined + : timestampMs(turn.completedAtMs) ?? timestampMs(turn.completedAt)); + const workedDurationMs = numberValue(turn.workedDurationMs) + ?? numberValue(turn.workDurationMs) + ?? (inferredFirstWorkItemStartedAtMs !== undefined && workedCompletedAtMs !== undefined + ? Math.max(0, workedCompletedAtMs - inferredFirstWorkItemStartedAtMs) + : undefined); + // A turn may append bookkeeping/tool records after the final assistant + // item. Identify the final assistant from the rendered assistant records, + // with an explicit final-answer phase taking precedence over chronology. + const itemDisplays = items.map((item) => itemDisplayVariants(item)); + let finalAssistantIndex = -1; + let explicitFinalAssistantIndex = -1; + itemDisplays.forEach((displays, index) => { + if (!displays.some((display) => display.role === "assistant")) return; + finalAssistantIndex = index; + const phase = typeof items[index].phase === "string" + ? normalizeStatus(items[index].phase) + : ""; + if (phase === "final_answer" || phase === "finalanswer") explicitFinalAssistantIndex = index; + }); + if (explicitFinalAssistantIndex >= 0) finalAssistantIndex = explicitFinalAssistantIndex; + items.forEach((item, index) => { + const displays = itemDisplays[index]; + if (!displays.length) return; + const rawItemId = typeof item.id === "string" ? item.id : undefined; + const startedAtMs = timestampMs(item.startedAtMs) + ?? timestampMs(item.startedAt) + ?? (rawItemId ? timestampMs(commandStarts[rawItemId]) : undefined); + const durationMs = numberValue(item.durationMs) ?? numberValue(item.duration); + const completedAtMs = timestampMs(item.completedAtMs) + ?? timestampMs(item.finishedAtMs) + ?? timestampMs(item.completedAt) + ?? (startedAtMs !== undefined && durationMs !== undefined ? startedAtMs + durationMs : undefined); + const command = commandText(item); + const phase = typeof item.phase === "string" ? item.phase : undefined; + displays.forEach((display, displayIndex) => { + const projectionId = display.projectionId ?? rawItemId; + const itemId = projectionId + ? `id:${projectionId}` + : turnKey + ? `turn:${turnKey}:${index}:${displayIndex}` + : `raw:${JSON.stringify(item)}:${displayIndex}`; + const isFinalAssistant = display.role === "assistant" + && (phase === "final_answer" || phase === "final-answer" || index === finalAssistantIndex); + // User items do not carry their own timestamp in several official + // snapshots. Associate them with the turn start; likewise associate the + // final assistant item with the turn's final-answer start and duration. + const effectiveStartedAtMs = display.startedAtMs ?? startedAtMs + ?? (display.role === "user" ? turnStartedAtMs : undefined) + ?? (display.role === "reasoning" ? inferredFirstWorkItemStartedAtMs : undefined) + ?? (isFinalAssistant ? inferredFinalAssistantStartedAtMs : undefined); + const effectiveDurationMs = display.durationMs ?? durationMs + ?? (isFinalAssistant ? turnDurationMs : undefined); + const effectiveCompletedAtMs = display.completedAtMs ?? completedAtMs + ?? (effectiveStartedAtMs !== undefined && effectiveDurationMs !== undefined + ? effectiveStartedAtMs + effectiveDurationMs + : undefined); + const itemStatus = display.status ?? statusValue(item.status) + ?? (item.completed === true ? "completed" : item.completed === false ? "in_progress" : undefined); + const displayCommand = display.command ?? (displayIndex === 0 ? command : undefined); + add(display.outputText, itemId, { + id: itemId, + ...(turnKey ? { turnId: turnKey } : {}), + ...(projectionId ? { itemId: projectionId } : {}), + role: display.role, + kind: display.kind, + text: display.text, + ...(display.label ? { label: display.label } : {}), + ...(display.itemType ? { itemType: display.itemType } : typeof item.type === "string" ? { itemType: item.type } : typeof item.kind === "string" ? { itemType: item.kind } : {}), + ...(itemStatus ? { status: itemStatus } : {}), + ...(turnStatus ? { turnStatus } : {}), + ...(effectiveStartedAtMs !== undefined ? { startedAtMs: effectiveStartedAtMs } : {}), + ...(effectiveCompletedAtMs !== undefined ? { completedAtMs: effectiveCompletedAtMs } : {}), + ...(effectiveDurationMs !== undefined ? { durationMs: effectiveDurationMs } : {}), + ...(workedDurationMs !== undefined ? { workedDurationMs } : {}), + ...(displayCommand ? { command: displayCommand } : {}), + ...(display.commandActions?.length ? { commandActions: display.commandActions } : {}), + ...(display.cwd !== undefined ? { cwd: display.cwd } : {}), + ...(display.shellName !== undefined ? { shellName: display.shellName } : {}), + ...(numberValue(item.exitCode) !== undefined && displayIndex === 0 ? { exitCode: numberValue(item.exitCode) as number } : {}), + ...(phase && displayIndex === 0 ? { phase } : {}), + ...(display.action ? { action: display.action } : {}), + ...(display.senderThreadId ? { senderThreadId: display.senderThreadId } : {}), + ...(display.receiverThreadIds ? { receiverThreadIds: display.receiverThreadIds, receiverThreads: display.receiverThreads ?? display.receiverThreadIds } : {}), + ...(display.prompt !== undefined ? { prompt: display.prompt } : {}), + ...(display.model !== undefined ? { model: display.model } : {}), + ...(display.reasoningEffort !== undefined ? { reasoningEffort: display.reasoningEffort } : {}), + ...(display.agentsStates ? { agentsStates: display.agentsStates } : {}), + ...(display.agentThreadId ? { agentThreadId: display.agentThreadId } : {}), + ...(display.agentPath ? { agentPath: display.agentPath } : {}), + ...(display.displayName !== undefined ? { displayName: display.displayName } : {}), + ...(display.displayStatus ? { displayStatus: display.displayStatus } : {}), + ...(display.activityKind ? { activityKind: display.activityKind } : {}), + ...(display.uiType ? { uiType: display.uiType } : {}), + ...(display.readPaths?.length ? { readPaths: display.readPaths } : {}), + ...(display.output ? { output: redactText(display.output) } : {}), + ...(item.breaksPreviousAdjacency === true ? { breaksPreviousAdjacency: true } : {}), + }); + }); + }); + }; + // Canonical history islands carry the stable chronological order. The + // lightweight `turns` list is usually just the active page, so append only + // entities that are not already represented there. + for (const turn of orderedHistoryTurns(state)) consumeTurn(turn); + if (Array.isArray(state.turns)) for (const turn of state.turns) if (isRecord(turn)) consumeTurn(turn); + const rendered = chunks.join("\n\n"); + return { + text: rendered.length > maxChars ? rendered.slice(-maxChars) : rendered, + totalLength: rendered.length, + truncated: rendered.length > maxChars, + messages, + subagents: collectSubagents(state), + }; +} + +/** + * Return only newly appended text. Once the bounded output window starts + * sliding, compare the old suffix with the new prefix so a one-character + * stream update does not retransmit the entire 32 KB snapshot. + */ +function appendOnlyOutputDelta( + previous: string, + previousLength: number, + next: string, + nextLength: number, + previousWasTruncated: boolean, +): string | undefined { + // A replacement, deletion, or history prepend cannot be represented by an + // append-only chunk. Fall back to a bounded snapshot in those cases. + if (nextLength < previousLength) return undefined; + if (!previousWasTruncated) return next.startsWith(previous) ? next.slice(previous.length) : undefined; + if (!previous || !next) return undefined; + const dropped = Math.max(0, nextLength - next.length) - Math.max(0, previousLength - previous.length); + if (dropped < 0 || dropped > previous.length) return undefined; + const retained = previous.slice(dropped); + if (retained.length > next.length || next.slice(0, retained.length) !== retained) return undefined; + // If the append is larger than the retained tail, the bounded state no + // longer contains all newly appended text; a snapshot is the only lossless + // representation. + const deltaLength = nextLength - previousLength; + if (deltaLength !== next.length - retained.length) return undefined; + return next.slice(retained.length); +} + +function orderedHistoryTurns(state: JsonObject): Record[] { + const turnHistory = isRecord(state.turnHistory) ? state.turnHistory : undefined; + const history = turnHistory && isRecord(turnHistory.history) ? turnHistory.history : undefined; + const entities = history && isRecord(history.entitiesByKey) ? history.entitiesByKey : undefined; + if (!entities) return []; + const ordered: Record[] = []; + const seen = new Set(); + const add = (key: unknown): void => { + if (typeof key !== "string" || seen.has(key)) return; + const entity = entities[key]; + if (!isRecord(entity) || !looksLikeTurn(entity)) return; + seen.add(key); + ordered.push(entity); + }; + if (history && Array.isArray(history.islands)) { + for (const island of history.islands) if (isRecord(island) && Array.isArray(island.entries)) { + for (const entry of island.entries) { + if (isRecord(entry)) add(entry.key ?? entry.value); + } + } + } + // Include entities not listed by islands for forward compatibility with an + // extension that omits island metadata in a snapshot. + for (const [key, entity] of Object.entries(entities)) { + if (!seen.has(key) && isRecord(entity) && looksLikeTurn(entity)) { + seen.add(key); + ordered.push(entity); + } + } + return ordered; +} + +function looksLikeTurn(value: Record): boolean { + return Array.isArray(value.items) || value.turnId !== undefined || value.status !== undefined; +} + +function isTransientHistoryLoadError(error: unknown): boolean { + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (["timeout", "connection-closed", "not-connected"].includes(code) || code.startsWith("no-client-found")) return true; + const message = error instanceof Error ? error.message : String(error ?? ""); + return /timed? out|socket (?:is )?closed|not connected|no client found/i.test(message); +} + +function hasIncompleteHistory(state: JsonObject): boolean { + const turnHistory = isRecord(state.turnHistory) ? state.turnHistory : undefined; + const history = turnHistory && isRecord(turnHistory.history) ? turnHistory.history : undefined; + if (turnHistory?.kind === "canonical" && !history) return true; + const entities = history && isRecord(history.entitiesByKey) ? Object.values(history.entitiesByKey) : []; + const turns = [ + ...entities, + ...(Array.isArray(state.turns) ? state.turns : []), + ]; + // Both canonical entities and the legacy turns list expose this per-turn + // marker. The official completeness predicate only treats an explicit + // `false` as incomplete; missing metadata is compatible with older builds. + if (turns.some((turn) => isRecord(turn) + && isRecord(turn.itemsPagination) + && turn.itemsPagination.hasLoadedOldest === false)) return true; + + // Canonical history is complete only after the owner has coalesced it into + // one island. Boundary status is deliberately not checked here: the + // official webview uses `isComplete` and island count, and some versions + // leave boundary objects in a non-exhausted transitional shape. + const canonical = Boolean(history && ( + turnHistory?.kind === "canonical" + || history.isComplete !== undefined + || Array.isArray(history.islands) + )); + if (canonical) { + return history?.isComplete !== true + || !Array.isArray(history.islands) + || history.islands.length !== 1; + } + + // Legacy snapshots carry a resume marker. Avoid requesting an unsupported + // history operation for old snapshots that expose no pagination metadata at + // all, while respecting explicit loading/unfinished states. + if (state.resumeState !== undefined && state.resumeState !== "resumed") return true; + const turnsPagination = isRecord(state.turnsPagination) ? state.turnsPagination : undefined; + return turnsPagination?.hasLoadedOldest === false; +} + +function itemDisplay(rawItem: Record): ItemDisplayProjection | undefined { + const normalizedItem = normalizeOfficialItem(rawItem); + const type = String(normalizedItem.type ?? normalizedItem.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); + + if (type === "collabagenttoolcall") { + const tool = stringValue(normalizedItem.tool) ?? "collabAgent"; + // `wait` is an internal synchronization action. The official webview + // consumes it for aggregation but intentionally omits it from the + // visible transcript. + if (tool === "wait") return undefined; + const status = statusValue(normalizedItem.status) ?? "inProgress"; + const receiverThreadIds = stringArray(normalizedItem.receiverThreadIds); + const agentsStates = collabAgentStates(normalizedItem.agentsStates); + const prompt = redactNullableString(normalizedItem.prompt); + const model = redactNullableString(normalizedItem.model); + const reasoningEffort = redactNullableString(normalizedItem.reasoningEffort); + const senderThreadId = stringValue(normalizedItem.senderThreadId); + const label = collabAgentToolLabel(tool); + const promptText = prompt?.trim() ? `: ${redactText(prompt.trim())}` : ""; + const outputText = `${label}${promptText}`; + return { + outputText, + projectionId: stringValue(normalizedItem.id), + role: "tool", + kind: "tool", + text: outputText, + label, + itemType: "collabAgentToolCall", + status, + action: tool, + ...(senderThreadId ? { senderThreadId } : {}), + receiverThreadIds, + receiverThreads: receiverThreadIds, + prompt, + model, + reasoningEffort, + agentsStates: redactJson(agentsStates) as JsonObject, + uiType: "multi-agent-action", + }; + } + + if (type === "subagentactivity") { + const activityKind = stringValue(normalizedItem.kind) ?? "started"; + const agentThreadId = stringValue(normalizedItem.agentThreadId); + if (!agentThreadId) return undefined; + const agentPath = redactNullableString(normalizedItem.agentPath); + const displayName = formatAgentPath(agentPath ?? undefined); + const displayStatus = subagentActivityDisplayStatus(activityKind); + const status = statusValue(normalizedItem.status) + ?? (activityKind === "interrupted" || activityKind === "completed" ? "completed" : "inProgress"); + const label = displayName ? `子代理 · ${displayName}` : "子代理"; + const activityText = subagentActivityText(displayName, activityKind); + return { + outputText: activityText, + projectionId: stringValue(normalizedItem.id), + role: "tool", + kind: "tool", + text: activityText, + label, + itemType: "subAgentActivity", + status, + agentThreadId, + ...(agentPath ? { agentPath } : {}), + displayName, + displayStatus, + activityKind, + uiType: "subagent-activity", + }; + } + + const item = normalizedItem; + // Pending permission/input/elicitation items are rendered by the request + // card, not as a second transcript activity. Once the owner marks one + // complete it may re-enter history and be displayed normally. + const requestItem = type.includes("permissionrequest") + || type.includes("userinput") + || type.includes("mcpserverelicitation"); + const itemStatus = normalizeStatus(statusValue(item.status)); + const requestPending = requestItem + && item.completed !== true + && !TERMINAL_TURN_STATES.has(itemStatus); + if (requestPending) return undefined; + if (["agentmessage", "assistantmessage", "usermessage"].includes(type)) { + const text = textFromValue(item.text) ?? textFromValue(item.content); + if (!text) return undefined; + if (type.startsWith("user")) return { outputText: `> ${text}`, role: "user", kind: "user", text }; + return { outputText: text, role: "assistant", kind: "assistant", text }; + } + if (type.includes("contextcompaction")) { + const text = textFromValue(item.summary) ?? textFromValue(item.content) ?? textFromValue(item.text) ?? "整理上下文"; + return { outputText: text, role: "reasoning", kind: "reasoning", text, label: "整理上下文" }; + } + if (type.includes("reasoning") || type.includes("approvalreview")) { + const text = textFromValue(item.summary) ?? textFromValue(item.content); + return text ? { outputText: text, role: "reasoning", kind: "reasoning", text, label: "思考" } : undefined; + } + if (type.includes("plan") || type.includes("todo")) { + const value = item.plan ?? item.steps ?? item.todos ?? item.content ?? item.text; + const text = planDisplayText(value); + return text ? { outputText: text, role: "reasoning", kind: "plan", text, label: "计划" } : undefined; + } + const parsedActions = commandActionsValue(item); + const readPaths = readPathsFromActions(parsedActions); + const directReadItem = type === "read" + || type.includes("fileread") + || type.includes("readfile") + || type.includes("exploration"); + if (directReadItem || readPaths.length > 0) { + const command = commandText(item); + const output = textFromValue(item.aggregatedOutput) + ?? textFromValue(item.output) + ?? textFromValue(item.stdout) + ?? textFromValue(item.content) + ?? textFromValue(item.text); + const pathSummary = readPaths.length ? readPaths.join(", ") : readPathFromItem(item); + const summary = pathSummary ? `已读取 ${pathSummary}` : "已读取文件"; + const text = summary; + const commandActions = projectCommandActions(parsedActions); + const cwd = redactNullableString(item.cwd); + const shellName = redactNullableString(item.shellName ?? item.shell); + return { + outputText: output && output.trim() ? `${summary}\n${output}` : text, + role: "tool", + kind: "tool", + text, + label: "已读取文件", + itemType: typeof item.type === "string" ? item.type : "fileRead", + ...(command ? { command } : {}), + ...(commandActions.length ? { commandActions } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(shellName !== undefined ? { shellName } : {}), + ...(readPaths.length ? { readPaths } : {}), + ...(output && output.trim() ? { output } : {}), + activityKind: "read", + uiType: "file-read", + }; + } + if (type.includes("filechange") || type.includes("file_change") || type.includes("patch") || type.includes("edit")) { + const text = textFromValue(item.diff) + ?? textFromValue(item.patch) + ?? fileChangesText(item.changes) + ?? textFromValue(item.output) + ?? textFromValue(item.text); + return text ? { outputText: text, role: "tool", kind: "edit", text, label: "文件变更" } : undefined; + } + const hasCommandProjection = isCommandActionValue(item.commandActions) + || isCommandActionValue(item.command_actions) + || isCommandActionValue(item.parsedCmd) + || isCommandActionValue(item.parsed_cmd) + || item.command !== undefined + || item.commandLine !== undefined; + if (type.includes("command") || type.includes("exec") || type.includes("process") || hasCommandProjection) { + const command = commandText(item); + // Some official snapshots expose commandActions before output is flushed + // (and may leave aggregatedOutput as an empty string). Keep the command + // visible in that state, while avoiding a bare shell bootstrap such as + // `/bin/zsh` becoming the displayed command. + const output = textFromValue(item.aggregatedOutput) + ?? textFromValue(item.output) + ?? textFromValue(item.stdout) + ?? textFromValue(item.stderr); + const text = output || command; + const commandActions = projectCommandActions(parsedActions); + const cwd = redactNullableString(item.cwd); + const shellName = redactNullableString(item.shellName ?? item.shell); + return text ? { + outputText: text, + role: "tool", + kind: "tool", + text, + label: "命令输出", + ...(commandActions.length ? { commandActions } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(shellName !== undefined ? { shellName } : {}), + } : undefined; + } + if (type.includes("websearch") || type.includes("mcp") || type.includes("dynamictool") + || type.includes("imageview") || type.includes("imagegeneration") || type.includes("generatedimage") + || type.includes("toolcall") || type.includes("permissionrequest") || type.includes("userinput")) { + const text = textFromValue(item.output) + ?? textFromValue(item.result) + ?? textFromValue(item.content) + ?? textFromValue(item.summary) + ?? textFromValue(item.text) + ?? textFromValue(item.query) + ?? textFromValue(item.name); + if (!text) return undefined; + const label = type.includes("websearch") ? "搜索" + : type.includes("image") ? "查看图像" + : type.includes("permissionrequest") ? "等待授权" + : type.includes("userinput") ? "等待输入" + : type.includes("mcp") ? "MCP 工具" + : "工具"; + return { outputText: text, role: "tool", kind: "tool", text, label }; + } + const text = textFromValue(item.text) ?? textFromValue(item.output); + return text ? { outputText: text, role: "assistant", kind: "assistant", text } : undefined; +} + +/** + * Official conversation messages can carry collaboration records in a + * metadata envelope instead of exposing them as a top-level `type`. Normalize + * direct item names here; metadata variants are added by + * `itemDisplayVariants` so the parent message is retained as well. + */ +function normalizeOfficialItem(item: Record): Record { + const directType = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); + if (directType === "collabagenttoolcall") { + return { ...item, type: "collabAgentToolCall" }; + } + if (directType === "subagentactivity") { + return { ...item, type: "subAgentActivity" }; + } + return item; +} + +/** Return the normal item plus any collaboration records attached as metadata. */ +function itemDisplayVariants(item: Record): ItemDisplayProjection[] { + const displays: ItemDisplayProjection[] = []; + const base = itemDisplay(item); + if (base) displays.push(base); + const metadata = parseRecord(item.metadata); + const candidates: Array<{ key: string; type: "collabAgentToolCall" | "subAgentActivity" }> = [ + { key: "codex_collab_agent_tool_call", type: "collabAgentToolCall" }, + { key: "codex_sub_agent_activity", type: "subAgentActivity" }, + ]; + for (const candidate of candidates) { + const value = parseRecord(metadata?.[candidate.key]); + if (!value) continue; + const normalized: Record = { ...value, type: candidate.type }; + // A direct item may carry a copy of its own metadata record. Do not render + // that record twice when the ids identify the same official item. + const normalizedId = stringValue(normalized.id); + if (normalizedId && displays.some((display) => display.projectionId === normalizedId)) continue; + const display = itemDisplay(normalized); + if (display) displays.push(display); + } + return displays; +} + +function parseRecord(value: unknown): Record | undefined { + if (isRecord(value)) return value; + if (typeof value !== "string") return undefined; + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function nullableString(value: unknown): string | null | undefined { + return typeof value === "string" ? value : value === null ? null : undefined; +} + +function redactNullableString(value: unknown): string | null | undefined { + const normalized = nullableString(value); + return normalized === undefined || normalized === null ? normalized : redactText(normalized); +} + +function collabAgentStates(value: unknown): JsonObject { + const record = parseRecord(value); + if (!record) return {}; + const states: JsonObject = {}; + for (const [threadId, rawState] of Object.entries(record)) { + const state = parseRecord(rawState); + if (!state || typeof state.status !== "string") continue; + states[threadId] = redactJson({ + status: state.status, + ...(state.message === null || typeof state.message === "string" ? { message: state.message } : {}), + }) as JsonValue; + } + return states; +} + +function collabAgentToolLabel(tool: string): string { + switch (tool) { + case "spawnAgent": return "启动子代理"; + case "sendInput": return "向子代理发送输入"; + case "resumeAgent": return "恢复子代理"; + case "wait": return "等待子代理"; + case "closeAgent": return "关闭子代理"; + default: return "子代理操作"; + } +} + +function formatAgentPath(agentPath?: string): string | null { + if (!agentPath) return null; + const leaf = agentPath.split("/").map((part) => part.trim()).filter((part) => part && part !== "root").at(-1); + if (!leaf) return null; + const normalized = leaf.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); + return normalized ? normalized[0].toUpperCase() + normalized.slice(1) : null; +} + +function subagentActivityDisplayStatus(kind: string): string { + switch (kind) { + case "started": return "active"; + case "interacted": return "updated"; + case "interrupted": return "interrupted"; + case "completed": return "completed"; + default: return "active"; + } +} + +function subagentActivityText(displayName: string | null, kind: string): string { + const subject = displayName ? `子代理 ${displayName}` : "子代理"; + switch (kind) { + case "started": return `${subject} 已开始工作`; + case "interacted": return `${subject} 正在工作`; + case "interrupted": return `${subject} 已中断`; + case "completed": return `${subject} 已完成`; + default: return `${subject} ${kind}`; + } +} + +interface SubagentAccumulator extends SubagentSnapshot { + lastEventIndex: number; +} + +/** Rebuild the official subagent panel model from direct items and metadata. */ +function collectSubagents(state: JsonObject): SubagentSnapshot[] { + const agents = new Map(); + const parentThreadId = stringValue(state.id) + ?? stringValue(state.threadId) + ?? (isRecord(state.thread) ? stringValue(state.thread.id) : undefined) + ?? null; + let eventIndex = 0; + const ensure = (threadId: string): SubagentAccumulator => { + const current = agents.get(threadId); + if (current) { + current.lastEventIndex = eventIndex; + return current; + } + const created: SubagentAccumulator = { + threadId, + displayName: null, + prompt: null, + objective: null, + status: "working", + statusMessage: null, + canInteract: false, + parentThreadId, + lastEventIndex: eventIndex, + }; + agents.set(threadId, created); + return created; + }; + const consumeTurn = (turn: Record): void => { + const turnStartedAtMs = timestampMs(turn.turnStartedAtMs) + ?? timestampMs(turn.startedAtMs) + ?? timestampMs(turn.startedAt) + ?? timestampMs(turn.createdAtMs) + ?? timestampMs(turn.createdAt); + const turnCompletedAtMs = timestampMs(turn.completedAtMs) + ?? timestampMs(turn.completedAt) + ?? (turnStartedAtMs !== undefined && numberValue(turn.durationMs) !== undefined + ? turnStartedAtMs + (numberValue(turn.durationMs) as number) + : undefined); + for (const rawItem of Array.isArray(turn.items) ? turn.items : []) { + if (!isRecord(rawItem)) continue; + const itemStartedAtMs = timestampMs(rawItem.startedAtMs) + ?? timestampMs(rawItem.startedAt) + ?? turnStartedAtMs; + const itemCompletedAtMs = timestampMs(rawItem.completedAtMs) + ?? timestampMs(rawItem.finishedAtMs) + ?? timestampMs(rawItem.completedAt) + ?? (itemStartedAtMs !== undefined && numberValue(rawItem.durationMs) !== undefined + ? itemStartedAtMs + (numberValue(rawItem.durationMs) as number) + : turnCompletedAtMs); + for (const item of officialSubagentItems(rawItem)) { + eventIndex += 1; + const type = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); + if (type === "subagentactivity") { + const threadId = stringValue(item.agentThreadId); + if (!threadId) continue; + const agent = ensure(threadId); + const agentPath = stringValue(item.agentPath); + const displayName = formatAgentPath(agentPath); + const activityKind = stringValue(item.kind) ?? "started"; + if (displayName) agent.displayName = redactText(displayName); + if (agentPath) agent.agentPath = redactText(agentPath); + if (agent.startedAtMs == null && itemStartedAtMs !== undefined) agent.startedAtMs = itemStartedAtMs; + agent.statusMessage = null; + if (activityKind === "interrupted" || activityKind === "completed") { + agent.status = "done"; + if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs; + } else { + agent.status = "working"; + agent.completedAtMs = null; + } + continue; + } + if (type !== "collabagenttoolcall") continue; + const tool = stringValue(item.tool) ?? ""; + const toolStatus = statusValue(item.status) ?? "inProgress"; + const receivers = stringArray(item.receiverThreadIds ?? item.receiverThreads); + const states = parseRecord(item.agentsStates) ?? {}; + const prompt = nullableString(item.prompt); + const model = nullableString(item.model); + + for (const threadId of new Set([...receivers, ...Object.keys(states)])) { + if (!threadId) continue; + const agent = ensure(threadId); + if (agent.startedAtMs == null && itemStartedAtMs !== undefined) agent.startedAtMs = itemStartedAtMs; + if (tool === "spawnAgent") { + if (prompt?.trim()) { + agent.prompt = redactText(prompt.trim()); + agent.objective = agent.prompt; + } + if (model !== undefined) agent.model = model === null ? null : redactText(model); + agent.canInteract = true; + } else if (tool === "sendInput" || tool === "resumeAgent") { + agent.canInteract = true; + } + if (toolStatus === "failed") { + agent.status = "failed"; + if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs; + } else if (tool === "spawnAgent" || tool === "sendInput" || tool === "resumeAgent") { + agent.status = "working"; + agent.statusMessage = null; + agent.completedAtMs = null; + } else if (tool === "closeAgent" && toolStatus === "completed") { + agent.status = "done"; + if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs; + } + } + + for (const [threadId, rawState] of Object.entries(states)) { + const agentState = parseRecord(rawState); + if (!agentState || typeof agentState.status !== "string") continue; + const agent = ensure(threadId); + agent.status = coarseSubagentStatus(agentState.status); + if (agent.status === "waiting" || agent.status === "working") { + agent.statusMessage = null; + agent.completedAtMs = null; + } else { + const statusMessage = nullableString(agentState.message); + agent.statusMessage = statusMessage?.trim() ? redactText(statusMessage.trim()) : null; + if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs; + } + } + + // A completed wait means the parent observed all currently active + // receivers finishing, even if older state records were not patched. + if (tool === "wait" && toolStatus === "completed") { + for (const agent of agents.values()) { + if (agent.status !== "waiting" && agent.status !== "working") continue; + agent.status = "done"; + agent.statusMessage = null; + agent.lastEventIndex = eventIndex; + if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs; + } + } + } + } + }; + + for (const turn of orderedHistoryTurns(state)) consumeTurn(turn); + if (Array.isArray(state.turns)) for (const turn of state.turns) if (isRecord(turn)) consumeTurn(turn); + + // The official UI closes stale active rows when the parent turn is no longer + // live. This prevents an old running state from lingering after reconnect. + if (!deriveTurn(state).active) { + for (const agent of agents.values()) { + if (agent.status !== "waiting" && agent.status !== "working") continue; + agent.status = "done"; + agent.statusMessage = null; + } + } + + return Array.from(agents.values(), ({ lastEventIndex: _lastEventIndex, ...agent }) => agent); +} + +function officialSubagentItems(item: Record): Record[] { + const result: Record[] = []; + const seen = new Set(); + const add = (value: Record, type: "collabAgentToolCall" | "subAgentActivity"): void => { + const normalized: Record = { ...value, type }; + const id = stringValue(normalized.id); + const key = `${type}:${id ?? JSON.stringify(normalized)}`; + if (seen.has(key)) return; + seen.add(key); + result.push(normalized); + }; + const directType = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase(); + if (directType === "collabagenttoolcall") add(item, "collabAgentToolCall"); + if (directType === "subagentactivity") add(item, "subAgentActivity"); + const metadata = parseRecord(item.metadata); + const collab = parseRecord(metadata?.codex_collab_agent_tool_call); + if (collab) add(collab, "collabAgentToolCall"); + const activity = parseRecord(metadata?.codex_sub_agent_activity); + if (activity) add(activity, "subAgentActivity"); + return result; +} + +function coarseSubagentStatus(status: string): "waiting" | "working" | "done" | "failed" { + switch (normalizeStatus(status)) { + case "pendinginit": return "waiting"; + case "running": return "working"; + case "completed": + case "interrupted": + case "shutdown": return "done"; + case "errored": + case "notfound": return "failed"; + default: return "working"; + } +} + +function planDisplayText(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!Array.isArray(value)) { + if (isRecord(value)) return planDisplayText(value.plan ?? value.steps ?? value.todos ?? value.text ?? value.content); + return textFromValue(value); + } + const lines = value.map((entry) => { + if (!isRecord(entry)) return textFromValue(entry); + const status = statusValue(entry.status) ?? "pending"; + const marker = ["completed", "complete", "done", "success", "succeeded"].includes(status.toLowerCase()) ? "[x]" : "[ ]"; + const label = stringValue(entry.step) ?? stringValue(entry.text) ?? stringValue(entry.title) ?? stringValue(entry.description); + return label ? `${marker} ${label}` : undefined; + }).filter((line): line is string => Boolean(line)); + return lines.length ? lines.join("\n") : undefined; +} + +function itemDisplayText(item: Record): string | undefined { + return itemDisplay(item)?.outputText; +} + +/** + * Keep the command action shape used by the official webview while making it + * safe to send over the relay. Older snapshots use `command`, whereas the + * webview's normalized action uses `cmd`; expose both aliases so either + * renderer can consume the projection without losing the original fields. + */ +function projectCommandActions(value: unknown): JsonValue[] { + const actions = commandActionList(value); + if (!actions.length) return []; + const projected: JsonValue[] = []; + for (const rawAction of actions) { + const action = redactJson(rawAction); + if (isRecord(action)) { + const command = stringValue(action.command) ?? stringValue(action.cmd); + if (command && action.command === undefined) action.command = command; + if (command && action.cmd === undefined) action.cmd = command; + if (Object.keys(action).length > 0) projected.push(action as JsonObject); + continue; + } + if (typeof action === "string" && action.trim()) projected.push(action); + } + return projected; +} + +function commandActionText(value: unknown): string | undefined { + if (typeof value === "string") return commandValue(value); + if (!isRecord(value)) return undefined; + const command = stringValue(value.command) ?? stringValue(value.cmd); + return command ? commandValue(command) : undefined; +} + +const SHELL_BOOTSTRAP = /^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)(?:\s|$)/i; + +function isShellBootstrapCommand(value: string): boolean { + return SHELL_BOOTSTRAP.test(value.trim()); +} + +function commandValue(value: unknown): string | undefined { + if (typeof value === "string") { + const command = value.trim(); + if (!command) return undefined; + // A few IPC versions put the shell wrapper and the user command in one + // string instead of an argv array. Strip only the wrapper here; the + // frontend remains responsible for presentation-level quote cleanup. + const wrapped = command.match(/^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)\s+-(?:l?c|c?l)\s+([\s\S]+)$/i); + return (wrapped?.[1] ?? command).trim() || undefined; + } + if (!Array.isArray(value)) return undefined; + const parts = value.filter((part): part is string => typeof part === "string").map((part) => part.trim()).filter(Boolean); + if (!parts.length) return undefined; + // The IPC snapshot may preserve the process argv (`zsh -lc `) + // rather than the command string shown by the official disclosure. + if (parts.length >= 3 && SHELL_BOOTSTRAP.test(parts[0]) && /^-(?:l?c|c?l)$/i.test(parts[1])) { + return parts.slice(2).join(" ").trim() || undefined; + } + return parts.join(" ").trim() || undefined; +} + +function commandText(item: Record): string | undefined { + // The official renderer walks actions backwards and displays the last + // non-bootstrap command. This matters when the first action is just the + // shell wrapper used to launch the process. + const actions = commandActionsValue(item); + const actionList = commandActionList(actions); + for (let index = actionList.length - 1; index >= 0; index -= 1) { + const candidate = commandActionText(actionList[index]); + if (candidate && !isShellBootstrapCommand(candidate)) return candidate; + } + const command = commandValue(item.command) ?? stringValue(item.commandLine)?.trim(); + if (!command || isShellBootstrapCommand(command)) return undefined; + return command; +} + +/** Read the parsed command-action field across live and persisted schemas. */ +function commandActionsValue(item: Record): unknown { + return item.commandActions + ?? item.command_actions + ?? item.parsedCmd + ?? item.parsed_cmd; +} + +/** Normalize live/persisted command actions, which may be a single object. */ +function commandActionList(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + return isRecord(value) ? [value] : []; +} + +function isCommandActionValue(value: unknown): boolean { + return Array.isArray(value) || isRecord(value); +} + +function readPathsFromActions(value: unknown): string[] { + const actions = commandActionList(value); + if (!actions.length) return []; + const paths: string[] = []; + const seen = new Set(); + for (const raw of actions) { + if (!isRecord(raw)) continue; + const type = normalizeStatus(raw.type); + if (type !== "read") continue; + const path = stringValue(raw.path) ?? stringValue(raw.filePath) ?? stringValue(raw.file_path) ?? stringValue(raw.name); + if (!path) continue; + const safe = redactText(path.trim()); + if (!safe || seen.has(safe)) continue; + seen.add(safe); + paths.push(safe); + } + return paths.slice(0, 128); +} + +function readPathFromItem(item: Record): string | undefined { + const value = item.path ?? item.filePath ?? item.file_path ?? item.file ?? item.name; + return typeof value === "string" && value.trim() ? redactText(value.trim()) : undefined; +} + +function fileChangesText(value: unknown): string | undefined { + if (!Array.isArray(value)) return textFromValue(value); + const changes = value.map((change) => { + if (!isRecord(change)) return textFromValue(change); + const file = stringValue(change.path) ?? stringValue(change.filePath) ?? stringValue(change.file) ?? stringValue(change.name); + const kind = statusValue(change.kind ?? change.type ?? change.status); + const diff = textFromValue(change.diff) ?? textFromValue(change.patch) ?? textFromValue(change.output) ?? textFromValue(change.text); + const heading = [kind ? `[${kind}]` : undefined, file].filter(Boolean).join(" "); + return [heading, diff].filter(Boolean).join("\n"); + }).filter((part): part is string => Boolean(part)); + return changes.length ? changes.join("\n\n") : undefined; +} + +function statusValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + return isRecord(value) && typeof value.type === "string" ? value.type : undefined; +} + +function textFromValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts = value.map(textFromValue).filter((part): part is string => Boolean(part)); + return parts.length ? parts.join("\n") : undefined; + } + if (isRecord(value)) { + for (const key of ["text", "value", "output", "stdout", "stderr", "delta", "summary"]) { + const text = textFromValue(value[key]); + if (text) return text; + } + } + return undefined; +} + +const MODEL_CATALOG_ROOT_KEYS = ["availableModels", "models", "modelCatalog", "listModels"] as const; +const MODEL_CATALOG_CONTAINER_KEYS = new Set([ + "data", + "items", + "models", + "availableModels", + "modelCatalog", + "listModels", +]); +const MODEL_CATALOG_META_KEYS = new Set([ + "cursor", + "nextCursor", + "next_cursor", + "hasMore", + "has_more", + "total", + "name", + "label", + "description", + "provider", + "status", + "type", + "message", + "error", +]); +const MODEL_CATALOG_MAX_ENTRIES = 256; +const MODEL_CATALOG_MAX_TEXT = 512; +const MODEL_CATALOG_MAX_MODEL = 256; +const MODEL_CATALOG_MAX_EFFORTS = 32; +const MODEL_CATALOG_MAX_SCANNED = 4_096; + +/** + * Keep the model directory useful to the browser without forwarding opaque + * provider records (which may contain credentials, URLs, or internal flags). + * The official `model/list` response is normally `{ data: Model[] }`, but + * older extension builds have exposed the same data under several state keys. + */ +function projectAvailableModels(state: JsonObject): JsonValue[] { + const sources: unknown[] = []; + const addSources = (value: unknown): void => { + if (!isRecord(value)) return; + for (const key of MODEL_CATALOG_ROOT_KEYS) { + if (value[key] !== undefined) sources.push(value[key]); + } + }; + addSources(state); + for (const key of ["thread", "metadata", "conversation", "session", "latestThreadSettings", "threadSettings", "settings"]) { + addSources(state[key]); + } + + const projected: JsonObject[] = []; + const byModel = new Map(); + const visited = new Set(); + let scanned = 0; + + const add = (value: unknown, fallbackModel?: string): void => { + const item = projectAvailableModel(value, fallbackModel); + if (!item) return; + const model = stringValue(item.model); + if (!model) return; + const key = model.toLowerCase(); + const existing = byModel.get(key); + if (!existing) { + byModel.set(key, item); + projected.push(item); + return; + } + // A state patch can first expose a bare model id and later provide the + // catalog details. Fill only absent fields so explicit false/null values + // from the first projection are not accidentally overwritten. + for (const [field, fieldValue] of Object.entries(item)) { + if (existing[field] === undefined || (Array.isArray(existing[field]) && (existing[field] as unknown[]).length === 0)) { + existing[field] = fieldValue; + } + } + }; + + const collect = (value: unknown, fallbackModel?: string, depth = 0): void => { + if (projected.length >= MODEL_CATALOG_MAX_ENTRIES || scanned >= MODEL_CATALOG_MAX_SCANNED || depth > 6 || value === undefined || value === null) return; + scanned += 1; + if (typeof value === "string") { + // Strings at the root are model ids. A string under a map key is a + // display label, so retain the key as the canonical id in that case. + if (fallbackModel && isPlausibleModelMapKey(fallbackModel)) add({ model: fallbackModel, displayName: value }); + else add(value); + return; + } + if (Array.isArray(value)) { + for (const entry of value) collect(entry, undefined, depth + 1); + return; + } + if (!isRecord(value)) return; + if (visited.has(value)) return; + visited.add(value); + + let hasContainer = false; + for (const key of MODEL_CATALOG_CONTAINER_KEYS) { + if (value[key] === undefined) continue; + hasContainer = true; + collect(value[key], undefined, depth + 1); + } + + const strongIdentity = modelCatalogText(value.model, MODEL_CATALOG_MAX_MODEL) + ?? modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL) + ?? modelCatalogText(value.slug, MODEL_CATALOG_MAX_MODEL); + const directModel = modelCatalogIdentity(value); + const isModelEntry = Boolean( + (directModel && (!hasContainer || strongIdentity)) + || (fallbackModel && isPlausibleModelMapKey(fallbackModel)), + ); + if (isModelEntry) add(value, fallbackModel); + // Once a record has an identity, its scalar fields are model properties, + // not additional map entries (for example `displayName: "Sol"`). + if (isModelEntry && !hasContainer) return; + + // A map-shaped catalog (`{ "gpt-5": { displayName: ... } }`) is used by + // a few pre-model/list extension builds. Ignore pagination metadata and + // known envelopes while walking those entries. + for (const [key, child] of Object.entries(value)) { + if (MODEL_CATALOG_CONTAINER_KEYS.has(key) || MODEL_CATALOG_META_KEYS.has(key)) continue; + if (!isPlausibleModelMapKey(key)) continue; + if (hasContainer && !isRecord(child) && !Array.isArray(child)) continue; + if (isRecord(child) || Array.isArray(child)) collect(child, key, depth + 1); + else if (typeof child === "string" && isPlausibleModelMapKey(key)) collect(child, key, depth + 1); + } + }; + + for (const source of sources) collect(source); + return projected; +} + +function isPlausibleModelMapKey(value: string): boolean { + const key = value.trim(); + return Boolean(key) + && key.length <= MODEL_CATALOG_MAX_MODEL + && !MODEL_CATALOG_CONTAINER_KEYS.has(key) + && !MODEL_CATALOG_META_KEYS.has(key) + && !/(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i.test(key); +} + +function modelCatalogText(value: unknown, maxLength = MODEL_CATALOG_MAX_TEXT): string | undefined { + if (typeof value !== "string") return undefined; + const text = redactText(value.trim()).slice(0, maxLength).trim(); + return text || undefined; +} + +function modelCatalogStrongIdentity(value: Record): string | undefined { + return modelCatalogText(value.model, MODEL_CATALOG_MAX_MODEL) + ?? modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL) + ?? modelCatalogText(value.slug, MODEL_CATALOG_MAX_MODEL); +} + +function modelCatalogIdentity(value: Record): string | undefined { + return modelCatalogStrongIdentity(value) + ?? modelCatalogText(value.name, MODEL_CATALOG_MAX_MODEL); +} + +function projectAvailableModel(value: unknown, fallbackModel?: string): JsonObject | undefined { + if (typeof value === "string") { + const model = modelCatalogText(fallbackModel ?? value, MODEL_CATALOG_MAX_MODEL); + return model ? { model } : undefined; + } + if (!isRecord(value)) return undefined; + // For map-shaped catalogs the key is the canonical model id and `name` is + // commonly only a human-readable label. Prefer explicit model/id/slug, + // then the map key, and use `name` as a legacy fallback for standalone rows. + const model = modelCatalogStrongIdentity(value) + ?? (fallbackModel && isPlausibleModelMapKey(fallbackModel) ? modelCatalogText(fallbackModel, MODEL_CATALOG_MAX_MODEL) : undefined) + ?? modelCatalogText(value.name, MODEL_CATALOG_MAX_MODEL); + if (!model) return undefined; + + const result: JsonObject = { model }; + const id = modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL); + if (id) result.id = id; + const displayName = modelCatalogText(value.displayName ?? value.label ?? (fallbackModel ? value.name : undefined)); + if (displayName) result.displayName = displayName; + const description = modelCatalogText(value.description); + if (description) result.description = description; + const specialty = modelCatalogText(value.modelSpecialty); + if (specialty) result.modelSpecialty = specialty; + for (const key of ["hidden", "isDefault"] as const) { + if (typeof value[key] === "boolean") result[key] = value[key]; + } + const upgrade = modelCatalogText(value.upgrade, MODEL_CATALOG_MAX_MODEL); + if (upgrade) result.upgrade = upgrade; + const defaultEffort = modelCatalogText(value.defaultReasoningEffort ?? value.defaultEffort, 64); + if (defaultEffort) result.defaultReasoningEffort = defaultEffort; + else if (value.defaultReasoningEffort === null || value.defaultEffort === null) result.defaultReasoningEffort = null; + + const rawEfforts = Array.isArray(value.supportedReasoningEfforts) + ? value.supportedReasoningEfforts + : Array.isArray(value.reasoningEfforts) + ? value.reasoningEfforts + : Array.isArray(value.efforts) ? value.efforts : undefined; + const efforts = projectReasoningEfforts(rawEfforts); + if (efforts) result.supportedReasoningEfforts = efforts; + return result; +} + +function projectReasoningEfforts(value: unknown[] | undefined): JsonValue[] | undefined { + if (!value) return undefined; + const seen = new Set(); + const projected: JsonValue[] = []; + for (const entry of value.slice(0, MODEL_CATALOG_MAX_EFFORTS)) { + const effort = typeof entry === "string" + ? modelCatalogText(entry, 64) + : isRecord(entry) + ? modelCatalogText(entry.reasoningEffort ?? entry.effort, 64) + : undefined; + if (!effort) continue; + const key = effort.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const description = isRecord(entry) ? modelCatalogText(entry.description) : undefined; + projected.push({ + reasoningEffort: effort, + ...(description ? { description } : {}), + }); + } + return projected.length ? projected : undefined; +} + +const TOKEN_USAGE_FIELDS = [ + "totalTokens", + "inputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "outputTokens", + "reasoningOutputTokens", +] as const; + +/** Project the official thread/tokenUsage payload without forwarding limits or account data. */ +function projectTokenUsage(value: unknown): JsonObject | undefined { + if (!isRecord(value)) return undefined; + const source = isRecord(value.info) + ? value.info + : isRecord(value.tokenUsage) + ? value.tokenUsage + : isRecord(value.token_usage) + ? value.token_usage + : value; + const total = projectTokenUsageBreakdown( + source.total + ?? source.total_token_usage + ?? source.totalTokenUsage, + ); + const last = projectTokenUsageBreakdown( + source.last + ?? source.last_token_usage + ?? source.lastTokenUsage, + ); + const contextWindow = tokenNumber( + source.modelContextWindow + ?? source.model_context_window + ?? source.contextWindow + ?? source.context_window, + ); + if (!total && !last && contextWindow === undefined) return undefined; + return { + ...(total ? { total } : {}), + ...(last ? { last } : {}), + ...(contextWindow !== undefined ? { modelContextWindow: contextWindow } : {}), + }; +} + +function projectTokenUsageBreakdown(value: unknown): JsonObject | undefined { + if (!isRecord(value)) return undefined; + const aliases: Record<(typeof TOKEN_USAGE_FIELDS)[number], string[]> = { + totalTokens: ["totalTokens", "total_tokens"], + inputTokens: ["inputTokens", "input_tokens"], + cachedInputTokens: ["cachedInputTokens", "cached_input_tokens"], + cacheWriteInputTokens: ["cacheWriteInputTokens", "cache_write_input_tokens"], + outputTokens: ["outputTokens", "output_tokens"], + reasoningOutputTokens: ["reasoningOutputTokens", "reasoning_output_tokens"], + }; + const result: JsonObject = {}; + for (const field of TOKEN_USAGE_FIELDS) { + for (const alias of aliases[field]) { + const number = tokenNumber(value[alias]); + if (number === undefined) continue; + result[field] = number; + break; + } + } + return Object.keys(result).length ? result : undefined; +} + +function tokenNumber(value: unknown): number | undefined { + if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined; + if (typeof value !== "string" || !/^\d+(?:\.\d+)?$/.test(value.trim())) return undefined; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : undefined; +} + +/** Project display-safe thread settings from the opaque IPC state. */ +function projectSessionMetadata(state: JsonObject): JsonObject { + const candidates: unknown[] = [ + state.latestThreadSettings, + state.threadSettings, + state.settings, + isRecord(state.thread) ? state.thread.latestThreadSettings : undefined, + isRecord(state.thread) ? state.thread.settings : undefined, + ]; + // Merge compatibility locations from oldest to newest so a partial + // `latestThreadSettings` record can still inherit provider/permission + // fields exposed by older state shapes, while the official latest record + // wins when it contains the same key. + const settings: Record = {}; + for (const candidate of candidates.slice().reverse()) { + if (isRecord(candidate)) Object.assign(settings, candidate); + } + const result: JsonObject = {}; + const latestModel = settings.model ?? state.latestModel ?? state.model; + const latestReasoningEffort = settings.effort !== undefined + ? settings.effort + : Object.prototype.hasOwnProperty.call(state, "latestReasoningEffort") + ? state.latestReasoningEffort + : state.effort; + const values: Record = { + model: latestModel, + latestModel, + modelProvider: settings.modelProvider ?? state.modelProvider, + approvalPolicy: settings.approvalPolicy ?? state.approvalPolicy, + approvalsReviewer: settings.approvalsReviewer ?? state.approvalsReviewer, + sandboxPolicy: settings.sandboxPolicy ?? settings.sandbox ?? state.sandboxPolicy ?? state.sandbox, + permissions: settings.permissions ?? state.permissions, + currentPermissions: settings.currentPermissions ?? state.currentPermissions, + runtimeWorkspaceRoots: settings.runtimeWorkspaceRoots ?? state.runtimeWorkspaceRoots, + cwd: settings.cwd ?? state.cwd, + effort: latestReasoningEffort, + latestReasoningEffort, + summary: settings.summary ?? state.summary, + }; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== null) result[key] = asJsonValue(value); + } + // Preserve an explicit null effort: the official state uses null when a + // model has no selectable reasoning level, and omission would make the + // browser retain a stale prior value. + if (latestReasoningEffort === null) { + result.effort = null; + result.latestReasoningEffort = null; + } + const tokenUsageSource = [ + state.latestTokenUsageInfo, + state.tokenUsage, + state.token_usage, + settings.latestTokenUsageInfo, + settings.tokenUsage, + settings.token_usage, + ].find((candidate) => candidate !== undefined); + const tokenUsage = projectTokenUsage(tokenUsageSource); + if (tokenUsage) { + // Keep both names: `latestTokenUsageInfo` is the official state key while + // `tokenUsage` is easier for relay/browser clients to consume. + result.tokenUsage = tokenUsage; + result.latestTokenUsageInfo = tokenUsage; + } else if (tokenUsageSource === null) { + result.tokenUsage = null; + result.latestTokenUsageInfo = null; + } + if (!result.title && isRecord(state.thread)) { + const title = state.thread.name ?? state.thread.title ?? state.thread.preview; + if (title !== undefined && title !== null) result.title = asJsonValue(title); + } + const availableModels = projectAvailableModels(state); + if (availableModels.length) { + // `availableModels` is the current relay field; `models` preserves the + // name used by older browser clients and by the app-server response. + result.availableModels = availableModels; + result.models = availableModels; + } + return result; +} + +function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function normalizeStatus(value: unknown): string { return typeof value === "string" ? value.replace(/[- ]/g, "_").toLowerCase() : "unknown"; } +function cloneObject(value: JsonObject): JsonObject { return JSON.parse(JSON.stringify(value)) as JsonObject; } + +const SECRET_KEY = /(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i; +const SECRET_VALUE = /(?:Bearer\s+)[A-Za-z0-9._~+\-/]+=*|(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,})/g; +function redactText(text: string): string { + return text.replace(SECRET_VALUE, "[REDACTED]").replace(/([?&](?:token|key|secret|password|api[_-]?key)=)[^&\s]+/gi, "$1[REDACTED]").replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]"); +} +function redactJson(value: unknown): JsonValue { + if (Array.isArray(value)) return value.map((item) => redactJson(item)); + if (isRecord(value)) { + const result: JsonObject = {}; + for (const [key, child] of Object.entries(value)) result[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redactJson(child); + return result; + } + return typeof value === "string" ? redactText(value) : asJsonValue(value); +} +function hashJson(value: JsonValue): string { return createHash("sha256").update(stableStringify(value)).digest("hex"); } +function stableStringify(value: JsonValue): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value !== null && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key] ?? null)}`).join(",")}}`; + return JSON.stringify(value); +} diff --git a/aether-vscodex/vscode-extension/src/codexPath.ts b/aether-vscodex/vscode-extension/src/codexPath.ts new file mode 100644 index 000000000..44873fa9c --- /dev/null +++ b/aether-vscodex/vscode-extension/src/codexPath.ts @@ -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[]; + 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[]; + 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; +} diff --git a/aether-vscodex/vscode-extension/src/compositeRelay.ts b/aether-vscodex/vscode-extension/src/compositeRelay.ts new file mode 100644 index 000000000..1d83ad8ac --- /dev/null +++ b/aether-vscodex/vscode-extension/src/compositeRelay.ts @@ -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(); + 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(); + 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 { + 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); +} diff --git a/aether-vscodex/vscode-extension/src/extension.ts b/aether-vscodex/vscode-extension/src/extension.ts new file mode 100644 index 000000000..e863ac25f --- /dev/null +++ b/aether-vscodex/vscode-extension/src/extension.ts @@ -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 => vscode.l10n.t(message, ...args); + +export async function activate(context: vscode.ExtensionContext): Promise { + 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 => { + 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("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("threadId", "").trim(); + const socketPath = configuration.get("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("hostId", "local"), + autoDiscoverThread: configuration.get("autoDiscoverThread", true), + // Synchronous mode has one navigation owner: the official panel. + followVscodeSession: true, + preferredCwds: workspaceRoots(), + strictVersions: configuration.get("ipcStrictVersions", true), + logger, + approvalTimeoutMs: configuration.get("approvalTimeoutMs", 300_000), + openNewSession: () => openOfficialNewSession(logger), + }); + } + + const configuredCommand = configuration.get("codexCommand", "codex"); + const command = resolveCodexCommand(configuredCommand); + const args = configuration.get("codexArgs", ["app-server", "--stdio"]); + const defaultCwd = configuration.get("defaultCwd", "") || firstWorkspaceRoot(); + logger.info(`Asynchronous mode enabled; using independent Codex executable: ${command}`); + return new CodexAgentAdapter({ + command, + args, + defaultCwd: defaultCwd || undefined, + logger, + approvalTimeoutMs: configuration.get("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("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("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("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("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 => { + 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("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("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("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("autoStart", true)) await start(true); +} + +export async function deactivate(): Promise { + 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 { + 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(configuration.inspect("localRelayUrl")); + const explicitCloud = inspectedValue(configuration.inspect("cloudRelayUrl")); + const explicitLegacy = inspectedValue(configuration.inspect("relayUrl")); + const legacyUrl = explicitLegacy?.trim() || ""; + const legacyRemote = Boolean(legacyUrl && !localRelayTarget(legacyUrl)); + const localUrl = (explicitLocal?.trim() + || (!legacyRemote ? legacyUrl : "") + || configuration.get("localRelayUrl", defaultLocalUrl).trim() + || defaultLocalUrl); + const cloudUrl = explicitCloud?.trim() + || (legacyRemote ? legacyUrl : "") + || configuration.get("cloudRelayUrl", "").trim(); + return { localUrl, cloudUrl, legacyRemote }; +} + +function inspectedValue(inspection: ReturnType | 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(configuration.inspect("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 { + 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 { + 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 }; +} diff --git a/aether-vscodex/vscode-extension/src/index.ts b/aether-vscodex/vscode-extension/src/index.ts new file mode 100644 index 000000000..4ec7c5d2a --- /dev/null +++ b/aether-vscodex/vscode-extension/src/index.ts @@ -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"; diff --git a/aether-vscodex/vscode-extension/src/jsonlRpc.ts b/aether-vscodex/vscode-extension/src/jsonlRpc.ts new file mode 100644 index 000000000..2d22d0f18 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/jsonlRpc.ts @@ -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> & + Omit; + private child?: ChildProcessWithoutNullStreams; + private stdoutLines?: ReadLineInterface; + private nextId = 1; + private readonly pending = new Map(); + 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 { + 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((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 { + if (!this.running) return Promise.reject(new Error("app-server is not running")); + const id = this.nextId++; + + return new Promise((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 & { 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 & { 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]"); +} diff --git a/aether-vscodex/vscode-extension/src/localRelay.ts b/aether-vscodex/vscode-extension/src/localRelay.ts new file mode 100644 index 000000000..93f24bb2a --- /dev/null +++ b/aether-vscodex/vscode-extension/src/localRelay.ts @@ -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; +} + +interface BundledRelayModule { + CodexRelay: new (options: Record) => 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; +} + +/** + * 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; + private generation = 0; + + constructor(options: LocalRelayControllerOptions) { + this.options = options; + } + + async ensureRunning(relayUrl: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + }); +} diff --git a/aether-vscodex/vscode-extension/src/protocol.ts b/aether-vscodex/vscode-extension/src/protocol.ts new file mode 100644 index 000000000..19170a598 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/protocol.ts @@ -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 { + 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; + /** Switch between following VS Code and independently owned conversations. */ + setControlMode?(params: JsonObject): Promise; + /** Return the currently committed control mode without taking a snapshot. */ + getControlMode?(): ControlMode; + /** Start a new app-server thread. */ + startThread?(params?: JsonObject): Promise; + /** Ask the official VS Code Codex extension to open a fresh conversation. */ + newSession?(params?: JsonObject): Promise; + /** Start a turn; `threadId` may be supplied in params or use the active thread. */ + startTurn?(params: JsonObject): Promise; + /** Steer the active turn. */ + steerTurn?(params: JsonObject): Promise; + /** Persist model/effort and other owner-managed settings on the thread. */ + updateThreadSettings?(params: JsonObject): Promise; + /** List verified, attachable local conversations without starting another Codex process. */ + listSessions?(params?: JsonObject): Promise; + /** Attach the follower to another already-open conversation. */ + selectSession?(params: JsonObject): Promise; + /** Interrupt a turn. */ + interruptTurn?(params: JsonObject): Promise; + /** Convenience MVP aliases. */ + sendInput(text: string, params?: JsonObject): Promise; + cancel(taskId?: string, params?: JsonObject): Promise; + respondApproval( + requestId: JsonRpcId, + decision: "allow" | "deny" | "cancel", + reason?: string, + response?: JsonValue, + ): Promise; + /** Resolve all pending approvals/inputs with a deny response. */ + denyPending?(reason?: string): Promise; + snapshot(): Promise; + onEvent(listener: (event: AgentEvent) => void): Disposable; + dispose(): Promise; +} + +export interface RelayTransport { + connect(): Promise; + 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 { + 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); +} diff --git a/aether-vscodex/vscode-extension/src/relayClient.ts b/aether-vscodex/vscode-extension/src/relayClient.ts new file mode 100644 index 000000000..e1e0f9c60 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/relayClient.ts @@ -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 + > & + Omit; + 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; + // 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 { + 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; + connectionPromise = new Promise((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 { + 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 ?? ""); +} diff --git a/aether-vscodex/vscode-extension/src/relayHost.ts b/aether-vscodex/vscode-extension/src/relayHost.ts new file mode 100644 index 000000000..d9ff21df3 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/relayHost.ts @@ -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; + 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(); + private readonly inFlightCommands = new Set(); + private readonly capabilities: Set; + private eventSeq = 0; + private sessionId: string; + private started = false; + private adapterReady = false; + + constructor(options: RelayHostOptions); + constructor(adapter: AgentAdapter, relay: RelayTransport, options?: Omit); + constructor( + optionsOrAdapter: RelayHostOptions | AgentAdapter, + relayArg?: RelayTransport, + legacyOptions: Omit = {}, + ) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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): 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): "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); +} diff --git a/aether-vscodex/vscode-extension/src/switchableAgentAdapter.ts b/aether-vscodex/vscode-extension/src/switchableAgentAdapter.ts new file mode 100644 index 000000000..a78f9bd22 --- /dev/null +++ b/aether-vscodex/vscode-extension/src/switchableAgentAdapter.ts @@ -0,0 +1,425 @@ +import { + AgentAdapter, + AgentEvent, + asJsonObject, + ControlMode, + Disposable, + JsonObject, + JsonRpcId, + JsonValue, + Logger, + SessionSnapshot, +} from "./protocol"; + +export type AgentAdapterFactory = (mode: ControlMode) => AgentAdapter | Promise; + +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; + 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 | null = null; + private switchPromise: Promise | null = null; + private started = false; + private disposed = false; + + constructor(options: SwitchableAgentAdapterOptions) { + this.options = options; + this.controlMode = validateControlMode(options.initialMode); + } + + async start(): Promise { + 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 { + 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 { + 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 { + 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 { + const adapter = this.activeAdapterForMutation(); + if (!adapter.startTurn) throw unsupported("turn/start", this.controlMode); + return adapter.startTurn(params); + } + + async steerTurn(params: JsonObject): Promise { + const adapter = this.activeAdapterForMutation(); + if (!adapter.steerTurn) throw unsupported("turn/steer", this.controlMode); + return adapter.steerTurn(params); + } + + async updateThreadSettings(params: JsonObject): Promise { + const adapter = this.activeAdapterForMutation(); + if (!adapter.updateThreadSettings) throw unsupported("thread/settings/update", this.controlMode); + return adapter.updateThreadSettings(params); + } + + async listSessions(params: JsonObject = {}): Promise { + 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 { + 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 { + const adapter = this.activeAdapter(); + if (!adapter.interruptTurn) throw unsupported("turn/interrupt", this.controlMode); + return adapter.interruptTurn(params); + } + + async sendInput(text: string, params: JsonObject = {}): Promise { + return this.activeAdapterForMutation().sendInput(text, params); + } + + async cancel(taskId?: string, params: JsonObject = {}): Promise { + return this.activeAdapter().cancel(taskId, params); + } + + async respondApproval( + requestId: JsonRpcId, + decision: "allow" | "deny" | "cancel", + reason?: string, + response?: JsonValue, + ): Promise { + return this.activeAdapter().respondApproval(requestId, decision, reason, response); + } + + async denyPending(reason?: string): Promise { + await this.activeAdapter().denyPending?.(reason); + } + + async snapshot(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/aether-vscodex/vscode-extension/tsconfig.json b/aether-vscodex/vscode-extension/tsconfig.json new file mode 100644 index 000000000..b58a0919f --- /dev/null +++ b/aether-vscodex/vscode-extension/tsconfig.json @@ -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" + ] +} diff --git a/aether-vscodex/web/.gitignore b/aether-vscodex/web/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/aether-vscodex/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/aether-vscodex/web/index.html b/aether-vscodex/web/index.html new file mode 100644 index 000000000..8a0d2f16d --- /dev/null +++ b/aether-vscodex/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Codex + + +
+ + + diff --git a/aether-vscodex/web/package-lock.json b/aether-vscodex/web/package-lock.json new file mode 100644 index 000000000..deb51611f --- /dev/null +++ b/aether-vscodex/web/package-lock.json @@ -0,0 +1,2680 @@ +{ + "name": "@aether/vscodex-web", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aether/vscodex-web", + "version": "0.4.0", + "dependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.1.3", + "vue": "^3.5.20" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.1.0", + "typescript": "^5.9.2", + "vitest": "^3.2.4", + "vue-tsc": "^3.0.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.0.tgz", + "integrity": "sha512-6Clu5EKR/r6cDPYrKsu+8wenciWJJ3rhS9OEGsfDlZeZIhlJeEPGIZQHxE4lHRJCzPSq3EWMsFxQUqCvrbHQuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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 + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/aether-vscodex/web/package.json b/aether-vscodex/web/package.json new file mode 100644 index 000000000..abd573349 --- /dev/null +++ b/aether-vscodex/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@aether/vscodex-web", + "version": "0.4.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "typecheck": "vue-tsc -b --pretty false", + "test": "vitest run" + }, + "dependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.1.3", + "vue": "^3.5.20" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.1.0", + "typescript": "^5.9.2", + "vitest": "^3.2.4", + "vue-tsc": "^3.0.6" + }, + "engines": { + "node": ">=20" + } +} diff --git a/aether-vscodex/web/src/App.vue b/aether-vscodex/web/src/App.vue new file mode 100644 index 000000000..bcafc50d0 --- /dev/null +++ b/aether-vscodex/web/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/aether-vscodex/web/src/components/CodexSurface.vue b/aether-vscodex/web/src/components/CodexSurface.vue new file mode 100644 index 000000000..76e4106f6 --- /dev/null +++ b/aether-vscodex/web/src/components/CodexSurface.vue @@ -0,0 +1,269 @@ + diff --git a/aether-vscodex/web/src/main.ts b/aether-vscodex/web/src/main.ts new file mode 100644 index 000000000..62e2f50f8 --- /dev/null +++ b/aether-vscodex/web/src/main.ts @@ -0,0 +1,52 @@ +import { createApp } from "vue"; + +import appRuntimeUrl from "../../public/app.js?url"; +import embedBridgeUrl from "../../public/embed-bridge.js?url"; +import i18nRuntimeUrl from "../../public/i18n.js?url"; +import "../../public/style.css"; +import App from "./App.vue"; +import { installRequestTemplate } from "./runtime/request-template"; + +type RuntimeAsset = { + id: string; + url: string; +}; + +const runtimeAssets: RuntimeAsset[] = [ + { id: "vscodex-i18n-runtime", url: i18nRuntimeUrl }, + { id: "vscodex-embed-bridge", url: embedBridgeUrl }, + { id: "vscodex-compat-runtime", url: appRuntimeUrl }, +]; + +function loadRuntimeAsset(asset: RuntimeAsset): Promise { + const existing = document.getElementById(asset.id) as HTMLScriptElement | null; + if (existing?.dataset.loaded === "true") return Promise.resolve(); + + return new Promise((resolve, reject) => { + const script = existing ?? document.createElement("script"); + script.id = asset.id; + script.async = false; + script.src = asset.url; + script.addEventListener("load", () => { + script.dataset.loaded = "true"; + resolve(); + }, { once: true }); + script.addEventListener("error", () => reject(new Error(`Unable to load ${asset.id}`)), { once: true }); + if (!existing) document.body.append(script); + }); +} + +async function startCompatibilityRuntime(): Promise { + for (const asset of runtimeAssets) await loadRuntimeAsset(asset); +} + +createApp(App).mount("#app"); +installRequestTemplate(); + +void startCompatibilityRuntime().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const status = document.getElementById("appState"); + if (status) status.textContent = message; + document.body.dataset.runtimeError = "true"; + console.error("Failed to start the Codex compatibility runtime", error); +}); diff --git a/aether-vscodex/web/src/runtime/request-template.ts b/aether-vscodex/web/src/runtime/request-template.ts new file mode 100644 index 000000000..92153b4b8 --- /dev/null +++ b/aether-vscodex/web/src/runtime/request-template.ts @@ -0,0 +1,34 @@ +export function installRequestTemplate(): HTMLTemplateElement { + const existing = document.getElementById("requestTemplate"); + if (existing instanceof HTMLTemplateElement) return existing; + + const template = document.createElement("template"); + template.id = "requestTemplate"; + template.innerHTML = ` +
+
+

+

+      
+ +
+ 查看请求数据 +

+      
+ +
+ + + +
+
+ `; + document.body.append(template); + return template; +} diff --git a/aether-vscodex/web/src/vite-env.d.ts b/aether-vscodex/web/src/vite-env.d.ts new file mode 100644 index 000000000..be2c61784 --- /dev/null +++ b/aether-vscodex/web/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface Window { + AetherVscodexEmbed?: { + active: boolean; + stop?: () => void; + }; + VscodexI18n?: { + locale: () => string; + setLocale: (locale: string, options?: { persist?: boolean }) => string; + }; +} diff --git a/aether-vscodex/web/tests/CodexSurface.test.ts b/aether-vscodex/web/tests/CodexSurface.test.ts new file mode 100644 index 000000000..d8a352778 --- /dev/null +++ b/aether-vscodex/web/tests/CodexSurface.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { mount } from "@vue/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; + +import CodexSurface from "../src/components/CodexSurface.vue"; +import { installRequestTemplate } from "../src/runtime/request-template"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("CodexSurface", () => { + it("mounts the compatibility shell expected by the existing runtime", () => { + const wrapper = mount(CodexSurface, { attachTo: document.body }); + + expect(wrapper.find("#output").exists()).toBe(true); + expect(wrapper.find("#messageInput").attributes("contenteditable")).toBe("true"); + expect(wrapper.find("#sessionPicker").exists()).toBe(true); + expect(wrapper.find("#modelMenu").exists()).toBe(true); + expect(wrapper.find("#permissionMenu").exists()).toBe(true); + expect(wrapper.find("#requests").exists()).toBe(true); + expect(wrapper.find("#controlModeSwitch").attributes("data-mode")).toBe("sync"); + const controlModes = wrapper.findAll("#controlModeSwitch [data-control-mode]"); + expect(controlModes).toHaveLength(2); + expect(controlModes[0].attributes("aria-pressed")).toBe("true"); + expect(controlModes.every((button) => button.attributes("disabled") !== undefined)).toBe(true); + + wrapper.unmount(); + }); + + it("keeps every compatibility element from the legacy shell", () => { + mount(CodexSurface, { attachTo: document.body }); + installRequestTemplate(); + + const legacyHtml = readFileSync(resolve(process.cwd(), "../public/index.html"), "utf8"); + const legacyDocument = new DOMParser().parseFromString(legacyHtml, "text/html"); + const expected = [...legacyDocument.querySelectorAll("[id]")] + .map((element) => ({ id: element.id, tag: element.tagName, className: element.className })) + .sort((left, right) => left.id.localeCompare(right.id)); + const actual = [...document.querySelectorAll("[id]")] + .filter((element) => element.id !== "app") + .map((element) => ({ id: element.id, tag: element.tagName, className: element.className })) + .sort((left, right) => left.id.localeCompare(right.id)); + + expect(actual).toEqual(expected); + }); +}); diff --git a/aether-vscodex/web/tsconfig.app.json b/aether-vscodex/web/tsconfig.app.json new file mode 100644 index 000000000..568840ae7 --- /dev/null +++ b/aether-vscodex/web/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "jsx": "preserve", + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts"] +} diff --git a/aether-vscodex/web/tsconfig.json b/aether-vscodex/web/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/aether-vscodex/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/aether-vscodex/web/tsconfig.node.json b/aether-vscodex/web/tsconfig.node.json new file mode 100644 index 000000000..506d2e826 --- /dev/null +++ b/aether-vscodex/web/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/aether-vscodex/web/vite.config.ts b/aether-vscodex/web/vite.config.ts new file mode 100644 index 000000000..ade8df114 --- /dev/null +++ b/aether-vscodex/web/vite.config.ts @@ -0,0 +1,45 @@ +/// + +import { fileURLToPath, URL } from "node:url"; + +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +const relayTarget = "http://127.0.0.1:8787"; + +export default defineConfig({ + // Relative assets let the same build run at the local relay root and under + // Aether's /aether-vscodex/ static subpath. + base: "./", + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + server: { + fs: { + allow: [fileURLToPath(new URL("..", import.meta.url))], + }, + proxy: { + "/api": { + target: relayTarget, + changeOrigin: true, + }, + "/ws": { + target: relayTarget.replace("http", "ws"), + changeOrigin: true, + ws: true, + }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + assetsInlineLimit: 0, + }, + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/apps/aether-gateway/Cargo.toml b/apps/aether-gateway/Cargo.toml index 2488d6e5f..9351b84df 100644 --- a/apps/aether-gateway/Cargo.toml +++ b/apps/aether-gateway/Cargo.toml @@ -86,6 +86,7 @@ sysinfo = "0.32" thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["fs", "compression-gzip", "set-header"] } tracing.workspace = true diff --git a/apps/aether-gateway/src/api/backend/public.rs b/apps/aether-gateway/src/api/backend/public.rs index 628a9badf..a2f334f7a 100644 --- a/apps/aether-gateway/src/api/backend/public.rs +++ b/apps/aether-gateway/src/api/backend/public.rs @@ -1,7 +1,10 @@ use axum::routing::get; use axum::Router; -use crate::{handlers::proxy::proxy_request, state::AppState}; +use crate::{ + handlers::{proxy::proxy_request, public::vscodex_ws_proxy}, + state::AppState, +}; pub(crate) fn mount_public_support_routes(router: Router) -> Router { router @@ -26,6 +29,7 @@ pub(crate) fn mount_public_support_routes(router: Router) -> Router() .map(|value| value.0.as_str()), + client_ip, local_proxy_body.as_ref(), ) .await diff --git a/apps/aether-gateway/src/handlers/public/mod.rs b/apps/aether-gateway/src/handlers/public/mod.rs index d666a3dd9..66d0070f1 100644 --- a/apps/aether-gateway/src/handlers/public/mod.rs +++ b/apps/aether-gateway/src/handlers/public/mod.rs @@ -28,5 +28,5 @@ pub(crate) use self::support::{ build_api_key_install_session_response, build_proxy_node_install_session_response, build_unhandled_public_support_response, matches_model_mapping_for_models, maybe_build_local_admin_announcements_response, maybe_build_local_public_support_response, - CreateApiKeyInstallSessionRequest, + vscodex_ws_proxy, CreateApiKeyInstallSessionRequest, }; diff --git a/apps/aether-gateway/src/handlers/public/support.rs b/apps/aether-gateway/src/handlers/public/support.rs index 4a14e2819..dddf8e92f 100644 --- a/apps/aether-gateway/src/handlers/public/support.rs +++ b/apps/aether-gateway/src/handlers/public/support.rs @@ -48,6 +48,8 @@ mod support_payment; mod support_test_connection; #[path = "support/user_me.rs"] mod support_user_me; +#[path = "support/user_me_vscodex.rs"] +mod support_vscodex; #[path = "support/wallet.rs"] mod support_wallet; @@ -89,6 +91,8 @@ use self::support_oauth::maybe_build_local_oauth_response; use self::support_payment::maybe_build_local_payment_callback_response; use self::support_test_connection::maybe_build_local_test_connection_response; use self::support_user_me::maybe_build_local_users_me_response; +pub(crate) use self::support_vscodex::vscodex_ws_proxy; +use self::support_vscodex::{handle_users_me_vscodex_request, maybe_build_local_vscodex_response}; use self::support_wallet::{ build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user, build_wallet_live_today_usage_payload_for_api_key, @@ -121,6 +125,7 @@ pub(crate) async fn maybe_build_local_public_support_response( request_context: &GatewayPublicRequestContext, headers: &http::HeaderMap, cf_connecting_ip: Option<&str>, + client_ip: std::net::IpAddr, request_body: Option<&Bytes>, ) -> Option> { let decision = request_context.control_decision.as_ref()?; @@ -192,6 +197,11 @@ pub(crate) async fn maybe_build_local_public_support_response( .await; } + if decision.route_family.as_deref() == Some("vscodex") { + return maybe_build_local_vscodex_response(state, request_context, client_ip, request_body) + .await; + } + if decision.route_family.as_deref() == Some("install") { return maybe_build_local_install_response(state, request_context).await; } diff --git a/apps/aether-gateway/src/handlers/public/support/user_me.rs b/apps/aether-gateway/src/handlers/public/support/user_me.rs index c9672bbdc..f5ad65b66 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me.rs @@ -2,8 +2,9 @@ use super::{ auth_password_policy_level, base_url_from_request, build_auth_error_response, build_auth_wallet_summary_payload, decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me, - handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value, - resolve_authenticated_local_user, sanitize_public_model_config_for_user, unix_secs_to_rfc3339, + handle_users_me_api_key_install_session_create, handle_users_me_vscodex_request, + query_param_optional_bool, query_param_value, resolve_authenticated_local_user, + sanitize_public_model_config_for_user, unix_secs_to_rfc3339, users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState, AuthenticatedLocalUserContext, GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS, }; diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs b/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs index 377b12a1a..e6b11bddc 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs @@ -18,9 +18,10 @@ use super::{ handle_users_me_preferences_put, handle_users_me_providers_get, handle_users_me_referral_get, handle_users_me_sessions_get, handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get, handle_users_me_usage_heatmap_get, - handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches, - users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches, - users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches, + handle_users_me_usage_interval_timeline_get, handle_users_me_vscodex_request, + users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches, + users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches, + users_me_management_token_detail_path_matches, users_me_management_token_regenerate_path_matches, users_me_management_token_toggle_path_matches, users_me_management_tokens_root, users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext, @@ -55,6 +56,14 @@ pub(crate) async fn maybe_build_local_users_me_response( { Some(handle_users_me_delete_other_sessions(state, request_context, headers).await) } + Some( + "vscodex_devices_list" + | "vscodex_pairing_create" + | "vscodex_device_delete" + | "vscodex_ws_ticket_create", + ) => Some( + handle_users_me_vscodex_request(state, request_context, headers, request_body).await, + ), Some("session_delete") if users_me_session_detail_path_matches(&request_context.request_path) => { diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_vscodex.rs b/apps/aether-gateway/src/handlers/public/support/user_me_vscodex.rs new file mode 100644 index 000000000..df2014e05 --- /dev/null +++ b/apps/aether-gateway/src/handlers/public/support/user_me_vscodex.rs @@ -0,0 +1,931 @@ +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use axum::body::{Body, Bytes}; +use axum::extract::{ + ws::{CloseFrame as AxumCloseFrame, Message as AxumMessage, WebSocket, WebSocketUpgrade}, + ConnectInfo, State, +}; +use axum::http::{self, header}; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use tokio::sync::Semaphore; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::{ + CloseFrame as TungsteniteCloseFrame, WebSocketConfig, +}; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tracing::warn; + +use super::{ + build_auth_error_response, build_auth_json_response, module_available_from_env, + resolve_authenticated_local_user, AppState, GatewayPublicRequestContext, +}; + +const VSCODEX_ENABLED_ENV: &str = "AETHER_VSCODEX_ENABLED"; +const VSCODEX_INTERNAL_URL_ENV: &str = "AETHER_VSCODEX_INTERNAL_URL"; +const VSCODEX_INTERNAL_TOKEN_ENV: &str = "AETHER_VSCODEX_INTERNAL_TOKEN"; +const VSCODEX_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const VSCODEX_MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const VSCODEX_WS_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; +const VSCODEX_WS_MAX_CONNECTIONS: usize = 256; +const VSCODEX_WS_MAX_CONNECTIONS_PER_IP: usize = 16; +const VSCODEX_DEVICE_PATH_PREFIX: &str = "/api/users/me/vscodex/devices/"; +const VSCODEX_CLIENT_IP_HEADER: &str = "x-aether-client-ip"; + +static VSCODEX_HTTP_CLIENT: LazyLock> = + LazyLock::new(|| { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + }); +static VSCODEX_WS_CONNECTIONS: LazyLock> = + LazyLock::new(|| Arc::new(Semaphore::new(VSCODEX_WS_MAX_CONNECTIONS))); +static VSCODEX_WS_CONNECTIONS_BY_IP: LazyLock> = + LazyLock::new(|| { + Arc::new(VscodexWsIpConnectionLimiter::new( + VSCODEX_WS_MAX_CONNECTIONS_PER_IP, + )) + }); + +#[derive(Debug)] +struct VscodexWsIpConnectionLimiter { + max_connections: usize, + active: Mutex>, +} + +impl VscodexWsIpConnectionLimiter { + fn new(max_connections: usize) -> Self { + Self { + max_connections: max_connections.max(1), + active: Mutex::new(HashMap::new()), + } + } + + fn try_acquire(self: &Arc, client_ip: IpAddr) -> Option { + let mut active = self + .active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current = active.get(&client_ip).copied().unwrap_or_default(); + if current >= self.max_connections { + return None; + } + active.insert(client_ip, current.saturating_add(1)); + Some(VscodexWsIpConnectionPermit { + limiter: Arc::clone(self), + client_ip, + }) + } + + fn release(&self, client_ip: IpAddr) { + let mut active = self + .active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(current) = active.get_mut(&client_ip) else { + return; + }; + if *current <= 1 { + active.remove(&client_ip); + } else { + *current -= 1; + } + } + + #[cfg(test)] + fn active_ip_count(&self) -> usize { + self.active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } +} + +#[derive(Debug)] +struct VscodexWsIpConnectionPermit { + limiter: Arc, + client_ip: IpAddr, +} + +impl Drop for VscodexWsIpConnectionPermit { + fn drop(&mut self) { + self.limiter.release(self.client_ip); + } +} + +#[derive(Debug)] +struct VscodexSidecarConfig { + base_url: reqwest::Url, + authorization: reqwest::header::HeaderValue, + http_client: reqwest::Client, +} + +#[derive(Debug, Default, Deserialize)] +struct CreatePairingRequest { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateWsTicketRequest { + device_id: String, +} + +#[derive(Debug, Deserialize)] +struct ExchangePairingRequest { + code: String, + name: Option, +} + +pub(crate) async fn vscodex_ws_proxy( + State(state): State, + ConnectInfo(remote_addr): ConnectInfo, + ws: WebSocketUpgrade, + headers: http::HeaderMap, +) -> Response { + let request_permit = match state.try_acquire_request_permit().await { + Ok(value) => value, + Err(err) => { + warn!(error = ?err, "VS Codex WebSocket request admission rejected"); + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "服务繁忙,请稍后重试", + false, + ); + } + }; + let client_ip = crate::headers::effective_client_ip(&headers, &remote_addr); + match state.admin_security_ip_blacklisted(client_ip).await { + Ok(true) => { + return build_auth_error_response( + http::StatusCode::FORBIDDEN, + "当前 IP 已被禁止访问", + false, + ) + } + Ok(false) => {} + Err(err) => warn!( + client_ip = %client_ip, + error = ?err, + "VS Codex WebSocket IP blacklist check failed open" + ), + } + let connection_permit = match Arc::clone(&VSCODEX_WS_CONNECTIONS).try_acquire_owned() { + Ok(value) => value, + Err(_) => { + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 连接数已达上限", + false, + ) + } + }; + // Only active connections have entries, and each already owns one of the 256 global slots. + let ip_connection_permit = match VSCODEX_WS_CONNECTIONS_BY_IP.try_acquire(client_ip) { + Some(value) => value, + None => { + warn!( + client_ip = %client_ip, + limit = VSCODEX_WS_MAX_CONNECTIONS_PER_IP, + "VS Codex per-IP WebSocket connection limit reached" + ); + let mut response = build_auth_error_response( + http::StatusCode::TOO_MANY_REQUESTS, + "当前 IP 的 VS Codex 连接数已达上限", + false, + ); + response + .headers_mut() + .insert(header::RETRY_AFTER, http::HeaderValue::from_static("1")); + return response; + } + }; + let config = match load_vscodex_sidecar_config() { + Ok(Some(value)) => value, + Ok(None) => { + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务未启用", + false, + ) + } + Err(detail) => { + warn!(error = %detail, "VS Codex WebSocket sidecar configuration is invalid"); + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + ); + } + }; + let sidecar_url = match build_vscodex_websocket_url(&config.base_url) { + Ok(value) => value, + Err(detail) => { + warn!(error = %detail, "could not build VS Codex sidecar WebSocket URL"); + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + ); + } + }; + let mut sidecar_request = match sidecar_url.as_str().into_client_request() { + Ok(value) => value, + Err(err) => { + warn!(error = %err, "could not build VS Codex sidecar WebSocket request"); + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + ); + } + }; + for header_name in [header::ORIGIN, header::SEC_WEBSOCKET_PROTOCOL] { + if let Some(value) = headers.get(&header_name) { + sidecar_request + .headers_mut() + .insert(header_name, value.clone()); + } + } + + let mut sidecar_config = WebSocketConfig::default(); + sidecar_config.max_message_size = Some(VSCODEX_WS_MAX_MESSAGE_BYTES); + sidecar_config.max_frame_size = Some(VSCODEX_WS_MAX_MESSAGE_BYTES); + let (sidecar_socket, sidecar_response) = match tokio::time::timeout( + VSCODEX_REQUEST_TIMEOUT, + tokio_tungstenite::connect_async_with_config(sidecar_request, Some(sidecar_config), true), + ) + .await + { + Ok(Ok(value)) => value, + Ok(Err(err)) => { + warn!(error = %err, "VS Codex sidecar WebSocket handshake failed"); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务暂时不可用", + false, + ); + } + Err(_) => { + warn!("VS Codex sidecar WebSocket handshake timed out"); + return build_auth_error_response( + http::StatusCode::GATEWAY_TIMEOUT, + "VS Codex 服务请求超时", + false, + ); + } + }; + + let selected_protocol = sidecar_response + .headers() + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let ws = ws + .max_message_size(VSCODEX_WS_MAX_MESSAGE_BYTES) + .max_frame_size(VSCODEX_WS_MAX_MESSAGE_BYTES); + let ws = match selected_protocol { + Some(protocol) => ws.protocols([protocol]), + None => ws, + }; + drop(request_permit); + ws.on_upgrade(move |browser_socket| async move { + let _connection_permit = connection_permit; + bridge_vscodex_websockets(browser_socket, sidecar_socket, ip_connection_permit).await; + }) +} + +pub(super) async fn maybe_build_local_vscodex_response( + _state: &AppState, + request_context: &GatewayPublicRequestContext, + client_ip: std::net::IpAddr, + request_body: Option<&Bytes>, +) -> Option> { + let decision = request_context.control_decision.as_ref()?; + if decision.route_family.as_deref() != Some("vscodex") { + return None; + } + if decision.route_kind.as_deref() != Some("pairing_exchange") + || !matches!( + request_context.request_path.as_str(), + "/api/vscodex/pair" | "/api/vscodex/pair/" + ) + { + return Some(build_auth_error_response( + http::StatusCode::NOT_FOUND, + "VS Codex 接口不存在", + false, + )); + } + + let config = match load_vscodex_sidecar_config() { + Ok(Some(value)) => value, + Ok(None) => { + return Some(build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务未启用", + false, + )) + } + Err(detail) => { + warn!( + error = %detail, + "VS Codex sidecar configuration is invalid" + ); + return Some(build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + )); + } + }; + let payload = match parse_pairing_exchange_request(request_body) { + Ok(value) => value, + Err(response) => return Some(response), + }; + let url = match append_vscodex_sidecar_path(&config.base_url, &["v1", "pairings", "exchange"]) { + Ok(value) => value, + Err(detail) => { + warn!(error = %detail, "could not build VS Codex pairing exchange URL"); + return Some(build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + )); + } + }; + let request = + build_authenticated_sidecar_request(&config, reqwest::Method::POST, url, Some(payload)) + .header(VSCODEX_CLIENT_IP_HEADER, client_ip.to_string()); + Some(send_vscodex_sidecar_request(request, "public", "pairing_exchange").await) +} + +pub(super) async fn handle_users_me_vscodex_request( + state: &AppState, + request_context: &GatewayPublicRequestContext, + headers: &http::HeaderMap, + request_body: Option<&Bytes>, +) -> Response { + let auth = match resolve_authenticated_local_user(state, request_context, headers).await { + Ok(value) => value, + Err(response) => return response, + }; + let config = match load_vscodex_sidecar_config() { + Ok(Some(value)) => value, + Ok(None) => { + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务未启用", + false, + ) + } + Err(detail) => { + warn!( + user_id = %auth.user.id, + error = %detail, + "VS Codex sidecar configuration is invalid" + ); + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + ); + } + }; + + let Some(route_kind) = request_context + .control_decision + .as_ref() + .and_then(|decision| decision.route_kind.as_deref()) + else { + return build_auth_error_response( + http::StatusCode::NOT_FOUND, + "VS Codex 接口不存在", + false, + ); + }; + + let request = match build_vscodex_sidecar_request( + &config, + &auth.user.id, + route_kind, + &request_context.request_path, + request_body, + ) { + Ok(value) => value, + Err(response) => return response, + }; + + send_vscodex_sidecar_request(request, &auth.user.id, route_kind).await +} + +fn load_vscodex_sidecar_config() -> Result, String> { + if !module_available_from_env(VSCODEX_ENABLED_ENV, false) { + return Ok(None); + } + + let raw_url = required_env(VSCODEX_INTERNAL_URL_ENV)?; + let base_url = reqwest::Url::parse(&raw_url) + .map_err(|err| format!("{VSCODEX_INTERNAL_URL_ENV} is invalid: {err}"))?; + if !matches!(base_url.scheme(), "http" | "https") + || !base_url.has_host() + || !base_url.username().is_empty() + || base_url.password().is_some() + || base_url.query().is_some() + || base_url.fragment().is_some() + || base_url.cannot_be_a_base() + { + return Err(format!( + "{VSCODEX_INTERNAL_URL_ENV} must be an HTTP(S) base URL without credentials, query, or fragment" + )); + } + + let token = required_env(VSCODEX_INTERNAL_TOKEN_ENV)?; + let authorization = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| format!("{VSCODEX_INTERNAL_TOKEN_ENV} is not a valid HTTP credential"))?; + let http_client = VSCODEX_HTTP_CLIENT + .as_ref() + .map_err(|err| format!("could not initialize VS Codex HTTP client: {err}"))? + .clone(); + + Ok(Some(VscodexSidecarConfig { + base_url, + authorization, + http_client, + })) +} + +fn required_env(key: &str) -> Result { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("{key} is required")) +} + +fn build_vscodex_sidecar_request( + config: &VscodexSidecarConfig, + user_id: &str, + route_kind: &str, + request_path: &str, + request_body: Option<&Bytes>, +) -> Result> { + let (method, suffix, payload) = match route_kind { + "vscodex_devices_list" => (reqwest::Method::GET, vec!["devices"], None), + "vscodex_pairing_create" => ( + reqwest::Method::POST, + vec!["pairings"], + Some(parse_pairing_request(request_body)?), + ), + "vscodex_device_delete" => { + let Some(device_id) = vscodex_device_id_from_path(request_path) else { + return Err(build_auth_error_response( + http::StatusCode::BAD_REQUEST, + "设备标识无效", + false, + )); + }; + (reqwest::Method::DELETE, vec!["devices", device_id], None) + } + "vscodex_ws_ticket_create" => ( + reqwest::Method::POST, + vec!["ws-tickets"], + Some(parse_ws_ticket_request(request_body)?), + ), + _ => { + return Err(build_auth_error_response( + http::StatusCode::NOT_FOUND, + "VS Codex 接口不存在", + false, + )) + } + }; + let url = build_vscodex_sidecar_url(&config.base_url, user_id, &suffix).map_err(|detail| { + warn!(user_id = %user_id, error = %detail, "could not build VS Codex sidecar URL"); + build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "VS Codex 服务配置不完整", + false, + ) + })?; + + Ok(build_authenticated_sidecar_request( + config, method, url, payload, + )) +} + +fn build_authenticated_sidecar_request( + config: &VscodexSidecarConfig, + method: reqwest::Method, + url: reqwest::Url, + payload: Option, +) -> reqwest::RequestBuilder { + let mut request = config + .http_client + .request(method, url) + .header(header::AUTHORIZATION, config.authorization.clone()) + .header(header::ACCEPT, "application/json") + .timeout(VSCODEX_REQUEST_TIMEOUT); + if let Some(payload) = payload { + request = request.json(&payload); + } + request +} + +fn build_vscodex_sidecar_url( + base_url: &reqwest::Url, + user_id: &str, + suffix: &[&str], +) -> Result { + let mut segments = vec!["internal", "v1", "users", user_id]; + segments.extend(suffix.iter().copied()); + append_vscodex_sidecar_path(base_url, &segments) +} + +fn append_vscodex_sidecar_path( + base_url: &reqwest::Url, + suffix: &[&str], +) -> Result { + let mut url = base_url.clone(); + let mut path_segments = url + .path_segments_mut() + .map_err(|_| "VS Codex sidecar URL cannot contain path segments".to_string())?; + path_segments.pop_if_empty(); + path_segments.extend(suffix.iter().copied()); + drop(path_segments); + Ok(url) +} + +fn build_vscodex_websocket_url(base_url: &reqwest::Url) -> Result { + let mut url = append_vscodex_sidecar_path(base_url, &["api", "vscodex", "ws"])?; + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + _ => return Err("VS Codex sidecar URL must use HTTP(S)".to_string()), + }; + url.set_scheme(scheme) + .map_err(|_| "could not convert VS Codex sidecar URL to WebSocket".to_string())?; + Ok(url) +} + +fn parse_pairing_request(request_body: Option<&Bytes>) -> Result> { + let payload = parse_json_request::(request_body, true)?; + let mut object = Map::new(); + if let Some(name) = payload.name { + object.insert("name".to_string(), Value::String(name)); + } + Ok(Value::Object(object)) +} + +fn parse_ws_ticket_request(request_body: Option<&Bytes>) -> Result> { + let payload = parse_json_request::(request_body, false)?; + let device_id = payload.device_id.trim(); + if !valid_vscodex_device_id(device_id) { + return Err(build_auth_error_response( + http::StatusCode::BAD_REQUEST, + "设备标识无效", + false, + )); + } + Ok(json!({ "device_id": device_id })) +} + +fn parse_pairing_exchange_request(request_body: Option<&Bytes>) -> Result> { + let payload = parse_json_request::(request_body, false)?; + let code = payload.code.trim(); + if code.is_empty() || code.len() > 256 || code.chars().any(char::is_control) { + return Err(build_auth_error_response( + http::StatusCode::BAD_REQUEST, + "配对码无效", + false, + )); + } + let mut object = Map::from_iter([("code".to_string(), Value::String(code.to_string()))]); + if let Some(name) = payload.name { + object.insert("name".to_string(), Value::String(name)); + } + Ok(Value::Object(object)) +} + +fn parse_json_request( + request_body: Option<&Bytes>, + empty_object_allowed: bool, +) -> Result> +where + T: serde::de::DeserializeOwned, +{ + let body = request_body.filter(|body| !body.is_empty()); + let result = match body { + Some(body) => serde_json::from_slice(body), + None if empty_object_allowed => serde_json::from_slice(b"{}"), + None => { + return Err(build_auth_error_response( + http::StatusCode::BAD_REQUEST, + "缺少请求体", + false, + )) + } + }; + result.map_err(|_| { + build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false) + }) +} + +fn vscodex_device_id_from_path(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + let device_id = trimmed.strip_prefix(VSCODEX_DEVICE_PATH_PREFIX)?; + if device_id.contains('/') || !valid_vscodex_device_id(device_id) { + return None; + } + Some(device_id) +} + +fn valid_vscodex_device_id(value: &str) -> bool { + !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) +} + +async fn send_vscodex_sidecar_request( + request: reqwest::RequestBuilder, + request_scope: &str, + operation: &str, +) -> Response { + let mut upstream = match request.send().await { + Ok(value) => value, + Err(err) => { + warn!( + request_scope = %request_scope, + operation = %operation, + error = %err, + "VS Codex sidecar request failed" + ); + let (status, detail) = if err.is_timeout() { + (http::StatusCode::GATEWAY_TIMEOUT, "VS Codex 服务请求超时") + } else { + (http::StatusCode::BAD_GATEWAY, "VS Codex 服务暂时不可用") + }; + return build_auth_error_response(status, detail, false); + } + }; + let status = http::StatusCode::from_u16(upstream.status().as_u16()) + .unwrap_or(http::StatusCode::BAD_GATEWAY); + + if matches!( + status, + http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN + ) { + warn!( + request_scope = %request_scope, + operation = %operation, + upstream_status = status.as_u16(), + "VS Codex sidecar rejected gateway credentials" + ); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务鉴权失败", + false, + ); + } + if status.is_redirection() { + warn!( + request_scope = %request_scope, + operation = %operation, + upstream_status = status.as_u16(), + "VS Codex sidecar returned an unexpected redirect" + ); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务返回无效响应", + false, + ); + } + if status == http::StatusCode::NO_CONTENT { + return vscodex_no_store_response(status.into_response(), None); + } + + let mut response_body = Vec::new(); + while let Some(chunk) = match upstream.chunk().await { + Ok(value) => value, + Err(err) => { + warn!( + request_scope = %request_scope, + operation = %operation, + error = %err, + "could not read VS Codex sidecar response" + ); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务返回无效响应", + false, + ); + } + } { + if response_body.len().saturating_add(chunk.len()) > VSCODEX_MAX_RESPONSE_BYTES { + warn!( + request_scope = %request_scope, + operation = %operation, + "VS Codex sidecar response exceeded the size limit" + ); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务返回无效响应", + false, + ); + } + response_body.extend_from_slice(&chunk); + } + + if response_body.is_empty() { + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务返回无效响应", + false, + ); + } + let payload = match serde_json::from_slice(&response_body) { + Ok(value) => value, + Err(err) => { + warn!( + request_scope = %request_scope, + operation = %operation, + upstream_status = status.as_u16(), + error = %err, + "VS Codex sidecar returned non-JSON data" + ); + return build_auth_error_response( + http::StatusCode::BAD_GATEWAY, + "VS Codex 服务返回无效响应", + false, + ); + } + }; + let retry_after = upstream.headers().get(header::RETRY_AFTER).cloned(); + vscodex_no_store_response(build_auth_json_response(status, payload, None), retry_after) +} + +fn vscodex_no_store_response( + mut response: Response, + retry_after: Option, +) -> Response { + response.headers_mut().insert( + header::CACHE_CONTROL, + http::HeaderValue::from_static("no-store"), + ); + if let Some(retry_after) = retry_after { + response + .headers_mut() + .insert(header::RETRY_AFTER, retry_after); + } + response +} + +async fn bridge_vscodex_websockets( + browser_socket: WebSocket, + sidecar_socket: S, + ip_connection_permit: VscodexWsIpConnectionPermit, +) where + S: futures_util::Stream< + Item = Result, + > + futures_util::Sink + + Unpin + + Send + + 'static, +{ + let (mut browser_tx, mut browser_rx) = browser_socket.split(); + let (mut sidecar_tx, mut sidecar_rx) = sidecar_socket.split(); + let mut ip_connection_permit = Some(ip_connection_permit); + + loop { + tokio::select! { + browser_message = browser_rx.next() => { + match browser_message { + Some(Ok(message)) => { + let close = matches!(message, AxumMessage::Close(_)); + if let Err(err) = sidecar_tx.send(axum_to_tungstenite_message(message)).await { + warn!(error = %err, "could not forward VS Codex browser WebSocket frame"); + break; + } + if close { + break; + } + } + Some(Err(err)) => { + warn!(error = %err, "VS Codex browser WebSocket read failed"); + break; + } + None => break, + } + } + sidecar_message = sidecar_rx.next() => { + match sidecar_message { + Some(Ok(TungsteniteMessage::Frame(_))) => continue, + Some(Ok(message)) => { + if ip_connection_permit.is_some() && vscodex_ws_authentication_succeeded(&message) { + ip_connection_permit.take(); + } + let close = matches!(message, TungsteniteMessage::Close(_)); + if let Err(err) = browser_tx.send(tungstenite_to_axum_message(message)).await { + warn!(error = %err, "could not forward VS Codex sidecar WebSocket frame"); + break; + } + if close { + break; + } + } + Some(Err(err)) => { + warn!(error = %err, "VS Codex sidecar WebSocket read failed"); + break; + } + None => break, + } + } + } + } + + let _ = sidecar_tx.close().await; + let _ = browser_tx.close().await; +} + +fn vscodex_ws_authentication_succeeded(message: &TungsteniteMessage) -> bool { + let TungsteniteMessage::Text(text) = message else { + return false; + }; + serde_json::from_str::(text.as_ref()) + .ok() + .and_then(|payload| { + payload + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .as_deref() + == Some("auth.ok") +} + +fn axum_to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage { + match message { + AxumMessage::Text(text) => TungsteniteMessage::Text(text.to_string().into()), + AxumMessage::Binary(bytes) => TungsteniteMessage::Binary(bytes), + AxumMessage::Ping(bytes) => TungsteniteMessage::Ping(bytes), + AxumMessage::Pong(bytes) => TungsteniteMessage::Pong(bytes), + AxumMessage::Close(frame) => { + TungsteniteMessage::Close(frame.map(|frame| TungsteniteCloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + })) + } + } +} + +fn tungstenite_to_axum_message(message: TungsteniteMessage) -> AxumMessage { + match message { + TungsteniteMessage::Text(text) => AxumMessage::Text(text.to_string().into()), + TungsteniteMessage::Binary(bytes) => AxumMessage::Binary(bytes), + TungsteniteMessage::Ping(bytes) => AxumMessage::Ping(bytes), + TungsteniteMessage::Pong(bytes) => AxumMessage::Pong(bytes), + TungsteniteMessage::Close(frame) => AxumMessage::Close(frame.map(|frame| AxumCloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + })), + TungsteniteMessage::Frame(_) => AxumMessage::Close(None), + } +} + +#[cfg(test)] +mod tests { + use super::{vscodex_ws_authentication_succeeded, VscodexWsIpConnectionLimiter}; + use std::sync::Arc; + use tokio_tungstenite::tungstenite::Message; + + #[test] + fn vscodex_ws_ip_limiter_releases_and_removes_inactive_ips() { + let limiter = Arc::new(VscodexWsIpConnectionLimiter::new(1)); + let client_ip = "198.51.100.10".parse().expect("IP should parse"); + + let permit = limiter + .try_acquire(client_ip) + .expect("first connection should acquire"); + assert_eq!(limiter.active_ip_count(), 1); + assert!(limiter.try_acquire(client_ip).is_none()); + + drop(permit); + assert_eq!(limiter.active_ip_count(), 0); + assert!(limiter.try_acquire(client_ip).is_some()); + } + + #[test] + fn vscodex_ws_ip_limiter_releases_only_after_sidecar_auth_success() { + assert!(vscodex_ws_authentication_succeeded(&Message::Text( + r#"{"type":"auth.ok","role":"operator"}"#.into() + ))); + assert!(!vscodex_ws_authentication_succeeded(&Message::Text( + r#"{"type":"auth","token":"client-controlled"}"#.into() + ))); + assert!(!vscodex_ws_authentication_succeeded(&Message::Binary( + br#"{"type":"auth.ok"}"#.to_vec().into() + ))); + } +} diff --git a/apps/aether-gateway/src/handlers/shared/request_utils.rs b/apps/aether-gateway/src/handlers/shared/request_utils.rs index 5c268e02f..f262aaeef 100644 --- a/apps/aether-gateway/src/handlers/shared/request_utils.rs +++ b/apps/aether-gateway/src/handlers/shared/request_utils.rs @@ -495,8 +495,14 @@ pub(crate) fn public_support_local_requires_buffered_body( Some( "api_keys_create" | "api_key_install_session_create" - | "management_tokens_create", + | "management_tokens_create" + | "vscodex_pairing_create" + | "vscodex_ws_ticket_create", ), + ) | ( + Some("vscodex"), + http::Method::POST, + Some("pairing_exchange"), ) | ( Some("wallet"), http::Method::POST, diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support.rs b/apps/aether-gateway/src/tests/frontdoor/public_support.rs index 6eb1fd7a1..90e5ebaba 100644 --- a/apps/aether-gateway/src/tests/frontdoor/public_support.rs +++ b/apps/aether-gateway/src/tests/frontdoor/public_support.rs @@ -49,6 +49,8 @@ use chrono::{TimeZone, Utc}; #[path = "public_support/dashboard.rs"] mod dashboard; +#[path = "public_support/vscodex.rs"] +mod vscodex; #[tokio::test] async fn gateway_handles_public_announcements_list_without_proxying_upstream() { diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs b/apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs new file mode 100644 index 000000000..3229a8ad9 --- /dev/null +++ b/apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs @@ -0,0 +1,578 @@ +use super::{ + any, build_router_with_state, build_test_auth_token, json, sample_auth_session, + sample_auth_user, sample_auth_wallet, set_test_env_var, start_auth_gateway_with_state, + start_server, AppState, Arc, Json, Mutex, Request, Router, StatusCode, Utc, +}; +use axum::extract::ws::{Message as AxumWsMessage, WebSocketUpgrade}; +use axum::response::IntoResponse; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; + +#[derive(Debug, Clone, PartialEq)] +struct CapturedSidecarRequest { + method: http::Method, + path: String, + authorization: Option, + client_ip: Option, + body: Option, +} + +#[test] +fn gateway_authenticates_and_proxies_vscodex_bff_routes() { + std::thread::Builder::new() + .name("vscodex-gateway-test".to_string()) + .stack_size(32 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(32 * 1024 * 1024) + .build() + .expect("test runtime should build") + .block_on(run_vscodex_gateway_integration()); + }) + .expect("test thread should spawn") + .join() + .expect("test thread should complete"); +} + +async fn run_vscodex_gateway_integration() { + let captured_requests = Arc::new(Mutex::new(Vec::::new())); + let captured_requests_for_handler = Arc::clone(&captured_requests); + let captured_ws_handshake = Arc::new(Mutex::new(None::<(Option, Option)>)); + let captured_ws_handshake_for_handler = Arc::clone(&captured_ws_handshake); + let sidecar = Router::new() + .route( + "/api/vscodex/ws", + any(move |ws: WebSocketUpgrade, headers: http::HeaderMap| { + let captured_ws_handshake = Arc::clone(&captured_ws_handshake_for_handler); + async move { + let origin = headers + .get(http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let authorization = headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + *captured_ws_handshake + .lock() + .expect("WebSocket handshake store should lock") = + Some((origin, authorization)); + ws.protocols(["vscodex.v1"]) + .on_upgrade(|mut socket| async move { + while let Some(Ok(message)) = socket.next().await { + match message { + AxumWsMessage::Text(text) => { + let response = if text + == r#"{"type":"auth","token":"test-auth-ok"}"# { + r#"{"type":"auth.ok","role":"operator"}"#.to_string() + } else { + format!("echo:{text}") + }; + if socket + .send(AxumWsMessage::Text(response.into())) + .await + .is_err() + { + break; + } + } + AxumWsMessage::Binary(bytes) => { + if socket.send(AxumWsMessage::Binary(bytes)).await.is_err() + { + break; + } + } + AxumWsMessage::Close(frame) => { + let _ = socket.send(AxumWsMessage::Close(frame)).await; + break; + } + AxumWsMessage::Ping(_) | AxumWsMessage::Pong(_) => {} + } + } + }) + } + }), + ) + .route( + "/{*path}", + any(move |request: Request| { + let captured_requests = Arc::clone(&captured_requests_for_handler); + async move { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let authorization = request + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let client_ip = request + .headers() + .get("x-aether-client-ip") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) + .await + .expect("sidecar request body should be readable"); + let body = (!body.is_empty()).then(|| { + serde_json::from_slice(&body).expect("sidecar request body should be JSON") + }); + captured_requests + .lock() + .expect("captured request store should lock") + .push(CapturedSidecarRequest { + method: method.clone(), + path: path.clone(), + authorization, + client_ip, + body, + }); + + let (status, payload) = match (method, path.as_str()) { + (http::Method::GET, "/internal/v1/users/user-auth-1/devices") => ( + StatusCode::OK, + json!({ "devices": [{ "id": "host-1", "name": "My Mac" }] }), + ), + (http::Method::POST, "/internal/v1/users/user-auth-1/pairings") => { + (StatusCode::CREATED, json!({ "code": "PAIR-123" })) + } + (http::Method::POST, "/internal/v1/users/user-auth-1/ws-tickets") => ( + StatusCode::CREATED, + json!({ + "ticket": "ticket-123", + "ws_url": "wss://aether.example/vscodex/ws" + }), + ), + (http::Method::DELETE, "/internal/v1/users/user-auth-1/devices/host-1") => { + (StatusCode::NO_CONTENT, json!({})) + } + ( + http::Method::DELETE, + "/internal/v1/users/user-auth-1/devices/missing", + ) => (StatusCode::NOT_FOUND, json!({ "detail": "设备不存在" })), + ( + http::Method::DELETE, + "/internal/v1/users/user-auth-1/devices/internal-denied", + ) => ( + StatusCode::UNAUTHORIZED, + json!({ "detail": "internal token invalid" }), + ), + ( + http::Method::DELETE, + "/internal/v1/users/user-auth-1/devices/redirect", + ) => (StatusCode::TEMPORARY_REDIRECT, json!({ "redirect": true })), + ( + http::Method::DELETE, + "/internal/v1/users/user-auth-1/devices/empty-ok", + ) => return StatusCode::OK.into_response(), + (http::Method::POST, "/v1/pairings/exchange") => ( + StatusCode::CREATED, + json!({ "device_id": "host-2", "device_token": "host-secret" }), + ), + _ => ( + StatusCode::NOT_FOUND, + json!({ "detail": "unexpected path" }), + ), + }; + let mut response = (status, Json(payload)).into_response(); + if status == StatusCode::TEMPORARY_REDIRECT { + response.headers_mut().insert( + http::header::LOCATION, + "/redirect-must-not-be-followed".parse().unwrap(), + ); + } + response + } + }), + ); + let (sidecar_url, sidecar_handle) = start_server(sidecar).await; + let _enabled = set_test_env_var("AETHER_VSCODEX_ENABLED", "true"); + let _internal_url = set_test_env_var("AETHER_VSCODEX_INTERNAL_URL", &sidecar_url); + let _internal_token = set_test_env_var("AETHER_VSCODEX_INTERNAL_TOKEN", "sidecar-secret"); + + let now = Utc::now(); + let user = sample_auth_user(now); + let access_token = build_test_auth_token( + "access", + serde_json::Map::from_iter([ + ("user_id".to_string(), json!(user.id)), + ("role".to_string(), json!(user.role)), + ( + "created_at".to_string(), + json!(user.created_at.map(|value| value.to_rfc3339())), + ), + ("session_id".to_string(), json!("session-vscodex")), + ]), + now + chrono::Duration::hours(1), + ); + let (gateway_url, upstream_hits, gateway_handle, upstream_handle) = + start_auth_gateway_with_state( + user, + sample_auth_wallet("user-auth-1", now), + [sample_auth_session( + "user-auth-1", + "session-vscodex", + "browser-device-vscodex", + "refresh-vscodex", + now, + )], + ) + .await; + let client = reqwest::Client::new(); + + let unauthenticated = client + .get(format!("{gateway_url}/api/users/me/vscodex/devices")) + .send() + .await + .expect("unauthenticated request should complete"); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + assert!(captured_requests + .lock() + .expect("captured request store should lock") + .is_empty()); + + let devices = client + .get(format!("{gateway_url}/api/users/me/vscodex/devices")) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("devices request should complete"); + assert_eq!(devices.status(), StatusCode::OK); + let devices_payload: serde_json::Value = + devices.json().await.expect("devices body should be JSON"); + assert_eq!(devices_payload["devices"][0]["id"], "host-1"); + + let pairing = client + .post(format!("{gateway_url}/api/users/me/vscodex/pairings")) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .json(&json!({ "name": "My Mac", "user_id": "attacker" })) + .send() + .await + .expect("pairing request should complete"); + assert_eq!(pairing.status(), StatusCode::CREATED); + assert_eq!( + pairing + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let pairing_payload: serde_json::Value = + pairing.json().await.expect("pairing body should be JSON"); + assert_eq!(pairing_payload["code"], "PAIR-123"); + + let ticket = client + .post(format!("{gateway_url}/api/users/me/vscodex/ws-tickets")) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .json(&json!({ "device_id": "host-1", "user_id": "attacker" })) + .send() + .await + .expect("ticket request should complete"); + assert_eq!(ticket.status(), StatusCode::CREATED); + assert_eq!( + ticket + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let ticket_payload: serde_json::Value = + ticket.json().await.expect("ticket body should be JSON"); + assert_eq!(ticket_payload["ticket"], "ticket-123"); + assert_eq!(ticket_payload["ws_url"], "wss://aether.example/vscodex/ws"); + + let deleted = client + .delete(format!("{gateway_url}/api/users/me/vscodex/devices/host-1")) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("delete request should complete"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert_eq!( + deleted + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + + let missing = client + .delete(format!( + "{gateway_url}/api/users/me/vscodex/devices/missing" + )) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("missing device request should complete"); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + let missing_payload: serde_json::Value = + missing.json().await.expect("missing body should be JSON"); + assert_eq!(missing_payload["detail"], "设备不存在"); + + let internal_denied = client + .delete(format!( + "{gateway_url}/api/users/me/vscodex/devices/internal-denied" + )) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("internal auth failure request should complete"); + assert_eq!(internal_denied.status(), StatusCode::BAD_GATEWAY); + let internal_denied_payload: serde_json::Value = internal_denied + .json() + .await + .expect("internal auth failure body should be JSON"); + assert_eq!(internal_denied_payload["detail"], "VS Codex 服务鉴权失败"); + + let redirected = client + .delete(format!( + "{gateway_url}/api/users/me/vscodex/devices/redirect" + )) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("redirecting sidecar request should complete"); + assert_eq!(redirected.status(), StatusCode::BAD_GATEWAY); + + let empty_success = client + .delete(format!( + "{gateway_url}/api/users/me/vscodex/devices/empty-ok" + )) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("empty sidecar success should complete"); + assert_eq!(empty_success.status(), StatusCode::BAD_GATEWAY); + + let pairing_exchange = client + .post(format!("{gateway_url}/api/vscodex/pair")) + .header("x-aether-client-ip", "203.0.113.99") + .json(&json!({ + "code": "PAIR-123", + "name": "Office Mac", + "user_id": "attacker", + "device_token": "stolen" + })) + .send() + .await + .expect("public pairing exchange should complete"); + assert_eq!(pairing_exchange.status(), StatusCode::CREATED); + assert_eq!( + pairing_exchange + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let pairing_exchange_payload: serde_json::Value = pairing_exchange + .json() + .await + .expect("pairing exchange body should be JSON"); + assert_eq!(pairing_exchange_payload["device_id"], "host-2"); + assert_eq!(pairing_exchange_payload["device_token"], "host-secret"); + + let mut websocket_request = format!("{gateway_url}/api/vscodex/ws") + .replace("http://", "ws://") + .into_client_request() + .expect("WebSocket request should build"); + websocket_request.headers_mut().insert( + http::header::ORIGIN, + "https://aether.example".parse().unwrap(), + ); + websocket_request.headers_mut().insert( + http::header::AUTHORIZATION, + "Bearer browser-aether-jwt".parse().unwrap(), + ); + websocket_request.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + "vscodex.v1".parse().unwrap(), + ); + let (mut websocket, websocket_response) = tokio_tungstenite::connect_async(websocket_request) + .await + .expect("gateway WebSocket should connect"); + assert_eq!( + websocket_response + .headers() + .get(http::header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()), + Some("vscodex.v1") + ); + websocket + .send(TungsteniteMessage::Text( + "{\"type\":\"auth\",\"ticket\":\"one-time-ticket\"}".into(), + )) + .await + .expect("ticket frame should send"); + let echoed = websocket + .next() + .await + .expect("echoed frame should arrive") + .expect("echoed frame should be valid"); + assert_eq!( + echoed, + TungsteniteMessage::Text("echo:{\"type\":\"auth\",\"ticket\":\"one-time-ticket\"}".into()) + ); + websocket.close(None).await.expect("WebSocket should close"); + assert_eq!( + captured_ws_handshake + .lock() + .expect("WebSocket handshake store should lock") + .clone(), + Some((Some("https://aether.example".to_string()), None)) + ); + + let limited_gateway = build_router_with_state( + AppState::new() + .expect("limited gateway state should build") + .with_request_concurrency_limit(1), + ); + let (limited_gateway_url, limited_gateway_handle) = start_server(limited_gateway).await; + let limited_ws_url = + format!("{limited_gateway_url}/api/vscodex/ws").replace("http://", "ws://"); + let limited_ws_request = || { + let mut request = limited_ws_url + .as_str() + .into_client_request() + .expect("limited WebSocket request should build"); + request + .headers_mut() + .insert("x-real-ip", "198.51.100.50".parse().unwrap()); + request + }; + let mut held_websockets = Vec::new(); + for index in 0..16 { + let (websocket, _) = tokio_tungstenite::connect_async(limited_ws_request()) + .await + .unwrap_or_else(|err| panic!("limited WebSocket {index} should connect: {err}")); + held_websockets.push(websocket); + } + let per_ip_limit_error = tokio_tungstenite::connect_async(limited_ws_request()) + .await + .expect_err("seventeenth WebSocket from one IP should be rejected"); + match per_ip_limit_error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + response + .headers() + .get(http::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("1") + ); + } + other => panic!("expected HTTP per-IP limit rejection, got {other:?}"), + } + + held_websockets[0] + .send(TungsteniteMessage::Text( + r#"{"type":"auth","token":"test-auth-ok"}"#.into(), + )) + .await + .expect("test authentication frame should send"); + let auth_ok = + tokio::time::timeout(std::time::Duration::from_secs(1), held_websockets[0].next()) + .await + .expect("test authentication response should arrive in time") + .expect("test authentication response should contain a frame") + .expect("test authentication response should be valid"); + assert_eq!( + auth_ok, + TungsteniteMessage::Text(r#"{"type":"auth.ok","role":"operator"}"#.into()) + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + + let (mut replacement_websocket, _) = tokio_tungstenite::connect_async(limited_ws_request()) + .await + .expect("sidecar auth success should release one pending per-IP slot"); + replacement_websocket + .close(None) + .await + .expect("replacement WebSocket should close"); + for mut websocket in held_websockets { + websocket + .close(None) + .await + .expect("held WebSocket should close"); + } + limited_gateway_handle.abort(); + + let blacklisted_gateway = build_router_with_state( + AppState::new() + .expect("blacklisted gateway state should build") + .with_admin_security_blacklist_for_tests([( + "127.0.0.1".to_string(), + "blocked".to_string(), + )]), + ); + let (blacklisted_gateway_url, blacklisted_gateway_handle) = + start_server(blacklisted_gateway).await; + let blacklisted_ws_url = + format!("{blacklisted_gateway_url}/api/vscodex/ws").replace("http://", "ws://"); + let blacklisted_error = tokio_tungstenite::connect_async(&blacklisted_ws_url) + .await + .expect_err("blacklisted WebSocket should be rejected"); + match blacklisted_error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + assert_eq!(response.status(), StatusCode::FORBIDDEN) + } + other => panic!("expected HTTP blacklist rejection, got {other:?}"), + } + blacklisted_gateway_handle.abort(); + + let requests = captured_requests + .lock() + .expect("captured request store should lock") + .clone(); + assert_eq!(requests.len(), 9); + assert!(requests + .iter() + .all(|request| request.authorization.as_deref() == Some("Bearer sidecar-secret"))); + assert!(requests[..8] + .iter() + .all(|request| request.path.starts_with("/internal/v1/users/user-auth-1/"))); + assert_eq!(requests[8].path, "/v1/pairings/exchange"); + assert_eq!(requests[1].body, Some(json!({ "name": "My Mac" }))); + assert_eq!(requests[2].body, Some(json!({ "device_id": "host-1" }))); + assert_eq!( + requests[8].body, + Some(json!({ "code": "PAIR-123", "name": "Office Mac" })) + ); + assert_eq!(requests[8].client_ip.as_deref(), Some("127.0.0.1")); + assert!(requests[..8] + .iter() + .all(|request| request.client_ip.is_none())); + + let _disabled = set_test_env_var("AETHER_VSCODEX_ENABLED", "false"); + let disabled = client + .get(format!("{gateway_url}/api/users/me/vscodex/devices")) + .bearer_auth(&access_token) + .header("x-client-device-id", "browser-device-vscodex") + .send() + .await + .expect("disabled feature request should complete"); + assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); + let disabled_payload: serde_json::Value = + disabled.json().await.expect("disabled body should be JSON"); + assert_eq!(disabled_payload["detail"], "VS Codex 服务未启用"); + assert_eq!( + captured_requests + .lock() + .expect("captured request store should lock") + .len(), + 9 + ); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); + sidecar_handle.abort(); +} diff --git a/frontend/package.json b/frontend/package.json index e62412123..9cfc345c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,12 @@ "version": "0.0.0", "type": "module", "scripts": { + "sync:vscodex": "npm --prefix ../aether-vscodex/web run build && node scripts/sync-vscodex.mjs", + "predev": "npm run sync:vscodex", "dev": "vite --host", + "prebuild": "npm run sync:vscodex", "build": "vite build", + "prebuild:with-typecheck": "npm run sync:vscodex", "build:with-typecheck": "vue-tsc -b && vite build", "preview": "vite preview", "test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs", diff --git a/frontend/scripts/sync-vscodex.mjs b/frontend/scripts/sync-vscodex.mjs new file mode 100644 index 000000000..5b48e3b21 --- /dev/null +++ b/frontend/scripts/sync-vscodex.mjs @@ -0,0 +1,16 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const moduleRoot = resolve(frontendRoot, '..', 'aether-vscodex') +const vueBuild = resolve(moduleRoot, 'web', 'dist') +const destination = resolve(frontendRoot, 'public', 'aether-vscodex') + +if (!existsSync(resolve(vueBuild, 'index.html'))) { + throw new Error(`aether-vscodex Vue build was not found at ${vueBuild}`) +} + +rmSync(destination, { recursive: true, force: true }) +mkdirSync(destination, { recursive: true }) +cpSync(vueBuild, destination, { recursive: true }) diff --git a/frontend/src/api/__tests__/vscodex.spec.ts b/frontend/src/api/__tests__/vscodex.spec.ts new file mode 100644 index 000000000..883ad0a66 --- /dev/null +++ b/frontend/src/api/__tests__/vscodex.spec.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { deleteMock, getMock, postMock } = vi.hoisted(() => ({ + deleteMock: vi.fn(), + getMock: vi.fn(), + postMock: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + default: { + get: getMock, + post: postMock, + delete: deleteMock, + }, +})) + +import { vscodexApi } from '@/api/vscodex' + +describe('vscodexApi', () => { + beforeEach(() => { + getMock.mockReset() + postMock.mockReset() + deleteMock.mockReset() + }) + + it('lists and normalizes the current user devices', async () => { + getMock.mockResolvedValue({ + data: { + devices: [ + { + device_id: 'device-online', + display_name: 'Studio Mac', + connected: true, + last_seen_at: '2026-08-31T10:00:00Z', + }, + { + id: 'device-unknown', + name: 'Laptop', + status: 'unexpected-status', + }, + { name: 'missing id' }, + ], + }, + }) + + await expect(vscodexApi.listDevices()).resolves.toEqual([ + { + id: 'device-online', + name: 'Studio Mac', + status: 'online', + last_seen_at: '2026-08-31T10:00:00Z', + created_at: null, + }, + { + id: 'device-unknown', + name: 'Laptop', + status: 'unknown', + last_seen_at: null, + created_at: null, + }, + ]) + expect(getMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices') + }) + + it('creates a pairing using an explicit empty body and normalizes its code', async () => { + postMock.mockResolvedValue({ + data: { + pairing_code: 'PAIR-1234', + expires_in_seconds: 300, + }, + }) + + await expect(vscodexApi.createPairing()).resolves.toEqual({ + code: 'PAIR-1234', + expires_at: null, + expires_in_seconds: 300, + }) + expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/pairings', {}) + }) + + it('requests a scoped WebSocket ticket for the selected device', async () => { + postMock.mockResolvedValue({ + data: { + ticket: 'single-use-ticket', + wsUrl: 'wss://aether.example/api/vscodex/ws', + }, + }) + + await expect(vscodexApi.createWsTicket('device-online')).resolves.toEqual({ + ticket: 'single-use-ticket', + ws_url: 'wss://aether.example/api/vscodex/ws', + expires_at: null, + }) + expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/ws-tickets', { + device_id: 'device-online', + }) + }) + + it('revokes the selected device using an encoded path segment', async () => { + deleteMock.mockResolvedValue({ status: 204 }) + + await expect(vscodexApi.deleteDevice('device/one')).resolves.toBeUndefined() + expect(deleteMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices/device%2Fone') + }) + + it('rejects incomplete pairing and ticket responses', async () => { + postMock + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ data: { ticket: 'missing-url' } }) + + await expect(vscodexApi.createPairing()).rejects.toThrow('Pairing response did not include a code') + await expect(vscodexApi.createWsTicket('device-online')).rejects.toThrow( + 'WebSocket ticket response was incomplete', + ) + }) +}) diff --git a/frontend/src/api/vscodex.ts b/frontend/src/api/vscodex.ts new file mode 100644 index 000000000..ea664be71 --- /dev/null +++ b/frontend/src/api/vscodex.ts @@ -0,0 +1,111 @@ +import apiClient from '@/api/client' + +const BASE_PATH = '/api/users/me/vscodex' + +export type VscodexDeviceStatus = 'online' | 'offline' | 'connecting' | 'unknown' + +export interface VscodexDevice { + id: string + name: string + status: VscodexDeviceStatus + last_seen_at: string | null + created_at: string | null +} + +export interface VscodexPairing { + code: string + expires_at: string | null + expires_in_seconds: number | null +} + +export interface VscodexWsTicket { + ticket: string + ws_url: string + expires_at: string | null +} + +type DevicePayload = Partial & { + device_id?: string + display_name?: string + connected?: boolean +} + +type DevicesPayload = DevicePayload[] | { + devices?: DevicePayload[] + items?: DevicePayload[] +} + +type PairingPayload = Partial & { + pairing_code?: string +} + +type WsTicketPayload = Partial & { + wsUrl?: string +} + +function normalizeStatus(device: DevicePayload): VscodexDeviceStatus { + if (device.connected === true) return 'online' + if (device.connected === false) return 'offline' + + switch (device.status) { + case 'online': + case 'offline': + case 'connecting': + return device.status + default: + return 'unknown' + } +} + +function normalizeDevice(device: DevicePayload): VscodexDevice | null { + const id = device.id || device.device_id + if (!id) return null + + return { + id, + name: device.name || device.display_name || id, + status: normalizeStatus(device), + last_seen_at: device.last_seen_at ?? null, + created_at: device.created_at ?? null, + } +} + +export const vscodexApi = { + async listDevices(): Promise { + const response = await apiClient.get(`${BASE_PATH}/devices`) + const payload = response.data + const devices = Array.isArray(payload) ? payload : payload.devices ?? payload.items ?? [] + return devices.map(normalizeDevice).filter((device): device is VscodexDevice => device !== null) + }, + + async createPairing(): Promise { + const response = await apiClient.post(`${BASE_PATH}/pairings`, {}) + const code = response.data.code || response.data.pairing_code + if (!code) throw new Error('Pairing response did not include a code') + + return { + code, + expires_at: response.data.expires_at ?? null, + expires_in_seconds: response.data.expires_in_seconds ?? null, + } + }, + + async createWsTicket(deviceId: string): Promise { + const response = await apiClient.post(`${BASE_PATH}/ws-tickets`, { + device_id: deviceId, + }) + const { ticket } = response.data + const wsUrl = response.data.ws_url || response.data.wsUrl + if (!ticket || !wsUrl) throw new Error('WebSocket ticket response was incomplete') + + return { + ticket, + ws_url: wsUrl, + expires_at: response.data.expires_at ?? null, + } + }, + + async deleteDevice(deviceId: string): Promise { + await apiClient.delete(`${BASE_PATH}/devices/${encodeURIComponent(deviceId)}`) + }, +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 03cc4ecb3..ca8d0cef4 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -275,6 +275,37 @@ export const messages = { 'nav.healthMonitor': '健康监控', 'nav.modelCatalog': '模型目录', 'nav.apiKeys': 'API 密钥', + 'nav.vscodex': 'Codex 远程控制', + 'vscodex.title': 'Codex 远程控制', + 'vscodex.devices.label': '控制设备', + 'vscodex.devices.refresh': '刷新设备', + 'vscodex.devices.loading': '正在加载设备...', + 'vscodex.devices.loadFailed': '无法加载设备,请检查网络后重试。', + 'vscodex.devices.emptyTitle': '尚未连接设备', + 'vscodex.devices.emptyDescription': '安装 aether-vscodex 插件后,在 VS Code 命令面板运行“Codex Remote: Pair with Aether”连接此账户。', + 'vscodex.devices.checkConnection': '检查连接', + 'vscodex.devices.online': '在线', + 'vscodex.devices.offline': '离线', + 'vscodex.devices.connecting': '连接中', + 'vscodex.devices.unknown': '状态未知', + 'vscodex.devices.revoke': '撤销此设备', + 'vscodex.devices.revokeConfirm': '撤销设备“{name}”后,它会立即断开,并且必须重新配对才能连接。确认撤销吗?', + 'vscodex.devices.revokeFailed': '无法撤销设备,请稍后重试。', + 'vscodex.pairing.create': '生成配对码', + 'vscodex.pairing.creating': '正在生成...', + 'vscodex.pairing.title': '连接本地插件', + 'vscodex.pairing.description': '在 VS Code 命令面板运行“Codex Remote: Pair with Aether”,并输入下方的一次性配对码。配对完成后本页会自动连接。', + 'vscodex.pairing.code': '一次性配对码', + 'vscodex.pairing.copy': '复制配对码', + 'vscodex.pairing.copied': '配对码已复制', + 'vscodex.pairing.expires': '有效期至 {time}', + 'vscodex.pairing.newCode': '生成新配对码', + 'vscodex.pairing.failed': '无法生成配对码,请稍后重试。', + 'vscodex.connection.loading': '正在加载控制台...', + 'vscodex.connection.connecting': '正在建立安全连接...', + 'vscodex.connection.ticketFailed': '安全连接凭证获取失败。', + 'vscodex.connection.retry': '重试', + 'vscodex.frame.title': 'Codex 远程控制台', 'nav.walletCenter': '钱包中心', 'nav.billingCenter': '套餐中心', 'nav.myReferral': '我的邀请', @@ -594,6 +625,37 @@ export const messages = { 'nav.healthMonitor': 'Health monitor', 'nav.modelCatalog': 'Model catalog', 'nav.apiKeys': 'API keys', + 'nav.vscodex': 'Codex remote control', + 'vscodex.title': 'Codex remote control', + 'vscodex.devices.label': 'Control device', + 'vscodex.devices.refresh': 'Refresh devices', + 'vscodex.devices.loading': 'Loading devices...', + 'vscodex.devices.loadFailed': 'Devices could not be loaded. Check your connection and retry.', + 'vscodex.devices.emptyTitle': 'No connected devices', + 'vscodex.devices.emptyDescription': 'After installing aether-vscodex, run “Codex Remote: Pair with Aether” from the VS Code Command Palette to connect this account.', + 'vscodex.devices.checkConnection': 'Check connection', + 'vscodex.devices.online': 'Online', + 'vscodex.devices.offline': 'Offline', + 'vscodex.devices.connecting': 'Connecting', + 'vscodex.devices.unknown': 'Status unknown', + 'vscodex.devices.revoke': 'Revoke this device', + 'vscodex.devices.revokeConfirm': 'Revoking “{name}” disconnects it immediately. It must be paired again before reconnecting. Revoke it?', + 'vscodex.devices.revokeFailed': 'The device could not be revoked. Try again later.', + 'vscodex.pairing.create': 'Generate pairing code', + 'vscodex.pairing.creating': 'Generating...', + 'vscodex.pairing.title': 'Connect the local plugin', + 'vscodex.pairing.description': 'Run “Codex Remote: Pair with Aether” from the VS Code Command Palette and enter this one-time code. This page connects automatically after pairing.', + 'vscodex.pairing.code': 'One-time pairing code', + 'vscodex.pairing.copy': 'Copy pairing code', + 'vscodex.pairing.copied': 'Pairing code copied', + 'vscodex.pairing.expires': 'Valid until {time}', + 'vscodex.pairing.newCode': 'Generate a new code', + 'vscodex.pairing.failed': 'A pairing code could not be generated. Try again later.', + 'vscodex.connection.loading': 'Loading control surface...', + 'vscodex.connection.connecting': 'Establishing a secure connection...', + 'vscodex.connection.ticketFailed': 'A secure connection ticket could not be issued.', + 'vscodex.connection.retry': 'Retry', + 'vscodex.frame.title': 'Codex remote control surface', 'nav.walletCenter': 'Wallet', 'nav.billingCenter': 'Plans', 'nav.myReferral': 'Referrals', diff --git a/frontend/src/layouts/main-layout/__tests__/navigation.spec.ts b/frontend/src/layouts/main-layout/__tests__/navigation.spec.ts index 6781bbbe0..e2ef71fb9 100644 --- a/frontend/src/layouts/main-layout/__tests__/navigation.spec.ts +++ b/frontend/src/layouts/main-layout/__tests__/navigation.spec.ts @@ -37,6 +37,37 @@ describe('main layout navigation builder', () => { expect(navigation.flatMap(group => group.items.map(item => item.name))).toContain('tx:nav.myReferral') }) + it('exposes the same VS Code control destination to users and administrators', () => { + const commonOptions = { + modules: {}, + isModuleActive: () => false, + t: translate, + } + const userNavigation = buildNavigation({ + ...commonOptions, + canAccessAdmin: false, + }) + const adminNavigation = buildNavigation({ + ...commonOptions, + canAccessAdmin: true, + }) + + const findVscodeControl = (navigation: ReturnType) => ( + navigation + .flatMap(group => group.items) + .find(item => item.href === '/dashboard/vscodex') + ) + + expect(findVscodeControl(userNavigation)).toMatchObject({ + name: 'tx:nav.vscodex', + href: '/dashboard/vscodex', + }) + expect(findVscodeControl(adminNavigation)).toMatchObject({ + name: 'tx:nav.vscodex', + href: '/dashboard/vscodex', + }) + }) + it('builds admin navigation with dynamic module menu items sorted by menu order', () => { const navigation = buildNavigation({ canAccessAdmin: true, @@ -99,5 +130,16 @@ describe('main layout navigation builder', () => { { label: 'tx:nav.routing', href: '/admin/routing' }, { label: 'tx:breadcrumb.routingCreate' }, ]) + + expect(buildBreadcrumbs({ + route: route('/dashboard/vscodex'), + navigation, + modules: {}, + isNavActive: href => href === '/dashboard/vscodex', + t: translate, + })).toEqual([ + expect.objectContaining({ label: expect.any(String) }), + { label: 'tx:nav.vscodex' }, + ]) }) }) diff --git a/frontend/src/layouts/main-layout/navigation.ts b/frontend/src/layouts/main-layout/navigation.ts index 57c24f920..5027e8abd 100644 --- a/frontend/src/layouts/main-layout/navigation.ts +++ b/frontend/src/layouts/main-layout/navigation.ts @@ -21,6 +21,7 @@ import { Server, Shield, SlidersHorizontal, + SquareTerminal, Users, Wallet, Zap, @@ -88,6 +89,7 @@ export function buildNavigation(options: { items: [ { name: t('nav.modelCatalog'), href: '/dashboard/models', icon: Box }, { name: t('nav.apiKeys'), href: '/dashboard/api-keys', icon: Key }, + { name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal }, ] }, { @@ -115,6 +117,7 @@ export function buildNavigation(options: { title: t('nav.group.overview'), items: [ { name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home }, + { name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal }, { name: t('nav.operations'), href: '/admin/operations', icon: Activity }, { name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity }, { name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 }, diff --git a/frontend/src/router/routes/dashboard.ts b/frontend/src/router/routes/dashboard.ts index 2f391c8e8..44f537b44 100644 --- a/frontend/src/router/routes/dashboard.ts +++ b/frontend/src/router/routes/dashboard.ts @@ -63,6 +63,11 @@ export const dashboardRoutes: RouteRecordRaw[] = [ name: 'ModelCatalog', component: view(() => import('@/views/user/ModelCatalog.vue')) }, + { + path: 'vscodex', + name: 'VscodeControl', + component: view(() => import('@/views/user/VscodeControl.vue')) + }, { path: 'async-tasks', name: 'UserAsyncTasks', diff --git a/frontend/src/views/user/VscodeControl.vue b/frontend/src/views/user/VscodeControl.vue new file mode 100644 index 000000000..3edb746e5 --- /dev/null +++ b/frontend/src/views/user/VscodeControl.vue @@ -0,0 +1,559 @@ +