diff --git a/.changeset/fair-bears-tell.md b/.changeset/fair-bears-tell.md
new file mode 100644
index 000000000..def1bad05
--- /dev/null
+++ b/.changeset/fair-bears-tell.md
@@ -0,0 +1,5 @@
+---
+"@spencer-kit/coder-studio": patch
+---
+
+Stabilize the web test suite and fix workspace UI and dev-browser regressions.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..b378f883d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Quick Start
+ url: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/Quick-Start.md
+ about: Install and launch Coder Studio.
+ - name: First Agent Run
+ url: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/First-Agent-Run.md
+ about: Run Claude or Codex and review your first diff.
+ - name: Security and Privacy
+ url: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/Security-and-Privacy.md
+ about: Understand local-first behavior and remote access risks.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 000000000..5cf552a3f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,43 @@
+name: Feature request
+description: Suggest a product improvement or missing workflow.
+title: "Feature: "
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: workflow
+ attributes:
+ label: Workflow problem
+ description: What were you trying to do?
+ validations:
+ required: true
+ - type: textarea
+ id: current
+ attributes:
+ label: Current behavior
+ description: What makes the workflow hard today?
+ validations:
+ required: true
+ - type: textarea
+ id: desired
+ attributes:
+ label: Desired behavior
+ description: Describe the smallest useful version of the improvement.
+ validations:
+ required: true
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area
+ options:
+ - First run / onboarding
+ - Agent sessions
+ - Git review
+ - Terminal
+ - Mobile / remote access
+ - Supervisor
+ - Work Analysis
+ - Skills
+ - Provider integrations
+ - Other
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/installation.yml b/.github/ISSUE_TEMPLATE/installation.yml
new file mode 100644
index 000000000..bf7444a85
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/installation.yml
@@ -0,0 +1,60 @@
+name: Installation issue
+description: Report problems installing, launching, or opening Coder Studio.
+title: "Installation: "
+labels: ["installation"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting a first-run issue. Please remove secrets, private paths, API keys, and sensitive logs.
+ - type: textarea
+ id: steps
+ attributes:
+ label: What did you try?
+ description: Include the exact commands you ran.
+ placeholder: |
+ npm install -g @spencer-kit/coder-studio
+ coder-studio open
+ validations:
+ required: true
+ - type: textarea
+ id: result
+ attributes:
+ label: What happened?
+ description: Paste the visible error or describe the behavior.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: What did you expect?
+ validations:
+ required: true
+ - type: input
+ id: node
+ attributes:
+ label: Node.js version
+ placeholder: "node --version"
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Coder Studio version
+ placeholder: "coder-studio version"
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Operating system
+ placeholder: "macOS 15, Ubuntu 24.04, Windows 11"
+ validations:
+ required: true
+ - type: textarea
+ id: status
+ attributes:
+ label: Service status and logs
+ description: Include `coder-studio status` and relevant `coder-studio logs` output. Remove secrets.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/provider-setup.yml b/.github/ISSUE_TEMPLATE/provider-setup.yml
new file mode 100644
index 000000000..8c66a54f1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/provider-setup.yml
@@ -0,0 +1,54 @@
+name: Provider setup issue
+description: Report Claude, Codex, or other Provider CLI detection problems.
+title: "Provider setup: "
+labels: ["provider"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Use this when Coder Studio cannot find or start a Provider CLI. Remove secrets and private project details.
+ - type: dropdown
+ id: provider
+ attributes:
+ label: Provider
+ options:
+ - Claude Code
+ - Codex
+ - Gemini CLI
+ - Cursor Agent
+ - OpenCode
+ - Aider-style CLI
+ validations:
+ required: true
+ - type: textarea
+ id: commands
+ attributes:
+ label: Verification commands
+ description: Paste relevant output from `which`, `--version`, and Coder Studio logs.
+ placeholder: |
+ which claude
+ claude --version
+ coder-studio logs
+ validations:
+ required: true
+ - type: textarea
+ id: app
+ attributes:
+ label: What does Coder Studio show?
+ description: Describe the UI state or error message.
+ validations:
+ required: true
+ - type: input
+ id: shell
+ attributes:
+ label: Shell and terminal
+ placeholder: "zsh, bash, PowerShell, Windows Terminal"
+ validations:
+ required: false
+ - type: input
+ id: os
+ attributes:
+ label: Operating system
+ placeholder: "macOS 15, Ubuntu 24.04, Windows 11"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/workflow-showcase.yml b/.github/ISSUE_TEMPLATE/workflow-showcase.yml
new file mode 100644
index 000000000..921752343
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/workflow-showcase.yml
@@ -0,0 +1,31 @@
+name: Show your workflow
+description: Share how you use Coder Studio and what should improve.
+title: "Workflow: "
+labels: ["workflow"]
+body:
+ - type: textarea
+ id: setup
+ attributes:
+ label: Your setup
+ description: Which agent, OS, device mix, and repository type are you using?
+ validations:
+ required: true
+ - type: textarea
+ id: workflow
+ attributes:
+ label: Workflow
+ description: Describe the task from start to finish.
+ validations:
+ required: true
+ - type: textarea
+ id: useful
+ attributes:
+ label: What worked well?
+ validations:
+ required: false
+ - type: textarea
+ id: friction
+ attributes:
+ label: What was confusing or slow?
+ validations:
+ required: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..04b8de631
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,69 @@
+# Contributing
+
+Thanks for helping improve Coder Studio.
+
+Coder Studio is a self-hosted browser workspace for AI coding agent workflows. The most useful early feedback is concrete: what you tried, what happened, what you expected, and where the first-run path became unclear.
+
+## Good First Feedback
+
+Please open an issue when you hit:
+
+- installation problems
+- Provider CLI detection problems
+- first agent session confusion
+- mobile or remote access setup issues
+- documentation that does not match the current UI
+- a workflow that feels promising but needs one missing capability
+
+## Before Filing An Issue
+
+Run these commands when relevant:
+
+```bash
+node --version
+coder-studio version
+coder-studio status
+coder-studio logs
+which claude
+which codex
+claude --version
+codex --version
+```
+
+Do not paste secrets, API keys, private source code, or full logs that contain sensitive data.
+
+## Development Setup
+
+```bash
+git clone https://github.com/spencerkit/coder-studio.git
+cd coder-studio
+pnpm install
+pnpm dev
+```
+
+## Verification
+
+Before handing off code changes, run the relevant command:
+
+```bash
+pnpm ci:verify
+```
+
+For docs-only changes, at least run:
+
+```bash
+git diff --check
+pnpm ci:lint
+```
+
+## Pull Request Expectations
+
+- Keep changes focused.
+- Do not bundle unrelated refactors.
+- Include screenshots or short recordings for UI changes.
+- Update docs when behavior changes.
+- Mention what verification you ran.
+
+## Security
+
+Do not report sensitive security issues in public issues if they include exploitable details. Open a minimal public issue asking for a private contact path, or contact the maintainer through the repository owner profile.
diff --git a/README.md b/README.md
index 8c1cf83bd..8b7470e19 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,11 @@
# Coder Studio
-**The all-in-one vibe coding workspace for AI agents.**
+**Self-hosted browser workspace for AI coding agents.**
-Coder Studio brings your code editor, Git, terminals, AI coding agents, session review, notifications, work analysis, and Skills into one browser workspace.
+Coder Studio brings Claude Code, Codex, terminals, files, Git diff review, Supervisor loops, Work Analysis, and Skills into one browser workspace you run on your own machine.
-It helps keep agent context, progress, and follow-up work visible across desktop, tablet, and phone, so vibe coding feels less scattered and more controllable.
+Use it when raw terminal-only AI coding starts to feel scattered: start an agent task on desktop, review the changed files and diff beside the session, monitor long-running work, and reopen the same workspace from a tablet or phone.
Works with popular coding agents including Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, and Aider-style CLI agents.
@@ -17,9 +17,9 @@ Works with popular coding agents including Claude Code, Codex, Gemini CLI, Curso
[](https://nodejs.org/)
[](https://github.com/spencerkit/coder-studio/stargazers)
-[View Workspace](docs/help/assets/screenshot-desktop-workspace-full.png) · [Quick Start](#quick-start) · [Star on GitHub](https://github.com/spencerkit/coder-studio)
+[Watch Demo](docs/assets/demo.mp4) · [Quick Start](#quick-start) · [English Docs](docs/wiki/Quick-Start.md) · [Star on GitHub](https://github.com/spencerkit/coder-studio)
-[中文说明](README.zh-CN.md) | [Documentation](docs/help/quick-start.md)
+[中文说明](README.zh-CN.md) | [Security & Privacy](docs/wiki/Security-and-Privacy.md) | [Known Limitations](docs/wiki/Known-Limitations.md)
@@ -27,6 +27,17 @@ Works with popular coding agents including Claude Code, Codex, Gemini CLI, Curso
Preview the full workspace layout built for agent runs, review, supervision, and device switching.
+## What You Can Try In 5 Minutes
+
+1. Install with `npm install -g @spencer-kit/coder-studio`.
+2. Launch with `coder-studio open`.
+3. Open one local repository.
+4. Start an agent session. For a first trial, Claude or Codex is the recommended path.
+5. Ask the agent for a small change, then review the Git diff beside the session.
+6. Reopen the same workspace from a tablet or phone to check progress.
+
+Coder Studio is not a cloud IDE, not a VS Code replacement, and not an AI model provider. It is a self-hosted workbench around the AI coding agents and local tools you already use.
+
## Why Coder Studio?
Vibe coding feels fast until the agent output turns into real project work: you still need to run agents, inspect edits, manage Git, monitor long tasks, and improve the next run. Coder Studio keeps that loop in one programming workbench.
@@ -136,17 +147,14 @@ The same workspace URL works across all devices — interface adapts automatical
| Resource | Description |
|----------|-------------|
-| [Quick Start Guide](docs/help/quick-start.md) | Installation to first workspace |
-| [App Overview](docs/help/app-overview.md) | Core concepts and features |
-| [Agent CLI Setup](docs/help/providers.md) | Install and connect coding agent CLIs |
-| [Desktop Guide](docs/help/desktop-guide.md) | PC interface and shortcuts |
-| [Mobile & Remote Access Guide](docs/help/mobile-guide.md) | Phone / tablet usage, LAN access, Tailscale/ngrok/Cloudflare Tunnel |
-| [Work Analysis](docs/help/work-analysis.md) | Review workspace activity, agent sessions, and improvement opportunities |
-| [Common Workflows](docs/help/workflows.md) | Task-based tutorials |
-| [Troubleshooting](docs/help/troubleshooting.md) | FAQ and known issues |
-| [CLI Reference](docs/help/cli.md) | Command-line options |
-| [GitHub Wiki Source](docs/wiki/README.md) | Wiki source pages and publish flow |
-| [AI Coding Terms](docs/wiki/AI-Coding-Terms.md) | Vibe coding, agentic harnesses, eval harnesses, and where Coder Studio fits |
+| [Quick Start](docs/wiki/Quick-Start.md) | Install, launch, and open your first workspace |
+| [First Agent Run](docs/wiki/First-Agent-Run.md) | Run a recommended first provider, inspect output, and review Git diff |
+| [Agent Providers](docs/wiki/Agent-Providers.md) | Install and verify coding agent CLIs |
+| [Mobile and Remote Access](docs/wiki/Mobile-and-Remote-Access.md) | LAN, Tailscale, ngrok, Cloudflare Tunnel, and phone/tablet usage |
+| [Security and Privacy](docs/wiki/Security-and-Privacy.md) | Local-first model, provider boundaries, and remote access risks |
+| [Known Limitations](docs/wiki/Known-Limitations.md) | Current requirements and product boundaries |
+| [Troubleshooting](docs/wiki/Troubleshooting.md) | First-run problems, Provider CLI issues, and service recovery |
+| [Chinese Help Center](docs/help/README.md) | 中文帮助中心 |
---
@@ -164,7 +172,6 @@ The same workspace URL works across all devices — interface adapts automatical
- [ ] Web-based terminal streaming optimization
- [ ] Session replay and history navigation
- [ ] Multi-workspace management
-- [ ] Plugin system for custom integrations
- [ ] Workspace preference sync
---
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 42a7dfda3..67a94bf5a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -4,11 +4,11 @@
# Coder Studio
-**一站式 vibe coding 编程工作台。**
+**自部署的 AI Coding 工作台。**
-Coder Studio 把代码编辑器、Git、终端、AI coding agent、会话审查、消息提醒、工作复盘和 Skills 放进同一个浏览器工作区。
+Coder Studio 把 Claude Code、Codex、终端、文件、Git diff 审查、Supervisor 监督循环、工作分析和 Skills 放进同一个浏览器 workspace,并运行在你自己的机器上。
-它帮助你在桌面、平板和手机之间保持 Agent 上下文、任务进度和后续动作可见,让 vibe coding 不再散落在一堆窗口和工具里。
+当纯终端里的 AI coding 开始变得分散时,可以用它在桌面端启动 Agent 任务,在会话旁审查改动和 diff,监督长任务进度,并从平板或手机重新打开同一个工作区继续查看。
支持 Claude Code、Codex、Gemini CLI、Cursor Agent、OpenCode,以及 Aider 这类 CLI coding agent。
@@ -17,9 +17,9 @@ Coder Studio 把代码编辑器、Git、终端、AI coding agent、会话审查
[](https://nodejs.org/)
[](https://github.com/spencerkit/coder-studio/stargazers)
-[查看工作区](docs/help/assets/screenshot-desktop-workspace-full.png) · [快速开始](#快速开始) · [GitHub Star](https://github.com/spencerkit/coder-studio)
+[观看 Demo](docs/assets/demo.mp4) · [快速开始](#快速开始) · [中文帮助中心](docs/help/README.md) · [GitHub Star](https://github.com/spencerkit/coder-studio)
-[English](README.md) | [文档](docs/help/quick-start.md)
+[English](README.md) | [安全与隐私](docs/help/security-and-privacy.md) | [当前限制](docs/help/known-limitations.md)
@@ -27,6 +27,17 @@ Coder Studio 把代码编辑器、Git、终端、AI coding agent、会话审查
预览这个为 Agent 运行、改动审查、Supervisor 监督和跨设备切换而设计的完整工作区布局。
+## 5 分钟可以试到什么
+
+1. 使用 `npm install -g @spencer-kit/coder-studio` 安装。
+2. 使用 `coder-studio open` 启动。
+3. 打开一个本地代码仓库。
+4. 创建 Agent 会话。首次试跑建议选择 Claude 或 Codex。
+5. 让 Agent 做一个小改动,然后在 Git diff 里审查结果。
+6. 用平板或手机重新打开同一个 workspace 查看进度。
+
+Coder Studio 不是云 IDE,不是 VS Code 替代品,也不是 AI 模型提供方。它是围绕你已经在使用的 AI coding agent 和本地开发工具搭建的自部署工作台。
+
## 为什么选择 Coder Studio?
vibe coding 一开始很快,但当 Agent 输出进入真实项目,后面还要运行 Agent、理解改动、审查 diff、管理 Git、监督长任务,并改进下一轮执行。Coder Studio 把这条链路收进一个编程工作台。
@@ -136,17 +147,14 @@ coder-studio open
| 资源 | 描述 |
|------|------|
-| [快速开始](docs/help/quick-start.md) | 从安装到第一个工作区 |
-| [功能总览](docs/help/app-overview.md) | 核心概念和功能 |
-| [Agent CLI 配置](docs/help/providers.md) | 安装和连接 coding agent CLI |
-| [桌面端指南](docs/help/desktop-guide.md) | PC 界面和快捷键 |
-| [移动端与远程访问指南](docs/help/mobile-guide.md) | 手机/平板使用、局域网访问、Tailscale/ngrok/Cloudflare Tunnel |
-| [工作分析](docs/help/work-analysis.md) | 复盘工作区活动、Agent 会话和改进机会 |
-| [常用工作流](docs/help/workflows.md) | 任务式教程 |
-| [故障排除](docs/help/troubleshooting.md) | 常见问题和修复 |
-| [CLI 参考](docs/help/cli.md) | 命令行选项 |
-| [GitHub Wiki 源文件](docs/wiki/README.md) | Wiki 源页面与发布流程 |
-| [AI Coding 术语](docs/wiki/AI-Coding-Terms.md) | Vibe coding、agentic harness、eval harness,以及 Coder Studio 的定位 |
+| [快速开始](docs/help/quick-start.md) | 安装、启动和打开第一个 workspace |
+| [第一次 Agent 运行](docs/help/first-agent-run.md) | 创建推荐的首次 Provider 会话、查看输出并审查 Git diff |
+| [Provider 配置](docs/help/providers.md) | 安装和验证 coding agent CLI |
+| [移动端与远程访问](docs/help/mobile-guide.md) | 局域网、Tailscale、ngrok、Cloudflare Tunnel 和手机/平板使用 |
+| [安全与隐私](docs/help/security-and-privacy.md) | 本地运行模型、Provider 边界和远程访问风险 |
+| [当前限制](docs/help/known-limitations.md) | 当前要求、边界和适用场景 |
+| [故障排除](docs/help/troubleshooting.md) | 首次运行、Provider、端口、认证和服务恢复 |
+| [English Wiki](docs/wiki/README.md) | 英文 Wiki 源页面 |
---
diff --git a/docs/PRD-agent-automation-skills.md b/docs/PRD-agent-automation-skills.md
deleted file mode 100644
index 795c6bb32..000000000
--- a/docs/PRD-agent-automation-skills.md
+++ /dev/null
@@ -1,643 +0,0 @@
-# Product Requirements Document: Agent Automation Skills
-
-> Last updated: 2026-06-06
-> Scope: built-in skills, agent-facing automation discovery, CLI/API access, browser verification, and plugin/status extension surfaces.
-> Status: proposed product direction for phased implementation.
-
-## 1. Background
-
-Coder Studio already provides a local browser workspace that combines agent sessions, terminals, files, Git, worktrees, Supervisor, settings, and work analysis. The current product is strong when a human operates the workspace through the UI. It is weaker when an agent or local automation tool needs to discover and use Coder Studio capabilities on its own.
-
-The gap appears in three related areas:
-
-- Agents do not have a stable way to discover Coder Studio commands and current workspace context.
-- Agents can modify code, but they do not have a first-class Coder Studio browser verification loop for frontend changes.
-- External tools and future plugins do not have a stable status/progress/sidebar contribution surface inside Coder Studio.
-
-This PRD defines a complete product direction that starts with built-in skills and grows into a broader automation platform.
-
-## 2. Product Goal
-
-Make Coder Studio understandable and usable by coding agents without requiring the agent to guess commands, read stale documentation, or rely on manual UI operation.
-
-The intended user experience is:
-
-1. A user starts Claude, Codex, or another provider inside Coder Studio.
-2. Coder Studio automatically makes built-in skills available to that provider.
-3. The agent detects it is running inside Coder Studio.
-4. The agent uses a small stable entrypoint to identify the current workspace/session and discover available commands.
-5. The agent calls Coder Studio automation commands for safe operations.
-6. Risky operations require approval and are auditable.
-7. Later phases add browser verification and plugin/status surfaces on the same automation foundation.
-
-## 3. Non-Goals
-
-This project does not replace the existing Coder Studio UI.
-
-This project does not require implementing a full VS Code-style plugin marketplace in the first release.
-
-This project does not grant agents unrestricted filesystem, terminal, Git, browser, or plugin permissions.
-
-This project does not require every provider to support skills equally. Provider-specific adapters may expose different support levels.
-
-This project does not make browser automation use a user's real browser profile by default.
-
-## 4. Target Users
-
-### 4.1 Developer Running Agents
-
-The developer wants Claude, Codex, or another provider to operate more effectively inside the Coder Studio workspace.
-
-Primary benefit: fewer manual copy/paste steps and better task completion quality.
-
-### 4.2 Power User Automating Local Workflows
-
-The user wants scripts that open workspaces, start sessions, run tests, inspect Git, or launch verification flows.
-
-Primary benefit: Coder Studio becomes scriptable instead of UI-only.
-
-### 4.3 Plugin or Integration Author
-
-The author wants to surface CI, test, review, deployment, issue tracker, or internal workflow status inside Coder Studio.
-
-Primary benefit: integrations can attach to a stable extension surface instead of patching the core UI.
-
-## 5. Core Concepts
-
-### 5.1 Built-in Skill
-
-A built-in skill is a Coder Studio-owned skill packaged with the application and mounted into supported provider skill directories. It teaches the agent when and how to use Coder Studio capabilities.
-
-Built-in skills are versioned, visible in the Skills UI, and can be disabled by the user.
-
-### 5.2 Agent Runtime Context
-
-Agent runtime context is the workspace/session/terminal/provider identity exposed to the running agent through environment variables and a machine-readable command.
-
-The agent should not infer current context from terminal paths or UI labels.
-
-### 5.3 Capabilities Discovery
-
-Capabilities discovery is a machine-readable list of current Coder Studio automation commands, input schemas, examples, permission requirements, and availability.
-
-The capabilities response is the source of truth for agents. Built-in skills should point agents to capabilities discovery instead of hardcoding many command details.
-
-### 5.4 Automation Permission
-
-Automation permissions define what an agent may do through Coder Studio automation commands.
-
-Permissions are separate from normal UI access. An operation can be available to the user in the UI but still require explicit approval when invoked by an agent.
-
-### 5.5 Browser Verification Surface
-
-The browser verification surface is a controlled browser panel that an agent can use to open local development URLs, inspect page state, click, fill forms, capture screenshots, and read console errors.
-
-### 5.6 Plugin/Status Surface
-
-The plugin/status surface is a stable way for external integrations to publish commands, status pills, progress, logs, and sidebar views into Coder Studio.
-
-## 6. Product Requirements
-
-## 6.1 Built-in Skills
-
-Coder Studio must ship with first-party built-in skills.
-
-Initial built-in skills:
-
-| Skill | Default | Purpose |
-| --- | --- | --- |
-| `coder-studio-automation` | Enabled | Teach agents to identify context and discover automation commands. |
-| `coder-studio-browser-verification` | Enabled when browser automation is available | Teach agents to verify frontend/UI changes through Coder Studio browser automation. |
-| `coder-studio-review` | Enabled | Teach agents to inspect Git diff, run relevant checks, and summarize verification before finishing. |
-
-Each built-in skill must include:
-
-- `SKILL.md`
-- name
-- description
-- version
-- source marker of `builtin`
-- supported provider target rules
-- user-visible enablement state
-
-Built-in skill content must remain short and stable. It should instruct agents to use:
-
-```bash
-coder-studio identify --json
-coder-studio capabilities --json
-```
-
-Built-in skill content must not duplicate a large command reference that can drift from implementation.
-
-## 6.2 Built-in Skill Distribution
-
-Coder Studio must automatically distribute supported built-in skills to supported provider skill directories.
-
-Distribution rules:
-
-- Auto-mount is enabled by default.
-- Auto-mount only targets providers with configured skill mount directories.
-- User-disabled mounts must not be re-enabled automatically.
-- Built-in skill updates must refresh managed source content.
-- Existing user-installed skills must not be overwritten.
-- Failed mounts must surface health errors in the Skills UI.
-- Custom providers are not auto-mounted by default unless the provider declares compatible skill support or the user enables auto-mount for that provider.
-
-The existing skill management concepts should remain recognizable:
-
-- skill library
-- skill mount relation
-- provider skill target
-- health scan
-- repair
-
-## 6.3 Skills UI
-
-The Skills UI must show built-in skills as a distinct section.
-
-Required UI information:
-
-- skill name
-- built-in source badge
-- version
-- enabled/disabled state
-- mounted provider list
-- last synced time
-- health state
-- link/action to view skill content
-- action to disable or re-enable auto-mount
-- action to repair a failed mount
-
-The UI must make clear that disabling a built-in skill affects future agent guidance, not the availability of the underlying Coder Studio UI.
-
-## 6.4 Agent Runtime Environment
-
-When Coder Studio starts an agent session, it must inject runtime context environment variables when supported by the provider launch path.
-
-Minimum variables:
-
-```bash
-CODER_STUDIO=1
-CODER_STUDIO_WORKSPACE_ID=
-CODER_STUDIO_SESSION_ID=
-CODER_STUDIO_PROVIDER_ID=
-CODER_STUDIO_API_URL=
-```
-
-When a terminal id is known:
-
-```bash
-CODER_STUDIO_TERMINAL_ID=
-```
-
-When token-scoped automation is implemented:
-
-```bash
-CODER_STUDIO_TOKEN=
-```
-
-Environment injection must not leak long-lived administrative secrets.
-
-## 6.5 Identify Command
-
-Coder Studio must provide a machine-readable identify command:
-
-```bash
-coder-studio identify --json
-```
-
-The command must return the current runtime context when called from a Coder Studio-managed agent environment.
-
-Expected response shape:
-
-```json
-{
- "insideCoderStudio": true,
- "workspaceId": "ws_123",
- "sessionId": "sess_123",
- "terminalId": "term_123",
- "providerId": "codex",
- "cwd": "/path/to/workspace",
- "apiUrl": "http://127.0.0.1:4173",
- "permissions": ["workspace:read", "terminal:read", "git:read"]
-}
-```
-
-If called outside Coder Studio, the command must return a clear non-context response instead of pretending to know context:
-
-```json
-{
- "insideCoderStudio": false
-}
-```
-
-## 6.6 Capabilities Command
-
-Coder Studio must provide a machine-readable capabilities command:
-
-```bash
-coder-studio capabilities --json
-```
-
-The command must list automation capabilities available to the caller.
-
-Required fields per capability:
-
-- name
-- CLI form
-- description
-- input schema
-- output shape summary
-- permissions
-- risk level
-- examples
-- availability state
-
-Example:
-
-```json
-{
- "name": "git.status",
- "cli": "coder-studio git status",
- "description": "Read Git status for a workspace.",
- "inputSchema": {
- "workspaceId": "string"
- },
- "permissions": ["git:read"],
- "riskLevel": "read",
- "examples": [
- "coder-studio git status --workspace ws_123 --json"
- ],
- "available": true
-}
-```
-
-Capabilities must be filterable by current permission scope. An agent should not see privileged examples it cannot invoke.
-
-## 6.7 Initial Automation Command Families
-
-The first automation surface should expose these command families in a JSON-friendly way:
-
-| Family | Examples | Phase |
-| --- | --- | --- |
-| Context | `identify`, `capabilities` | MVP |
-| Workspace | list/current/open metadata | MVP |
-| Session | list/current/status | MVP |
-| Terminal | list/read/send | MVP |
-| Git | status/diff | MVP |
-| Events | JSONL event stream | Phase 2 |
-| Browser | open/snapshot/screenshot/console/click/fill/wait | Phase 2 |
-| Plugin status | set-status/progress/log/list | Phase 3 |
-| MCP | expose the same capabilities as MCP tools | Phase 3 |
-
-CLI commands and WebSocket/server commands should share a common command registry or metadata source where practical.
-
-## 6.8 Permissions and Approval
-
-Automation commands must declare risk level:
-
-| Risk | Behavior |
-| --- | --- |
-| `read` | Allowed when caller has read permission. |
-| `write` | Allowed when caller has write permission; command is audited. |
-| `dangerous` | Requires explicit user approval before execution. |
-
-Initial permission groups:
-
-- `workspace:read`
-- `workspace:write`
-- `session:read`
-- `session:write`
-- `terminal:read`
-- `terminal:write`
-- `git:read`
-- `git:write`
-- `browser:read`
-- `browser:write`
-- `plugin:read`
-- `plugin:write`
-- `events:read`
-
-Risk examples:
-
-| Operation | Risk |
-| --- | --- |
-| `git status` | `read` |
-| `git diff` | `read` |
-| read terminal output | `read` |
-| send terminal input | `write` |
-| browser screenshot | `read` |
-| browser click/fill | `write` |
-| `git push` | `dangerous` |
-| delete files | `dangerous` |
-| install plugin | `dangerous` |
-
-When a command requires approval and no approval is available yet, it must fail clearly:
-
-```json
-{
- "code": "approval_required",
- "message": "This operation requires user approval.",
- "approval": {
- "operation": "terminal.send",
- "riskLevel": "dangerous"
- }
-}
-```
-
-Later phases may add an approval queue or Feed-style UI.
-
-## 6.9 Audit Log
-
-Agent-triggered automation commands must be auditable.
-
-Audit records must include:
-
-- timestamp
-- workspace id
-- session id when available
-- provider id when available
-- command name
-- risk level
-- permission decision
-- success/failure
-- sanitized arguments
-
-Audit logs must not store secret values, full prompts, access tokens, or password field contents.
-
-## 6.10 Browser Verification
-
-Coder Studio should add a browser verification surface for agent-controlled frontend validation.
-
-Required capabilities:
-
-- open URL
-- navigate
-- wait for text or selector
-- capture screenshot
-- read accessibility or DOM snapshot
-- read console errors
-- click
-- fill
-- press key
-- inspect current URL/title
-
-Default security model:
-
-- use isolated browser profile
-- do not use the user's personal browser cookies by default
-- prefer localhost/workspace development URLs
-- require approval for sensitive form filling
-- expose screenshots through Coder Studio review UI when possible
-
-Browser verification must integrate with built-in skills. `coder-studio-browser-verification` should tell the agent to use capabilities discovery and then browser commands.
-
-## 6.11 Plugin/Status API
-
-Coder Studio should provide a lightweight extension surface before a full plugin runtime.
-
-Initial plugin/status contributions:
-
-- set status pill
-- clear status pill
-- set progress
-- clear progress
-- append log entry
-- clear log
-- list current extension state
-- register Quick Actions command metadata
-
-Example command direction:
-
-```bash
-coder-studio status set --workspace ws_123 --key ci --label "CI running" --state running
-coder-studio progress set --workspace ws_123 --key tests --value 42 --max 100
-coder-studio log append --workspace ws_123 --key ci --message "Unit tests started"
-```
-
-The plugin/status API should later support sidebar views, but the first release should not require arbitrary third-party webviews.
-
-## 6.12 MCP Server
-
-Coder Studio should eventually expose its automation capabilities through MCP.
-
-The MCP server must reuse the same underlying capabilities registry and permission checks as the CLI.
-
-Expected command:
-
-```bash
-coder-studio mcp
-```
-
-MCP is not the MVP path. CLI and internal automation metadata should land first.
-
-## 6.13 Documentation
-
-Documentation must include:
-
-- user-facing overview of built-in skills
-- how to disable auto-mount
-- how agents discover commands
-- CLI reference for `identify` and `capabilities`
-- security model and approval behavior
-- troubleshooting for failed skill mounts
-- examples for agent-friendly browser verification
-
-Docs must avoid implying that unavailable later-phase browser/plugin/MCP capabilities are already shipped.
-
-## 7. Phased Delivery
-
-## 7.1 MVP: Built-in Skills and Discovery Foundation
-
-Goal: agents can discover Coder Studio context and capabilities through built-in skills and machine-readable commands.
-
-Included:
-
-- built-in skill registry
-- materialized built-in skill files
-- built-in skills appear in skill library
-- auto-mount to supported providers
-- user disablement respected
-- Skills UI built-in section
-- session environment variables
-- `coder-studio identify --json`
-- `coder-studio capabilities --json`
-- capabilities metadata for context/workspace/session/terminal/git read commands
-- basic audit records for automation commands
-
-Excluded from MVP:
-
-- browser automation
-- plugin/sidebar API
-- MCP server
-- full approval queue UI
-
-MVP acceptance criteria:
-
-- A fresh Coder Studio start shows the built-in skills in Skills UI.
-- Codex and Claude receive mounted built-in skills when their skill directories are configured.
-- A user can disable a built-in skill and it stays disabled after restart.
-- An agent session includes `CODER_STUDIO=1` and workspace/session/provider environment variables.
-- Inside the session, `coder-studio identify --json` returns current context.
-- Inside the session, `coder-studio capabilities --json` returns available commands and permissions.
-- Existing user-installed skills continue to install, mount, unmount, repair, and uninstall.
-
-## 7.2 Phase 2: Browser Verification and Events
-
-Goal: agents can validate browser-visible changes through Coder Studio.
-
-Included:
-
-- browser panel lifecycle
-- browser command capabilities
-- screenshot capture
-- DOM or accessibility snapshot
-- console error collection
-- click/fill/wait commands
-- JSONL event stream for automation-relevant events
-- browser verification skill enabled when browser automation is available
-- browser command audit records
-
-Acceptance criteria:
-
-- An agent can start a dev server in a terminal and open the local URL through a Coder Studio browser command.
-- An agent can capture a screenshot and read console errors.
-- An agent can wait for expected text and report success/failure.
-- Browser actions are scoped, auditable, and do not use personal browser profile data by default.
-
-## 7.3 Phase 3: Plugin/Status API and Approval UI
-
-Goal: external tools can publish workflow state into Coder Studio, and risky automation has a visible approval flow.
-
-Included:
-
-- status/progress/log command families
-- workspace/session scoped status state
-- UI surfaces for status, progress, and logs
-- Quick Actions contribution metadata
-- approval queue UI for dangerous commands
-- approval result returned to blocked automation caller where possible
-
-Acceptance criteria:
-
-- A local script can set and clear a workspace status pill.
-- A local script can publish progress and append logs visible in the UI.
-- A risky agent command can request approval and the user can approve or deny it from Coder Studio.
-- Approval decisions are audited.
-
-## 7.4 Phase 4: MCP and Advanced Extension Surfaces
-
-Goal: expose Coder Studio automation to MCP-capable agents and support richer extension views.
-
-Included:
-
-- `coder-studio mcp`
-- MCP tools generated from capabilities registry
-- same permission and audit model as CLI
-- sidebar view contribution model
-- extension lifecycle management
-
-Acceptance criteria:
-
-- MCP-capable agents can discover and call Coder Studio tools.
-- MCP tools match CLI permissions and risk behavior.
-- A trusted extension can render a sidebar view without compromising the core UI.
-
-## 8. Data and State Requirements
-
-## 8.1 Built-in Skill State
-
-Coder Studio must persist:
-
-- built-in skill version
-- materialized source path
-- enabled state
-- per-provider auto-mount state
-- user disablement decisions
-- last sync timestamp
-- last health state
-
-## 8.2 Automation Command Metadata
-
-Coder Studio must maintain metadata for:
-
-- command name
-- CLI mapping
-- schema
-- permission requirements
-- risk level
-- examples
-- availability checks
-
-This metadata should be reusable by:
-
-- CLI help
-- `capabilities --json`
-- future MCP server
-- docs generation where practical
-
-## 8.3 Audit State
-
-Audit records must be local-first and stored under the configured Coder Studio state directory.
-
-The audit store should support bounded retention to avoid unbounded disk growth.
-
-## 9. Security and Privacy Requirements
-
-Agents must not receive unrestricted server control by default.
-
-Automation tokens, when introduced, must be scoped to the running session and permissions.
-
-Sensitive values must be redacted from logs, audit records, and UI.
-
-Dangerous automation commands must require approval.
-
-Browser automation must default to isolated browser state.
-
-Public network exposure does not change the security model. If Coder Studio is reachable through LAN, VPN, tunnel, or public URL, automation permissions still apply.
-
-## 10. Success Metrics
-
-Product success can be evaluated with:
-
-- percentage of agent sessions with built-in skills mounted successfully
-- number of successful `identify` and `capabilities` calls from agent sessions
-- reduction in user manual copy/paste actions during agent workflows
-- browser verification success/failure counts after Phase 2
-- plugin/status API adoption after Phase 3
-- number of risky operations correctly gated by approval
-- support issues related to failed skill mounts or stale skill docs
-
-## 11. Product Decisions for Initial Planning
-
-The first implementation plan should use these defaults:
-
-1. Auto-mount is enabled by default for `coder-studio-automation` and `coder-studio-review`.
-2. `coder-studio-browser-verification` is shipped in the built-in library during MVP, but it is only auto-mounted after browser automation is available.
-3. Custom providers do not receive built-in skills automatically in MVP. They may be enabled manually through the Skills UI if they expose a skill mount directory.
-4. `identify` and `capabilities` should exist as top-level CLI commands and should be backed by server-side metadata that can also serve WebSocket callers.
-5. MVP audit logs are stored locally but do not need a full Settings UI. They must still be available for debugging through logs or a future command.
-6. Phase 2 browser automation should use an isolated Playwright/Chromium profile unless implementation research proves a better local-first option.
-7. Plugin/status commands should wait until Phase 3, after the automation metadata and audit model exist.
-8. Approval UI is not required for MVP. MVP dangerous commands should return `approval_required` instead of executing.
-
-## 12. Recommended Implementation Order
-
-The recommended order is:
-
-1. Built-in skill registry and materialization.
-2. Built-in skill library integration.
-3. Auto-mount sync manager and user disablement persistence.
-4. Skills UI built-in section.
-5. Session environment injection.
-6. `identify --json`.
-7. `capabilities --json`.
-8. Initial audit log.
-9. Browser verification commands and UI.
-10. Event stream.
-11. Status/progress/log API.
-12. Approval UI.
-13. MCP server.
-14. Rich sidebar extension views.
-
-This order keeps the MVP small while preserving the larger platform direction.
diff --git a/docs/help/README.md b/docs/help/README.md
index fb9d20231..f22eee7d6 100644
--- a/docs/help/README.md
+++ b/docs/help/README.md
@@ -2,9 +2,12 @@
## 我是第一次使用
-1. [快速开始](quick-start.md) — 安装、启动、第一次使用
-2. [Provider 配置](providers.md) — 安装 Claude / Codex CLI
-3. [排障指南](troubleshooting.md) — 遇到问题先看这里
+1. [快速开始](quick-start.md) — 安装、启动、打开第一个 workspace
+2. [第一次 Agent 运行](first-agent-run.md) — 创建推荐的首次 Provider 会话并审查 Git diff
+3. [Provider 配置](providers.md) — 安装和验证 coding agent CLI
+4. [排障指南](troubleshooting.md) — 遇到问题先看这里
+5. [安全与隐私](security-and-privacy.md) — 本地运行模型、Provider 边界和远程访问风险
+6. [当前限制](known-limitations.md) — 了解当前要求和边界
## 我主要在电脑上使用
diff --git a/docs/help/app-overview.md b/docs/help/app-overview.md
index e675d42b5..994a2e035 100644
--- a/docs/help/app-overview.md
+++ b/docs/help/app-overview.md
@@ -27,7 +27,7 @@ Coder Studio 是一款**本地优先**的 AI 编码工作台。它将 AI Agent
会话是你在某个 Provider 下的一次 Agent 运行。每个会话:
-- 对应一个 AI 编码 Agent(Claude 或 Codex)
+- 对应一个 AI 编码 Agent Provider
- 拥有独立的终端输出
- 可以独立启动、停止和恢复
@@ -35,10 +35,13 @@ Coder Studio 是一款**本地优先**的 AI 编码工作台。它将 AI Agent
### Provider
-Provider 是 AI Agent 的运行环境。当前支持两种:
+Provider 是 AI Agent 的运行环境。当前内置支持:
-- **Claude**:Anthropic 的 Claude Code CLI
+- **Claude Code**:Anthropic 的 Claude Code CLI
- **Codex**:OpenAI 的 Codex CLI
+- **Gemini CLI**:Google 的 Gemini CLI
+- **Cursor Agent**:Cursor 的 agent CLI
+- **OpenCode**:OpenCode CLI
Provider 需要在本地独立安装。Coder Studio 会在启动会话时调用相应的 Provider CLI。
@@ -51,13 +54,13 @@ Provider 需要在本地独立安装。Coder Studio 会在启动会话时调用
### Skills(技能)
-技能是分发给 Agent 的本地说明文件,用来教 Agent 在特定场景下如何行动。Coder Studio 会内置一组第一方技能,并在服务启动时同步到支持技能挂载的 Provider。
+技能是分发给 Agent 的本地说明文件,用来教 Agent 在特定场景下如何行动。Coder Studio 支持安装、管理和挂载技能,也保留内置技能同步机制;当前默认不预装第一方内置技能。
-内置技能的 MVP 重点是:
+与技能和 Agent 自动化相关的基础 CLI 能力包括:
- 让 Agent 通过 `coder-studio identify --json` 识别当前 workspace、session 和 provider
-- 让 Agent 通过 `coder-studio capabilities --json` 发现可用自动化命令
-- 在任务结束前提醒 Agent 检查 Git 变更、测试结果和残余风险
+- 让 Agent 通过 `coder-studio capabilities --json` 查看可用的只读验证命令清单
+- 让 Agent 读取终端输出、Git 状态和指定文件 diff,辅助最终检查
### Settings(设置)
@@ -86,7 +89,7 @@ Coder Studio 使用响应式界面,同一服务同时支持:
## 常见问题
**Q:一个工作区可以有多个会话吗?**
-可以。你可以在同一工作区内同时运行 Claude 和 Codex 会话。
+可以。你可以在同一工作区内同时运行多个 Provider 会话。
**Q:可以打开多个项目吗?**
可以。通过顶栏的工作区标签切换,每个工作区独立管理自己的会话、文件和终端。
diff --git a/docs/help/cli.md b/docs/help/cli.md
index cf90cffb9..c9cba8048 100644
--- a/docs/help/cli.md
+++ b/docs/help/cli.md
@@ -98,13 +98,13 @@ coder-studio identify --json
### coder-studio capabilities
-输出面向 Agent 的自动化能力清单,包括命令名、CLI 用法、权限需求、风险等级和示例。
+输出面向 Agent 的只读自动化命令清单,包括命令名、CLI 用法、权限需求、风险等级和示例。
```bash
coder-studio capabilities --json
```
-Agent 应优先使用 JSON 输出做能力发现;如果某项能力不存在,说明当前版本还没有提供对应自动化入口。
+Agent 应优先使用 JSON 输出做能力发现;当前版本只返回工作区、会话、终端和 Git 读取命令,不包含浏览器驱动命令。
### Agent 自动化只读命令
@@ -130,6 +130,8 @@ coder-studio git diff --workspace ws_123 --path src/a.ts --staged --json
这些命令当前只做读取,不会修改工作区、会话、终端或 Git 状态。Agent 应先运行 `coder-studio capabilities --json`,再根据返回的能力清单选择命令。
+如果你要做浏览器可见变更的验证,`capabilities` 只负责把可用的读取命令列出来;真正的浏览器检查仍然需要在浏览器里直接看结果,或者借助项目自己的 E2E / Playwright 流程。
+
### coder-studio help
显示完整的帮助信息,包括所有命令和选项。
@@ -153,7 +155,7 @@ coder-studio open # 启动并打开浏览器(最常用)
coder-studio status # 随时检查服务状态
coder-studio logs # 出问题时查看日志
coder-studio identify --json # Agent 发现当前运行上下文
-coder-studio capabilities --json # Agent 发现可用自动化能力
+coder-studio capabilities --json # Agent 查看可用只读验证命令
coder-studio git status --workspace ws_123 --json # Agent 读取 Git 状态
coder-studio stop # 停止服务
```
diff --git a/docs/help/desktop-guide.md b/docs/help/desktop-guide.md
index 2a490d175..8b98b2dd8 100644
--- a/docs/help/desktop-guide.md
+++ b/docs/help/desktop-guide.md
@@ -121,7 +121,7 @@ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
### 创建会话
-当没有文件处于编辑状态时,中央区域显示 Agent 面板。点击创建会话按钮,选择 Provider(Claude 或 Codex)即可启动。
+当没有文件处于编辑状态时,中央区域显示 Agent 面板。点击创建会话按钮,选择已检测到的 Provider 即可启动。首次试跑建议选择 Claude 或 Codex。
### 切换会话
diff --git a/docs/help/first-agent-run.md b/docs/help/first-agent-run.md
new file mode 100644
index 000000000..210911a08
--- /dev/null
+++ b/docs/help/first-agent-run.md
@@ -0,0 +1,108 @@
+# 第一次 Agent 运行
+
+这篇文档带你完成第一次有效的 Coder Studio Agent 会话:打开项目、创建推荐的首次 Provider 会话、观察输出,并在 Git diff 中审查改动。
+
+## 这篇文档解决什么问题
+
+快速验证 Coder Studio 是否已经可以承接真实 AI coding 工作流,而不只是打开界面。
+
+## 前置条件
+
+- 已安装 Coder Studio:`npm install -g @spencer-kit/coder-studio`
+- Node.js 版本为 24 或更新:`node --version`
+- 至少安装一个 Provider CLI。首次试跑建议选择 Claude Code 或 Codex:
+ - Claude Code:`npm install -g @anthropic-ai/claude-code`
+ - Codex:`npm install -g @openai/codex`
+- Provider CLI 可以在普通终端中执行:
+ - `claude --version`
+ - `codex --version`
+
+## 1. 启动 Coder Studio
+
+```bash
+coder-studio open
+```
+
+浏览器会自动打开。如果没有自动打开,先执行:
+
+```bash
+coder-studio status
+```
+
+然后手动访问命令输出里的本地地址。
+
+## 2. 打开一个本地项目
+
+在欢迎页点击 **打开工作区**,选择一个你可以安全试验的小型仓库。
+
+建议第一次选择:
+
+- 有 README 的个人项目
+- 有 Git 仓库历史的项目
+- 不包含生产密钥或敏感配置的项目
+
+## 3. 创建推荐的首次 Provider 会话
+
+进入工作区后,点击 **创建会话**,选择已检测到的 Provider。首次试跑建议选择 Claude 或 Codex,因为对应安装和排障文档最完整。
+
+如果 Provider 显示未安装:
+
+1. 回到普通终端安装对应 CLI。
+2. 用 `claude --version` 或 `codex --version` 验证。
+3. 刷新 Coder Studio 页面或重新打开工作区。
+
+## 4. 使用一个安全的小任务
+
+第一次运行不要让 Agent 直接做大规模重构。建议使用这个任务:
+
+```text
+请阅读 README,补充一小段“本项目适合谁使用”的说明。改动保持在 1-2 段内,不要改代码。
+```
+
+这个任务适合第一次验证,因为:
+
+- 改动范围小
+- Git diff 容易审查
+- 即使结果不理想也容易回滚
+
+## 5. 查看输出和文件变化
+
+会话运行时,观察 Agent 面板里的输出。任务结束后:
+
+1. 打开 Git 视图。
+2. 查看 changed files。
+3. 打开 diff。
+4. 判断改动是否符合你的要求。
+
+如果你不满意,可以继续在同一个 Agent 会话中要求它调整,也可以直接手动编辑文件。
+
+## 6. 在移动端查看进度
+
+如果你想用手机或平板查看进度,先阅读 [移动端与远程访问指南](mobile-guide.md)。
+
+移动端适合:
+
+- 查看 Agent 是否还在运行
+- 查看终端输出
+- 浏览文件和 diff
+- 监督长任务
+
+移动端不适合作为第一次试用时的主力编辑环境。第一次 Agent 运行建议先在桌面端完成。
+
+## 常见问题
+
+**Q:没装 Claude 或 Codex 能不能试?**
+可以打开工作区、浏览文件、使用终端,但需要安装某个 Provider 的 CLI 后才能创建对应 Agent 会话。
+
+**Q:安装了 Provider 但 Coder Studio 仍然找不到?**
+在普通终端执行 `which ` 和 ` --version`。如果找不到,说明安装目录不在 PATH 中。按 [Provider 配置](providers.md) 排查。
+
+**Q:Agent 改坏了怎么办?**
+先看 Git diff。你可以手动编辑修正,也可以用 Git 丢弃不需要的改动。
+
+## 下一步
+
+- [Provider 配置](providers.md)
+- [移动端与远程访问指南](mobile-guide.md)
+- [排障指南](troubleshooting.md)
+- [常见工作流](workflows.md)
diff --git a/docs/help/known-limitations.md b/docs/help/known-limitations.md
new file mode 100644
index 000000000..9f9858d20
--- /dev/null
+++ b/docs/help/known-limitations.md
@@ -0,0 +1,70 @@
+# 当前限制
+
+这篇文档说明 Coder Studio 当前版本的要求、边界和不适合承诺的场景。
+
+## 这篇文档解决什么问题
+
+帮助新用户在试用前建立正确预期,避免把 Coder Studio 理解成云 IDE、VS Code 替代品或 AI 模型服务。
+
+## 系统要求
+
+- Node.js 24 或更新版本是必需的。
+- Coder Studio 通过 npm 全局安装。
+- AI Agent 会话依赖本机已安装的 Provider CLI。
+
+## Provider 边界
+
+Coder Studio 不内置 Claude、Codex 或其他模型。它负责在工作区中启动和管理本地 Provider CLI。
+
+如果没有安装 Provider CLI:
+
+- 可以打开 workspace
+- 可以浏览文件
+- 可以使用终端
+- 不能创建对应的 Agent 会话
+
+## 移动端边界
+
+移动端适合查看、监控和审查,不适合作为第一次试用或重度编码的主力入口。
+
+推荐移动端使用场景:
+
+- 查看长任务进度
+- 阅读 Agent 输出
+- 浏览文件
+- 查看 Git diff
+- 判断是否需要回到桌面端介入
+
+## 远程访问边界
+
+Coder Studio 默认更适合本机或可信网络访问。跨设备或公网访问需要你自己配置网络和认证。
+
+远程访问前应当:
+
+```bash
+coder-studio config --password <强密码>
+coder-studio config --host 0.0.0.0
+coder-studio serve --restart
+```
+
+不要把无密码的 Coder Studio 暴露到公网。
+
+## 当前不主打的能力
+
+当前增长发布阶段不主打:
+
+- 云端托管工作区
+- 多人团队权限系统
+- 手机端完整编码替代桌面端
+- 插件市场
+- 会话回放
+- 偏好云同步
+
+这些方向可能以后会演进,但不是第一次试用 Coder Studio 的核心价值。
+
+## 下一步
+
+- [快速开始](quick-start.md)
+- [第一次 Agent 运行](first-agent-run.md)
+- [移动端与远程访问指南](mobile-guide.md)
+- [排障指南](troubleshooting.md)
diff --git a/docs/help/mobile-guide.md b/docs/help/mobile-guide.md
index dbe428219..6c850c066 100644
--- a/docs/help/mobile-guide.md
+++ b/docs/help/mobile-guide.md
@@ -26,6 +26,36 @@
不建议直接把 Coder Studio 端口暴露到公网。至少先启用密码,并优先使用 Tailscale、ngrok 或 Cloudflare Tunnel 这类反向隧道/VPN 方案。
+## 移动端适合什么
+
+移动端的主要价值是延续桌面端已经启动的工作流:
+
+- 查看 Agent 是否仍在运行
+- 阅读终端输出和状态变化
+- 浏览文件和 Git diff
+- 检查 Supervisor 进度
+- 在离开电脑后决定是否需要介入
+
+移动端不应该被理解成完整替代桌面端的主力编码环境。第一次安装、Provider 配置、大规模文件编辑和复杂 Git 操作建议先在桌面端完成。
+
+## 访问本机开发服务
+
+如果项目里启动了只能在电脑本机访问的开发服务,例如 `http://localhost:8000` 或 `http://127.0.0.1:5173`,手机浏览器通常不能直接打开这个地址。Coder Studio 的内置 Browser 可以为这类 loopback HTTP 服务创建一个受控代理入口,让你在 Coder Studio 页面内部查看页面。
+
+使用方式:
+
+1. 在项目终端里启动你的开发服务。
+2. 在 Coder Studio 编辑器 header 点击 **Browser**,打开浏览器标签。
+3. 输入本机开发服务地址,例如 `http://localhost:8000`。
+4. 点击打开后,页面会在内置 Browser iframe 中加载,HTML、CSS、JS、图片等同源资源会通过 Coder Studio 的代理转发。
+
+限制:
+
+- v1 仅支持 HTTP/HTTPS 页面和资源请求,不支持 WebSocket。
+- Vite、Next.js 等开发服务器的 HMR/WebSocket 连接会失败;需要刷新页面查看代码变更。
+- 该入口只在 Coder Studio 内置 Browser 中工作,不会把 `localhost:8000` 变成任意外部浏览器都能访问的公开地址。
+- 仅用于访问本机 loopback 开发服务,不是通用公网代理。
+
## 局域网访问
局域网访问适合手机和电脑在同一 Wi-Fi 下的情况。
@@ -211,6 +241,10 @@ https://coder.example.com -> http://localhost:<端口>
- 该工作区的所有终端
- 新建终端的入口
+### 查看本机开发服务
+
+在编辑器 header 点击 Browser 按钮,输入电脑上的 loopback 服务地址,例如 `http://localhost:8000`。页面会在内置 Browser 中通过 Coder Studio 代理加载。WebSocket/HMR 暂不支持,代码变更后需要手动刷新。
+
### Supervisor 与通知
如果 Supervisor 处于活跃状态,Agent 区域会显示 Supervisor 徽章。点击可以打开 Supervisor 详情面板,查看当前进度。
diff --git a/docs/help/providers.md b/docs/help/providers.md
index 4eca1312d..8b8fe0897 100644
--- a/docs/help/providers.md
+++ b/docs/help/providers.md
@@ -4,7 +4,7 @@
## 这篇文档解决什么问题
-如何安装 Claude 或 Codex CLI,以便在 Coder Studio 中创建 Agent 会话。
+如何安装常用 coding agent CLI,以便在 Coder Studio 中创建 Agent 会话。
## 前置条件
@@ -13,12 +13,15 @@
## 当前支持哪些 Provider
-Coder Studio 目前支持两种 Provider:
+Coder Studio 当前内置支持:
-- **Claude**:Anthropic Claude Code CLI(Full mode)
-- **Codex**:OpenAI Codex CLI(Limited mode)
+- **Claude Code**:`claude`,Full,Stable
+- **Codex**:`codex`,Full,Stable
+- **Gemini CLI**:`gemini`,Full,Stable
+- **Cursor Agent**:`agent`,Full,Stable
+- **OpenCode**:`opencode`,Limited,Experimental
-两者都需要在本地独立安装。
+这些 Provider 都需要在本地独立安装。Full 表示支持交互式会话、idle detection,以及 supervisor/session analysis 这类自动化工作流。Limited 表示可以运行交互式会话,但并非所有自动化能力都已接入。
## 安装 Claude CLI
@@ -44,9 +47,87 @@ npm install -g @openai/codex
codex --version
```
+## 安装 Gemini CLI
+
+```bash
+npm install -g @google/gemini-cli
+```
+
+安装后确认:
+
+```bash
+gemini --version
+```
+
+## 安装 Cursor Agent
+
+macOS 和 Linux 可以使用 Cursor 官方安装脚本:
+
+```bash
+curl https://cursor.com/install -fsS | bash
+```
+
+安装后确认:
+
+```bash
+agent --version
+```
+
+## 安装 OpenCode
+
+```bash
+npm install -g opencode-ai
+```
+
+安装后确认:
+
+```bash
+opencode --version
+```
+
+## 验证 PATH
+
+安装 Provider CLI 后,Coder Studio 只能识别当前服务进程 PATH 中能找到的命令。先在普通终端验证:
+
+```bash
+which claude
+which codex
+which gemini
+which agent
+which opencode
+claude --version
+codex --version
+gemini --version
+agent --version
+opencode --version
+```
+
+如果 `which` 找不到命令,常见原因是全局 npm 命令目录没有加入 PATH。
+
+查看全局 npm 前缀目录:
+
+```bash
+npm config get prefix
+npm prefix -g
+```
+
+在 macOS/Linux 上,全局命令 shim 通常在 `/bin`。把这个目录加入 shell 配置后,重新打开终端并重启 Coder Studio:
+
+```bash
+coder-studio serve --restart
+```
+
+Windows 用户还需要确认 npm 的全局命令目录在用户或系统 PATH 中。常见目录是 `%APPDATA%\npm`。
+
## Coder Studio 如何识别 Provider
-Coder Studio 启动工作区后会自动检测系统中是否有 `claude` 和 `codex` 命令。如果检测到,界面上就会显示对应的 Provider 入口。如果未检测到,创建会话时会显示安装提示。
+Coder Studio 启动工作区后会自动检测系统中是否有内置 Provider 需要的命令,例如 `claude`、`codex`、`gemini`、`agent` 和 `opencode`。如果检测到,界面上就会显示对应的 Provider 入口。如果未检测到,创建会话时会显示安装提示。
+
+## Aider 和自定义 Provider
+
+Aider 不属于当前内置 Provider 列表。它适合通过 preset/custom-provider 工作流接入:先在本地安装 `aider` 命令,再用自定义 Provider 配置指定命令、参数和工作目录。
+
+自定义 Provider 主要是交互式命令集成,不一定支持内置 Provider 的 supervisor、idle detection、Agent instructions 或自动安装能力。
## 在设置页中配置 Provider
@@ -71,7 +152,7 @@ Coder Studio 启动工作区后会自动检测系统中是否有 `claude` 和 `c
## 常见问题
**Q:安装了 Provider 但 Coder Studio 仍然说未找到?**
-确保 `npm install -g` 安装的目录在 PATH 中。可以用 `which claude` 或 `which codex` 验证。
+确保安装目录在 PATH 中。可以用 `which ` 和 ` --version` 验证,例如 `which gemini`、`which opencode`。
**Q:两个 Provider 可以同时用吗?**
-可以。Coder Studio 支持在同一工作区内并行运行 Claude 和 Codex 会话。
+可以。Coder Studio 支持在同一工作区内并行运行多个 Provider 会话。
diff --git a/docs/help/quick-start.md b/docs/help/quick-start.md
index 32bad548b..a18616617 100644
--- a/docs/help/quick-start.md
+++ b/docs/help/quick-start.md
@@ -22,8 +22,8 @@ node --version
Coder Studio 本身不包含 AI 引擎,需要安装 Provider CLI 才能创建 Agent 会话:
-- **Claude**:需要安装 Claude Code CLI
-- **Codex**:需要安装 OpenAI Codex CLI
+- 推荐首次试跑:Claude Code 或 Codex
+- 其他内置 Provider:Gemini CLI、Cursor Agent、OpenCode
安装步骤见 [Provider 配置指南](providers.md)。
@@ -64,7 +64,7 @@ coder-studio open
### 2. 创建第一个 Agent 会话
-打开工作区后,你会看到 Agent 工作区。点击 **"创建会话"**,选择 Claude 或 Codex 即可开始。
+打开工作区后,你会看到 Agent 工作区。点击 **"创建会话"**,选择已检测到的 Provider 即可开始。首次试跑建议选择 Claude 或 Codex。
如果 Provider 未安装,界面上会显示安装提示和指引。
@@ -98,6 +98,8 @@ Coder Studio 需要 Node.js >= 24.0.0。可以用 `nvm use 24` 或从 [nodejs.or
## 下一步看什么
+- [第一次 Agent 运行](first-agent-run.md) — 完成第一个推荐 Provider 会话并审查 diff
- [App 功能总览](app-overview.md) — 了解核心概念
- [桌面端使用指南](desktop-guide.md) — 熟悉桌面端操作
- [移动端使用指南](mobile-guide.md) — 在手机端访问和使用
+- [排障指南](troubleshooting.md) — 解决 Node、Provider、端口和认证问题
diff --git a/docs/help/security-and-privacy.md b/docs/help/security-and-privacy.md
new file mode 100644
index 000000000..815c95851
--- /dev/null
+++ b/docs/help/security-and-privacy.md
@@ -0,0 +1,45 @@
+# 安全与隐私
+
+这篇文档说明 Coder Studio 的本地运行模型、Provider 数据边界和远程访问风险。
+
+## 运行位置
+
+- Coder Studio server 运行在你的机器上。
+- Web UI 由本机服务提供。
+- 你打开的 workspace 来自本机项目目录。
+- Agent 会话通过本机已安装的 Provider CLI 启动。
+
+Coder Studio 本身不是托管代码服务,不会把你的 workspace 作为云端代码环境运行。
+
+## Provider 数据边界
+
+Claude Code、Codex 或其他 Provider CLI 可能会根据各自的行为和配置,发送 prompts、代码上下文、终端输出、文件片段或其他任务数据。
+
+如果你需要严格的数据处理保证,请检查对应 Provider 的文档、账号设置和 CLI 配置。
+
+## 远程访问风险
+
+局域网或远程访问前先设置密码:
+
+```bash
+coder-studio config --password <强密码>
+coder-studio serve --restart
+```
+
+不要在没有认证的情况下把 Coder Studio 暴露到公网。
+
+请把远程 Coder Studio 访问当作远程 shell 访问处理:任何能够通过认证的人,都可能用你的本地用户权限读取文件、运行终端命令,并触发 Provider 工具。
+
+## 实用检查清单
+
+- 开放给其他设备前设置强密码。
+- 个人跨设备访问优先使用 Tailscale。
+- 提交前审查 Git diff。
+- 临时隧道使用结束后及时停止。
+- 敏感仓库避免使用公开链接访问。
+
+## 下一步
+
+- [移动端与远程访问指南](mobile-guide.md)
+- [当前限制](known-limitations.md)
+- [排障指南](troubleshooting.md)
diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md
index dac7244a3..053100e6c 100644
--- a/docs/help/troubleshooting.md
+++ b/docs/help/troubleshooting.md
@@ -11,6 +11,21 @@
- 已安装 Coder Studio 和 Node.js >= 24.0.0
- 能够执行 `coder-studio status`、`coder-studio logs` 等 CLI 命令
+## 首次运行快速排查
+
+如果第一次试用没有顺利跑起来,按这个顺序排查:
+
+1. `node --version` 确认 Node.js >= 24.0.0。
+2. `coder-studio version` 确认 CLI 已安装。
+3. `coder-studio status` 确认服务正在运行。
+4. `coder-studio logs` 查看最近错误。
+5. `which ` 确认 Provider CLI 在 PATH 中。
+6. ` --version` 确认 Provider CLI 可执行。
+7. 如果浏览器打不开,手动访问 `coder-studio status` 输出的 URL。
+8. 如果移动端打不开,确认服务监听 `0.0.0.0` 且防火墙允许该端口。
+
+首次试用建议先在桌面端完成,不要一开始就通过公网隧道或手机端排查所有问题。
+
## 服务启动失败
如果 `coder-studio serve` 或 `coder-studio open` 启动后没有正常响应:
@@ -46,9 +61,25 @@
```bash
which claude
which codex
+ which gemini
+ which agent
+ which opencode
+ claude --version
+ codex --version
+ gemini --version
+ agent --version
+ opencode --version
+ ```
+2. 如果未找到命令,按 [Provider 配置指南](providers.md) 安装或修复 PATH。
+3. 如果普通终端能找到命令,但 Coder Studio 找不到,重启服务:
+ ```bash
+ coder-studio serve --restart
+ ```
+4. 如果仍不可用,查看日志:
+ ```bash
+ coder-studio logs
```
-2. 如果未找到,按 [Provider 配置指南](providers.md) 进行安装
-3. 安装后刷新浏览器页面或重新打开工作区
+5. 提交问题时附上 Node 版本、Provider 版本、`which` 输出和日志片段。
## 无法创建会话
diff --git a/docs/help/workflows.md b/docs/help/workflows.md
index ad10c9daf..dd26e3f63 100644
--- a/docs/help/workflows.md
+++ b/docs/help/workflows.md
@@ -9,7 +9,7 @@
## 前置条件
- Coder Studio 已安装并启动
-- 已配置至少一个 Provider(Claude 或 Codex)
+- 已配置至少一个 Provider
## 打开一个本地项目并开始工作
diff --git a/docs/product-spec/modules/work-analysis.zh-CN.md b/docs/product-spec/modules/work-analysis.zh-CN.md
index eee8f789f..98f602f08 100644
--- a/docs/product-spec/modules/work-analysis.zh-CN.md
+++ b/docs/product-spec/modules/work-analysis.zh-CN.md
@@ -51,10 +51,11 @@
系统响应:
- 页面创建 work analysis controller,并从 URL query 解析时间范围和目录筛选。
- 控制器加载 dashboard 后渲染状态条、KPI、token 趋势、贡献排行、任务/工具/skill 归因和小时热力图。
+- 没有可用或足够新的小时索引时,服务自动补齐索引后返回 dashboard。
- 没有 dashboard 时展示空态,并提供立即刷新按钮。
状态与边界:
-- Loading:首次读取索引时显示读取提示。
+- Loading:首次读取或补齐索引时显示扫描中提示。
- Empty:没有索引或数据时显示“暂无工作分析索引”。
- Warning:scanState error 或 dashboard quality warnings 以 Notice 展示。
- URL sync:筛选条件变化后写回路由 query。
@@ -62,8 +63,9 @@
验收标准:
- Given 当前没有 dashboard 数据
- When 用户打开 Work Analysis 页面
-- Then 页面显示空态
-- And 提供立即刷新操作
+- Then 页面显示自动扫描中的状态
+- And 索引补齐后展示 dashboard
+- And 仍提供立即刷新操作
代码索引:
- `packages/web/src/features/work-analysis/page.tsx`
diff --git a/docs/promotion/growth-launch-2026-06.en.md b/docs/promotion/growth-launch-2026-06.en.md
new file mode 100644
index 000000000..4924b8b3a
--- /dev/null
+++ b/docs/promotion/growth-launch-2026-06.en.md
@@ -0,0 +1,70 @@
+# Coder Studio English Launch Copy
+
+## Show HN Title
+
+Show HN: Coder Studio - a self-hosted browser workspace for AI coding agents
+
+## Short Description
+
+Coder Studio brings Claude Code, Codex, terminals, files, Git diff review, supervision, and cross-device continuation into one self-hosted browser workspace.
+
+## Long Post Draft
+
+I built Coder Studio because terminal-only AI coding gets scattered once the agent output turns into real project work.
+
+The agent runs in one terminal, files are in an editor, diffs are in another tool, and long tasks require repeatedly checking whether anything stalled. If you leave the machine where the CLI is running, the workflow gets even more awkward.
+
+Coder Studio is a self-hosted browser workspace around local AI coding agent CLIs.
+
+It brings together:
+
+- built-in session support for Claude Code, Codex, Gemini CLI, Cursor Agent, and OpenCode
+- preset/custom-provider oriented workflows for tools such as Aider
+- local terminals
+- file tree and Monaco editor
+- Git status, changed files, and diff review
+- Supervisor loops for long tasks
+- Work Analysis for reviewing what happened over time
+- Skills management for reusable agent workflows
+- desktop, tablet, and phone access to the same workspace
+
+Install:
+
+```bash
+npm install -g @spencer-kit/coder-studio
+coder-studio open
+```
+
+A good first trial:
+
+1. Open a local repository.
+2. Start an agent session. For a first trial, Claude or Codex is the recommended path.
+3. Ask the agent for a small README or UI-copy change.
+4. Review the changed files and Git diff beside the session.
+5. Reopen the same workspace from a phone or tablet to monitor progress.
+
+What it is not:
+
+- not a cloud IDE
+- not a VS Code replacement
+- not an AI model provider
+- not a promise that phone screens replace desktop coding
+
+Coder Studio itself is not a hosted code service and opens local repositories. Provider CLIs may send prompts, code context, terminal output, file snippets, or other task data according to their own behavior and configuration.
+
+GitHub:
+
+https://github.com/spencerkit/coder-studio
+
+npm:
+
+https://www.npmjs.com/package/@spencer-kit/coder-studio
+
+I would especially like feedback from people running long Claude Code or Codex tasks, reviewing agent diffs frequently, or switching between desktop and mobile devices during AI coding work.
+
+## Reddit Notes
+
+- For `r/selfhosted`, lead with local-first and remote access boundaries.
+- For AI coding communities, lead with Claude Code / Codex workflow pain.
+- Avoid posting the same text to many communities on the same day.
+- Answer security and Provider CLI questions directly.
diff --git a/docs/promotion/growth-launch-2026-06.zh-CN.md b/docs/promotion/growth-launch-2026-06.zh-CN.md
new file mode 100644
index 000000000..9a06b1a09
--- /dev/null
+++ b/docs/promotion/growth-launch-2026-06.zh-CN.md
@@ -0,0 +1,70 @@
+# Coder Studio 中文增长发布长帖
+
+## 推荐标题
+
+我做了一个自部署的 AI Coding 工作台,用来管理 Claude Code / Codex 长任务
+
+## 正文草稿
+
+很多 AI coding 工作流一开始都很轻:打开终端,启动 Claude Code 或 Codex,写一个 prompt,然后等它输出。
+
+但进入真实项目后,问题会变得分散:
+
+- Agent 在终端里跑,文件在编辑器里看
+- 改动需要再切到 Git 工具里审查
+- 长任务经常要反复回来确认有没有卡住
+- 换到手机或平板后,很难看到完整 workspace 上下文
+
+Coder Studio 想解决的不是“再做一个云 IDE”,而是给 AI coding agent 一个自部署的浏览器工作台。
+
+它把这些东西放在同一个 workspace 里:
+
+- Claude Code / Codex 等 Agent 会话
+- 本地终端
+- 文件树和 Monaco 编辑器
+- Git 状态、changed files 和 diff
+- Supervisor 长任务监督
+- Work Analysis 工作复盘
+- Skills 管理
+- 桌面、平板、手机的同一个 workspace 访问
+
+安装方式:
+
+```bash
+npm install -g @spencer-kit/coder-studio
+coder-studio open
+```
+
+第一次试用建议做一个小任务:
+
+1. 打开一个本地仓库。
+2. 创建 Agent 会话。首次试跑建议选择 Claude 或 Codex。
+3. 让 Agent 改 README 的一小段说明。
+4. 在 Git diff 里审查结果。
+5. 用手机或平板打开同一个 workspace 查看进度。
+
+它不是:
+
+- 不是云 IDE
+- 不是 VS Code 替代品
+- 不是 AI 模型服务
+- 不是承诺手机完整替代桌面编码
+
+它更像是围绕本地 repo、本地 shell 和 AI coding CLI 的一层工作台。Coder Studio 本身不是托管代码服务,打开的是本地仓库;Provider CLI 可能根据自己的行为和配置发送 prompts、代码上下文、终端输出、文件片段或其他任务数据。
+
+项目地址:
+
+https://github.com/spencerkit/coder-studio
+
+npm:
+
+https://www.npmjs.com/package/@spencer-kit/coder-studio
+
+如果你主要使用 Claude Code / Codex,或者正在关注 Gemini CLI、Cursor Agent、OpenCode、Aider 这类 CLI agent 工作流,尤其经常跑长任务、审查 diff、或者希望离开电脑后还能看进度,这个项目应该值得试一下。
+
+## V2EX 发布备注
+
+- 标题保持技术导向,不使用夸张词。
+- 首帖重点解释“为什么不是云 IDE”。
+- 评论区优先收集安装、Provider、Node 版本和 PATH 问题。
+- 不在首帖承诺插件系统、团队协作或云同步。
diff --git a/docs/promotion/growth-launch-checklist-2026-06.md b/docs/promotion/growth-launch-checklist-2026-06.md
new file mode 100644
index 000000000..b272ff3a4
--- /dev/null
+++ b/docs/promotion/growth-launch-checklist-2026-06.md
@@ -0,0 +1,67 @@
+# Growth Launch Checklist - 2026-06
+
+Use this checklist before posting broadly in English communities.
+
+## Landing Pages
+
+- [ ] `README.md` explains the product in 30 seconds.
+- [ ] `README.md` links English users to `docs/wiki/*`.
+- [ ] `README.zh-CN.md` links Chinese users to `docs/help/*`.
+- [ ] `packages/cli/README.md` explains install, first run, Provider CLIs, and boundaries.
+- [ ] `packages/cli/package.json` description and keywords match the launch positioning.
+
+## First Trial
+
+- [ ] Quick Start explains install and `coder-studio open`.
+- [ ] First Agent Run explains opening a repo, starting a recommended first provider, and reviewing Git diff.
+- [ ] Provider docs explain install, `which`, `--version`, and PATH checks.
+- [ ] Troubleshooting covers Node 24, service status, logs, Provider CLI, port, browser, auth, and mobile access.
+
+## Trust And Boundaries
+
+- [ ] Security docs explain local-first behavior and Provider CLI data boundaries.
+- [ ] Known Limitations explain Node 24, Provider CLI dependency, mobile limits, and remote access responsibility.
+- [ ] Mobile docs describe monitoring and review as the primary phone/tablet use case.
+- [ ] README avoids claiming Coder Studio is a cloud IDE, VS Code replacement, AI provider, or full mobile coding replacement.
+
+## Feedback Intake
+
+- [ ] `CONTRIBUTING.md` exists.
+- [ ] Installation issue template exists.
+- [ ] Provider setup issue template exists.
+- [ ] Feature request template exists.
+- [ ] Workflow showcase template exists.
+
+## Promotion Materials
+
+- [ ] Chinese long-form post is ready.
+- [ ] English launch post is ready.
+- [ ] Social posts are ready.
+- [ ] Launch FAQ is ready.
+- [ ] Show HN title is ready.
+
+## Manual Trial
+
+- [ ] Install from npm in a clean shell.
+- [ ] Run `coder-studio open`.
+- [ ] Open a local repository.
+- [ ] Start one recommended first-run provider session.
+- [ ] Ask for a small documentation change.
+- [ ] Review changed files and Git diff.
+- [ ] Open the workspace from a second browser size or device.
+- [ ] Record any confusion in the feedback log.
+
+## Verification Commands
+
+```bash
+git diff --check
+pnpm ci:lint
+pnpm ci:test
+pnpm ci:build
+```
+
+Before public English launch, prefer:
+
+```bash
+pnpm ci:verify
+```
diff --git a/docs/promotion/launch-faq-2026-06.md b/docs/promotion/launch-faq-2026-06.md
new file mode 100644
index 000000000..6ec6339cd
--- /dev/null
+++ b/docs/promotion/launch-faq-2026-06.md
@@ -0,0 +1,33 @@
+# Growth Launch FAQ
+
+## What is Coder Studio?
+
+Coder Studio is a self-hosted browser workspace for AI coding agent workflows. It brings agent sessions, terminals, files, Git diff review, supervision, work analysis, and cross-device continuation into one workspace.
+
+## Is it a cloud IDE?
+
+No. It runs on your machine and opens local project directories. Remote access is something you configure through LAN, Tailscale, ngrok, Cloudflare Tunnel, or another network path. Set a strong password and authentication before exposing it beyond your local machine.
+
+## Is it a VS Code replacement?
+
+No. It is focused on AI coding agent workflows: running agents, watching progress, reviewing changes, and continuing across devices.
+
+## Does it include AI models or Provider CLIs?
+
+No. You install Provider CLIs separately, such as Claude Code, Codex, Gemini CLI, Cursor Agent, or OpenCode. Coder Studio detects and launches those local commands.
+
+## Does code leave my machine?
+
+Coder Studio itself is not a hosted code service. Provider CLIs may send prompts, code context, terminal output, file snippets, or other task data according to the provider's own behavior and configuration.
+
+## What is mobile good for?
+
+Mobile is best for checking progress, reading output, browsing files, reviewing diffs, and deciding whether to intervene. Desktop remains the best environment for first setup and heavy editing.
+
+## Why require Node.js 24?
+
+The current package targets the runtime used by the server, CLI, and bundled dependencies. Users should check `node --version` before installing.
+
+## What feedback is most useful?
+
+Installation failures, Provider CLI detection issues, first agent run confusion, mobile setup problems, and real AI coding workflows that still feel awkward.
diff --git a/docs/promotion/releases/README.md b/docs/promotion/releases/README.md
index 7dff80f0b..6a52488c1 100644
--- a/docs/promotion/releases/README.md
+++ b/docs/promotion/releases/README.md
@@ -16,6 +16,13 @@ Add a release narrative when a version is published to GitHub and npm, especiall
4. Install or upgrade command
5. Full changelog comparison link
+## Growth Launch Content Pack
+
+- [Chinese growth launch post](../growth-launch-2026-06.zh-CN.md)
+- [English growth launch post](../growth-launch-2026-06.en.md)
+- [Social posts](../social-posts-2026-06.md)
+- [Launch FAQ](../launch-faq-2026-06.md)
+
## Current releases
- [v0.4.4](v0.4.4.md)
diff --git a/docs/promotion/social-posts-2026-06.md b/docs/promotion/social-posts-2026-06.md
new file mode 100644
index 000000000..60089d234
--- /dev/null
+++ b/docs/promotion/social-posts-2026-06.md
@@ -0,0 +1,35 @@
+# Growth Launch Social Posts
+
+## 中文短帖
+
+我做了一个自部署的 AI Coding 工作台 Coder Studio。
+
+它把 Claude Code / Codex、终端、文件、Git diff、Supervisor 长任务监督和移动端查看进度放进同一个浏览器 workspace。
+
+不是云 IDE,也不是 VS Code 替代品。更像是给 AI coding CLI 加一个可审查、可监督、可跨设备继续的工作台。
+
+```bash
+npm install -g @spencer-kit/coder-studio
+coder-studio open
+```
+
+GitHub: https://github.com/spencerkit/coder-studio
+
+## English Short Post
+
+I built Coder Studio, a self-hosted browser workspace for AI coding agents.
+
+It brings Claude Code, Codex, terminals, files, Git diff review, Supervisor loops, and cross-device continuation into one workspace.
+
+Not a cloud IDE, not a VS Code replacement, and not an AI provider. It is a workbench around local repos, local shells, and the agent CLIs you already use.
+
+```bash
+npm install -g @spencer-kit/coder-studio
+coder-studio open
+```
+
+GitHub: https://github.com/spencerkit/coder-studio
+
+## Demo Caption
+
+Start an agent task on desktop, review the changed files and Git diff beside the session, then reopen the same workspace from mobile to check progress.
diff --git a/docs/superpowers/plans/2026-06-06-agent-automation-skills-mvp.md b/docs/superpowers/plans/2026-06-06-agent-automation-skills-mvp.md
deleted file mode 100644
index 005f1bad8..000000000
--- a/docs/superpowers/plans/2026-06-06-agent-automation-skills-mvp.md
+++ /dev/null
@@ -1,2008 +0,0 @@
-# Agent Automation Skills MVP Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Implement the MVP from `docs/PRD-agent-automation-skills.md`: built-in Coder Studio skills, automatic provider mounting, agent runtime context, `identify`/`capabilities` discovery, and a minimal audit foundation.
-
-**Architecture:** Reuse the existing skill library/mount/health pipeline. Add a built-in skill registry that materializes first-party skills into the state directory, then uses the existing `SkillMountManager` to mount them into provider skill directories. Add an automation metadata layer that backs both WebSocket commands and top-level CLI output, and extend `SessionManager` to inject Coder Studio runtime context into agent terminals.
-
-**Tech Stack:** TypeScript, Vitest, Node.js fs APIs, existing Coder Studio command dispatch, Fastify runtime config, Jotai/React Skills UI.
-
----
-
-## File Structure
-
-Create these new files:
-
-- `packages/server/src/skills/builtin/registry.ts` - declares built-in skill metadata and markdown content.
-- `packages/server/src/skills/builtin/materialize.ts` - writes built-in skill directories and `SKILL.md` files under state.
-- `packages/server/src/skills/builtin/sync-manager.ts` - synchronizes built-ins into the skill library and mounts default-enabled skills.
-- `packages/server/src/automation/capabilities.ts` - defines automation capability metadata and filtering.
-- `packages/server/src/automation/identify.ts` - resolves current runtime context from environment/runtime data.
-- `packages/server/src/automation/audit-log.ts` - append-only local audit writer with sanitization.
-- `packages/server/src/commands/automation.ts` - WebSocket commands for `automation.identify` and `automation.capabilities`.
-- `packages/cli/src/automation-client.ts` - CLI helpers for `identify` and `capabilities` output.
-- `packages/server/src/__tests__/skills/builtin-registry.test.ts`
-- `packages/server/src/__tests__/skills/builtin-sync-manager.test.ts`
-- `packages/server/src/__tests__/automation/identify.test.ts`
-- `packages/server/src/__tests__/automation/capabilities.test.ts`
-- `packages/server/src/__tests__/automation/audit-log.test.ts`
-
-Modify these existing files:
-
-- `packages/core/src/domain/skill-management.ts` - add `builtin` as a skill source and built-in related fields.
-- `packages/core/src/index.ts` - keep skill-management exports available.
-- `packages/server/src/storage/repositories/skill-library-repo.ts` - preserve built-in entries and scanned local entries.
-- `packages/server/src/skills/mount-manager.ts` - ensure mounted built-in skills follow the same symlink/copy behavior.
-- `packages/server/src/commands/skills.ts` - add built-in sync/list/set-enabled commands and expose built-in metadata in library list.
-- `packages/server/src/commands/index.ts` - import `automation.ts`.
-- `packages/server/src/ws/dispatch.ts` - add optional `builtinSkillSyncMgr`, `automationAuditLog`, and `stateRoot` context dependencies.
-- `packages/server/src/server.ts` - instantiate built-in sync manager, sync built-ins at startup, and wire dependencies.
-- `packages/server/src/session/manager.ts` - inject `CODER_STUDIO_*` env values into agent terminals.
-- `packages/server/src/session/types.ts` if needed for dependency typing.
-- `packages/cli/src/parse-args.ts` - add `identify`, `capabilities`, `--json`, and `skills builtin ...` parsing.
-- `packages/cli/src/cli.ts` - handle new commands.
-- `docs/help/cli.md` - document `identify` and `capabilities`.
-- `packages/web/src/features/workspace/actions/use-skills-panel.ts` - carry built-in metadata through UI state.
-- `packages/web/src/features/workspace/views/shared/skills-panel.tsx` - show built-in source labels and built-in section or grouping.
-- `packages/web/src/features/workspace/views/shared/skills-panel.test.tsx` - cover built-in labels and disabled state.
-
-Implementation notes:
-
-- Store user disablement in `SettingsRepo` under key `skills.builtin.disabledMounts`.
-- Store materialized built-in sources under `/state/skills/builtin//SKILL.md`.
-- Use TypeScript string constants for built-in skill content instead of relying on runtime copying of source markdown assets.
-- MVP auto-mounts `coder-studio-automation` and `coder-studio-review`. It materializes `coder-studio-browser-verification` but does not auto-mount it until browser automation exists.
-- Keep Browser, Plugin/status, MCP, and approval UI out of this MVP implementation.
-
----
-
-### Task 1: Extend Core Skill Types for Built-ins
-
-**Files:**
-- Modify: `packages/core/src/domain/skill-management.ts`
-- Test: `packages/core/src/domain/skill-management.test.ts`
-
-- [ ] **Step 1: Write the failing type/runtime test**
-
-Add a focused test in `packages/core/src/domain/skill-management.test.ts`:
-
-```ts
-import { describe, expect, it } from "vitest";
-import { SKILL_LIBRARY_SOURCES } from "./skill-management";
-
-describe("skill-management", () => {
- it("includes builtin as a supported skill library source", () => {
- expect(SKILL_LIBRARY_SOURCES).toContain("builtin");
- });
-});
-```
-
-If the file already has a `describe("skill-management", ...)`, add only the `it(...)` block and import `SKILL_LIBRARY_SOURCES`.
-
-- [ ] **Step 2: Run the test and verify it fails**
-
-Run:
-
-```bash
-pnpm vitest run packages/core/src/domain/skill-management.test.ts
-```
-
-Expected: FAIL because `SKILL_LIBRARY_SOURCES` is not exported or does not contain `builtin`.
-
-- [ ] **Step 3: Implement the core type change**
-
-Update `packages/core/src/domain/skill-management.ts`:
-
-```ts
-export const SKILL_LIBRARY_SOURCES = ["skillhub", "local", "builtin"] as const;
-type SkillLibrarySource = (typeof SKILL_LIBRARY_SOURCES)[number];
-
-export interface SkillLibraryEntry {
- slug: string;
- displayName: string;
- description?: string;
- version: string;
- source: SkillLibrarySource;
- libraryPath: string;
- installState: SkillInstallState;
- installedAt: number;
- updatedAt: number;
- lastError?: string;
- builtin?: {
- defaultEnabled: boolean;
- autoMount: boolean;
- };
-}
-```
-
-Keep the existing constants and interfaces intact; only replace the hardcoded `source: "skillhub" | "local"` union and add the optional `builtin` metadata.
-
-- [ ] **Step 4: Run the test and verify it passes**
-
-Run:
-
-```bash
-pnpm vitest run packages/core/src/domain/skill-management.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add packages/core/src/domain/skill-management.ts packages/core/src/domain/skill-management.test.ts
-git commit -m "feat: add builtin skill source type"
-```
-
----
-
-### Task 2: Add Built-in Skill Registry and Materialization
-
-**Files:**
-- Create: `packages/server/src/skills/builtin/registry.ts`
-- Create: `packages/server/src/skills/builtin/materialize.ts`
-- Test: `packages/server/src/__tests__/skills/builtin-registry.test.ts`
-
-- [ ] **Step 1: Write the failing registry/materialization tests**
-
-Create `packages/server/src/__tests__/skills/builtin-registry.test.ts`:
-
-```ts
-import { mkdtemp, readFile, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { BUILTIN_SKILLS } from "../../skills/builtin/registry.js";
-import { materializeBuiltinSkills } from "../../skills/builtin/materialize.js";
-
-describe("builtin skills", () => {
- let tempDir: string | undefined;
-
- afterEach(async () => {
- if (tempDir) {
- await rm(tempDir, { recursive: true, force: true });
- }
- });
-
- it("declares the MVP built-in skills with stable defaults", () => {
- expect(BUILTIN_SKILLS.map((skill) => skill.slug)).toEqual([
- "coder-studio-automation",
- "coder-studio-browser-verification",
- "coder-studio-review",
- ]);
- expect(BUILTIN_SKILLS.find((skill) => skill.slug === "coder-studio-automation")).toMatchObject({
- defaultEnabled: true,
- autoMountInMvp: true,
- });
- expect(
- BUILTIN_SKILLS.find((skill) => skill.slug === "coder-studio-browser-verification")
- ).toMatchObject({
- defaultEnabled: true,
- autoMountInMvp: false,
- });
- });
-
- it("materializes built-in SKILL.md files into the state directory", async () => {
- tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-skills-"));
-
- const entries = await materializeBuiltinSkills({
- builtinRoot: tempDir,
- now: () => 1234,
- });
-
- expect(entries).toHaveLength(3);
- expect(entries[0]).toMatchObject({
- source: "builtin",
- installState: "installed",
- installedAt: 1234,
- updatedAt: 1234,
- });
-
- const automation = entries.find((entry) => entry.slug === "coder-studio-automation");
- expect(automation).toBeDefined();
- expect(await readFile(join(automation!.libraryPath, "SKILL.md"), "utf8")).toContain(
- "coder-studio identify --json"
- );
- });
-});
-```
-
-- [ ] **Step 2: Run the test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-registry.test.ts
-```
-
-Expected: FAIL because `skills/builtin/registry.js` and `materialize.js` do not exist.
-
-- [ ] **Step 3: Implement `registry.ts`**
-
-Create `packages/server/src/skills/builtin/registry.ts`:
-
-```ts
-export interface BuiltinSkillDefinition {
- slug: string;
- displayName: string;
- description: string;
- version: string;
- defaultEnabled: boolean;
- autoMountInMvp: boolean;
- content: string;
-}
-
-const AUTOMATION_SKILL = `---
-name: coder-studio-automation
-description: Use when running inside Coder Studio and you need workspace, session, terminal, Git, or event automation.
----
-
-# Coder Studio Automation
-
-When CODER_STUDIO=1 is present:
-
-1. Run \`coder-studio identify --json\` to inspect current context.
-2. Run \`coder-studio capabilities --json\` to discover supported commands.
-3. Prefer commands with \`--json\`.
-4. Use current workspace and session IDs from identify instead of guessing.
-5. Do not run destructive commands unless the user explicitly asked.
-6. If a command returns approval_required, explain what approval is needed and wait.
-`;
-
-const BROWSER_VERIFICATION_SKILL = `---
-name: coder-studio-browser-verification
-description: Use after frontend, UI, CSS, route, form, or browser-visible changes to verify the app in Coder Studio's browser automation surface.
----
-
-# Browser Verification
-
-For browser-visible changes:
-
-1. Use \`coder-studio identify --json\`.
-2. Use \`coder-studio capabilities --json\` and find browser commands.
-3. Start the dev server in a terminal when needed.
-4. Open the local URL in a Coder Studio browser surface.
-5. Wait for the expected text or selector.
-6. Capture a screenshot.
-7. Read console errors.
-8. Report visible issues and fix them before final response.
-
-If browser capabilities are not available, say so and use the best available local verification.
-`;
-
-const REVIEW_SKILL = `---
-name: coder-studio-review
-description: Use before finishing a coding task in Coder Studio to inspect Git changes, tests, and residual risk.
----
-
-# Coder Studio Review
-
-Before final response after code edits:
-
-1. Run \`coder-studio identify --json\`.
-2. Use capabilities to find Git and terminal commands.
-3. Inspect Git status and diff.
-4. Run relevant tests when practical.
-5. Report files changed, verification run, and any remaining risk.
-`;
-
-export const BUILTIN_SKILLS: BuiltinSkillDefinition[] = [
- {
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Teach agents to identify Coder Studio context and discover automation commands.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: true,
- content: AUTOMATION_SKILL,
- },
- {
- slug: "coder-studio-browser-verification",
- displayName: "Coder Studio Browser Verification",
- description: "Teach agents to verify browser-visible changes through Coder Studio automation.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: false,
- content: BROWSER_VERIFICATION_SKILL,
- },
- {
- slug: "coder-studio-review",
- displayName: "Coder Studio Review",
- description: "Teach agents to review Git changes, tests, and residual risk before finishing.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: true,
- content: REVIEW_SKILL,
- },
-];
-```
-
-- [ ] **Step 4: Implement `materialize.ts`**
-
-Create `packages/server/src/skills/builtin/materialize.ts`:
-
-```ts
-import { mkdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
-import type { SkillLibraryEntry } from "@coder-studio/core";
-import { BUILTIN_SKILLS } from "./registry.js";
-
-export interface MaterializeBuiltinSkillsInput {
- builtinRoot: string;
- now?: () => number;
-}
-
-export async function materializeBuiltinSkills(
- input: MaterializeBuiltinSkillsInput
-): Promise {
- const now = input.now?.() ?? Date.now();
- const entries: SkillLibraryEntry[] = [];
-
- for (const skill of BUILTIN_SKILLS) {
- const libraryPath = join(input.builtinRoot, skill.slug);
- await mkdir(libraryPath, { recursive: true });
- await writeFile(join(libraryPath, "SKILL.md"), skill.content.trimEnd() + "\n", "utf8");
- entries.push({
- slug: skill.slug,
- displayName: skill.displayName,
- description: skill.description,
- version: skill.version,
- source: "builtin",
- libraryPath,
- installState: "installed",
- installedAt: now,
- updatedAt: now,
- builtin: {
- defaultEnabled: skill.defaultEnabled,
- autoMount: skill.autoMountInMvp,
- },
- });
- }
-
- return entries;
-}
-```
-
-- [ ] **Step 5: Run the test and verify it passes**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-registry.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add packages/server/src/skills/builtin packages/server/src/__tests__/skills/builtin-registry.test.ts
-git commit -m "feat: add built-in skill registry"
-```
-
----
-
-### Task 3: Sync Built-ins into Library and Auto-mount Defaults
-
-**Files:**
-- Create: `packages/server/src/skills/builtin/sync-manager.ts`
-- Modify: `packages/server/src/ws/dispatch.ts`
-- Modify: `packages/server/src/server.ts`
-- Test: `packages/server/src/__tests__/skills/builtin-sync-manager.test.ts`
-
-- [ ] **Step 1: Write the failing sync-manager tests**
-
-Create `packages/server/src/__tests__/skills/builtin-sync-manager.test.ts`:
-
-```ts
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import type { ProviderDefinition } from "@coder-studio/core";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { SettingsRepo } from "../../storage/repositories/settings-repo.js";
-import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
-import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
-import { BuiltinSkillSyncManager } from "../../skills/builtin/sync-manager.js";
-
-function provider(id: string, skillDir?: string): ProviderDefinition {
- return {
- id,
- displayName: id,
- badge: id,
- kind: "built_in",
- capability: "full",
- capabilities: [],
- install: {
- prerequisites: [],
- manualGuideKeys: [],
- docUrls: { provider: "", prerequisites: {} },
- strategies: {},
- },
- buildCommand: () => ({ argv: [id], env: {}, cwd: "/tmp" }),
- configSchema: { parse: (value: unknown) => value } as never,
- defaultConfig: {},
- requiredCommands: [id],
- skillMountDirectories: skillDir ? [skillDir] : undefined,
- };
-}
-
-describe("BuiltinSkillSyncManager", () => {
- let tempDir: string | undefined;
-
- afterEach(async () => {
- if (tempDir) {
- await rm(tempDir, { recursive: true, force: true });
- }
- });
-
- it("syncs built-ins into the library and mounts MVP defaults", async () => {
- tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-sync-"));
- const skillDir = join(tempDir, "codex-skills");
- const libraryRepo = new SkillLibraryRepo({
- filePath: join(tempDir, "library-index.json"),
- });
- const mountRepo = new SkillMountRepo({
- filePath: join(tempDir, "mounts.json"),
- });
- const settingsRepo = new SettingsRepo({
- filePath: join(tempDir, "settings.json"),
- });
-
- const manager = new BuiltinSkillSyncManager({
- builtinRoot: join(tempDir, "builtin"),
- getProviderRegistry: () => [provider("codex", skillDir)],
- skillLibraryRepo: libraryRepo,
- skillMountRepo: mountRepo,
- settingsRepo,
- now: () => 1000,
- });
-
- const result = await manager.sync();
-
- expect(result.libraryEntries.map((entry) => entry.slug)).toEqual([
- "coder-studio-automation",
- "coder-studio-browser-verification",
- "coder-studio-review",
- ]);
- expect(mountRepo.get("codex", "coder-studio-automation")).toMatchObject({
- enabled: true,
- status: "mounted",
- });
- expect(mountRepo.get("codex", "coder-studio-review")).toMatchObject({
- enabled: true,
- status: "mounted",
- });
- expect(mountRepo.get("codex", "coder-studio-browser-verification")).toBeUndefined();
- });
-
- it("does not re-mount a user-disabled built-in skill", async () => {
- tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-disabled-"));
- const skillDir = join(tempDir, "codex-skills");
- const libraryRepo = new SkillLibraryRepo({
- filePath: join(tempDir, "library-index.json"),
- });
- const mountRepo = new SkillMountRepo({
- filePath: join(tempDir, "mounts.json"),
- });
- const settingsRepo = new SettingsRepo({
- filePath: join(tempDir, "settings.json"),
- });
- settingsRepo.set("skills.builtin.disabledMounts", {
- "codex:coder-studio-review": true,
- });
-
- const manager = new BuiltinSkillSyncManager({
- builtinRoot: join(tempDir, "builtin"),
- getProviderRegistry: () => [provider("codex", skillDir)],
- skillLibraryRepo: libraryRepo,
- skillMountRepo: mountRepo,
- settingsRepo,
- now: () => 1000,
- });
-
- await manager.sync();
-
- expect(mountRepo.get("codex", "coder-studio-automation")).toBeDefined();
- expect(mountRepo.get("codex", "coder-studio-review")).toBeUndefined();
- });
-});
-```
-
-- [ ] **Step 2: Run the test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-sync-manager.test.ts
-```
-
-Expected: FAIL because `BuiltinSkillSyncManager` does not exist.
-
-- [ ] **Step 3: Implement `BuiltinSkillSyncManager`**
-
-Create `packages/server/src/skills/builtin/sync-manager.ts`:
-
-```ts
-import { copyFile, mkdir, readdir, readlink, rm, symlink, unlink } from "node:fs/promises";
-import { dirname, join } from "node:path";
-import type { ProviderDefinition, SkillLibraryEntry, SkillMountRelation } from "@coder-studio/core";
-import type { SettingsRepo } from "../../storage/repositories/settings-repo.js";
-import type { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
-import type { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
-import { materializeBuiltinSkills } from "./materialize.js";
-
-const DISABLED_MOUNTS_SETTING_KEY = "skills.builtin.disabledMounts";
-
-export interface BuiltinSkillSyncManagerDeps {
- builtinRoot: string;
- getProviderRegistry: () => ProviderDefinition[];
- skillLibraryRepo: SkillLibraryRepo;
- skillMountRepo: SkillMountRepo;
- settingsRepo: SettingsRepo;
- now?: () => number;
-}
-
-export interface BuiltinSkillSyncResult {
- libraryEntries: SkillLibraryEntry[];
- mounted: SkillMountRelation[];
- skipped: Array<{ providerId: string; skillSlug: string; reason: string }>;
-}
-
-export class BuiltinSkillSyncManager {
- constructor(private readonly deps: BuiltinSkillSyncManagerDeps) {}
-
- async sync(): Promise {
- const entries = await materializeBuiltinSkills({
- builtinRoot: this.deps.builtinRoot,
- now: this.deps.now,
- });
- for (const entry of entries) {
- this.deps.skillLibraryRepo.set(entry);
- }
-
- const disabled = this.readDisabledMounts();
- const mounted: SkillMountRelation[] = [];
- const skipped: BuiltinSkillSyncResult["skipped"] = [];
-
- for (const provider of this.deps.getProviderRegistry()) {
- const skillDir = provider.skillMountDirectories?.[0];
- if (!skillDir) {
- continue;
- }
-
- for (const entry of entries) {
- if (!entry.builtin?.autoMount) {
- skipped.push({ providerId: provider.id, skillSlug: entry.slug, reason: "not_mvp_auto" });
- continue;
- }
-
- if (disabled[disabledKey(provider.id, entry.slug)]) {
- skipped.push({ providerId: provider.id, skillSlug: entry.slug, reason: "disabled" });
- continue;
- }
-
- const relation = await this.mountBuiltin(provider.id, skillDir, entry);
- this.deps.skillMountRepo.upsert(relation);
- mounted.push(relation);
- }
- }
-
- return { libraryEntries: entries, mounted, skipped };
- }
-
- setMountEnabled(providerId: string, skillSlug: string, enabled: boolean): void {
- const disabled = this.readDisabledMounts();
- const key = disabledKey(providerId, skillSlug);
- if (enabled) {
- delete disabled[key];
- } else {
- disabled[key] = true;
- }
- this.deps.settingsRepo.set(DISABLED_MOUNTS_SETTING_KEY, disabled);
- }
-
- isMountDisabled(providerId: string, skillSlug: string): boolean {
- return Boolean(this.readDisabledMounts()[disabledKey(providerId, skillSlug)]);
- }
-
- private readDisabledMounts(): Record {
- const raw = this.deps.settingsRepo.get>(DISABLED_MOUNTS_SETTING_KEY);
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
- return {};
- }
- return Object.fromEntries(
- Object.entries(raw).filter(([, value]) => value === true)
- ) as Record;
- }
-
- private async mountBuiltin(
- providerId: string,
- skillDir: string,
- entry: SkillLibraryEntry
- ): Promise {
- const targetPath = join(skillDir, entry.slug);
- await mkdir(dirname(targetPath), { recursive: true });
- await rm(targetPath, { recursive: true, force: true });
-
- let mountModeResolved: SkillMountRelation["mountModeResolved"] = "symlink";
- try {
- await symlink(entry.libraryPath, targetPath);
- } catch {
- mountModeResolved = "copy";
- await copyRecursively(entry.libraryPath, targetPath);
- }
-
- return {
- providerId,
- skillSlug: entry.slug,
- enabled: true,
- sourcePath: entry.libraryPath,
- targetPath,
- mountModeResolved,
- status: "mounted",
- lastSyncedAt: this.deps.now?.() ?? Date.now(),
- };
- }
-}
-
-function disabledKey(providerId: string, skillSlug: string): string {
- return `${providerId}:${skillSlug}`;
-}
-
-async function copyRecursively(source: string, target: string): Promise {
- await mkdir(target, { recursive: true });
- const entries = await readdir(source, { withFileTypes: true });
- for (const entry of entries) {
- const from = join(source, entry.name);
- const to = join(target, entry.name);
- if (entry.isDirectory()) {
- await copyRecursively(from, to);
- } else if (entry.isSymbolicLink()) {
- const linkTarget = await readlink(from);
- await symlink(linkTarget, to);
- } else {
- await copyFile(from, to);
- }
- }
-}
-```
-
-Remove unused imports after implementation; if `unlink` is unused, do not keep it.
-
-- [ ] **Step 4: Run the test and verify it passes**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-sync-manager.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Wire manager into server context**
-
-Modify `packages/server/src/ws/dispatch.ts`:
-
-```ts
-import type { BuiltinSkillSyncManager } from "../skills/builtin/sync-manager.js";
-```
-
-Add to `CommandContext`:
-
-```ts
-builtinSkillSyncMgr?: BuiltinSkillSyncManager;
-stateRoot?: string;
-```
-
-Modify `packages/server/src/server.ts` imports:
-
-```ts
-import { BuiltinSkillSyncManager } from "./skills/builtin/sync-manager.js";
-```
-
-After `skillHealthMgr` is created, instantiate:
-
-```ts
-const builtinSkillSyncMgr = new BuiltinSkillSyncManager({
- builtinRoot: join(stateRoot, "state", "skills", "builtin"),
- getProviderRegistry: () => activeProviderRegistry,
- skillLibraryRepo,
- skillMountRepo,
- settingsRepo,
-});
-await builtinSkillSyncMgr.sync();
-```
-
-Add `builtinSkillSyncMgr` and `stateRoot` to `commandContext`.
-
-- [ ] **Step 6: Run targeted server tests**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-sync-manager.test.ts src/__tests__/skills/commands.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add packages/server/src/skills/builtin/sync-manager.ts packages/server/src/__tests__/skills/builtin-sync-manager.test.ts packages/server/src/ws/dispatch.ts packages/server/src/server.ts
-git commit -m "feat: sync built-in skills"
-```
-
----
-
-### Task 4: Expose Built-in Skill Commands and Library Metadata
-
-**Files:**
-- Modify: `packages/server/src/commands/skills.ts`
-- Test: `packages/server/src/__tests__/skills/commands.test.ts`
-
-- [ ] **Step 1: Write failing command tests**
-
-Add tests to `packages/server/src/__tests__/skills/commands.test.ts`:
-
-```ts
-it("returns builtin library metadata", async () => {
- const ctx = createBaseContext({
- skillLibraryRepo: {
- list: vi.fn(() => [
- {
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Teach agents",
- version: "1.0.0",
- source: "builtin",
- libraryPath: "/skills/builtin/coder-studio-automation",
- installState: "installed",
- installedAt: 1,
- updatedAt: 2,
- builtin: { defaultEnabled: true, autoMount: true },
- },
- ]),
- } as never,
- skillMountRepo: {
- listBySkillSlug: vi.fn(() => []),
- } as never,
- skillsHubClient: {} as never,
- });
-
- const result = await dispatch(
- {
- kind: "command",
- id: "skills-library-builtin-1",
- op: "skills.library.list",
- args: {},
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(result.data).toEqual([
- expect.objectContaining({
- slug: "coder-studio-automation",
- source: "builtin",
- builtin: { defaultEnabled: true, autoMount: true },
- }),
- ]);
-});
-
-it("syncs builtin skills through command dispatch", async () => {
- const sync = vi.fn(async () => ({
- libraryEntries: [],
- mounted: [],
- skipped: [],
- }));
- const ctx = createBaseContext({
- builtinSkillSyncMgr: { sync } as never,
- });
-
- const result = await dispatch(
- {
- kind: "command",
- id: "skills-builtin-sync-1",
- op: "skills.builtin.sync",
- args: {},
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(sync).toHaveBeenCalledTimes(1);
-});
-
-it("persists builtin mount disablement through command dispatch", async () => {
- const setMountEnabled = vi.fn();
- const sync = vi.fn(async () => ({
- libraryEntries: [],
- mounted: [],
- skipped: [],
- }));
- const ctx = createBaseContext({
- builtinSkillSyncMgr: { setMountEnabled, sync } as never,
- });
-
- const result = await dispatch(
- {
- kind: "command",
- id: "skills-builtin-set-enabled-1",
- op: "skills.builtin.setMountEnabled",
- args: {
- providerId: "codex",
- skillSlug: "coder-studio-review",
- enabled: false,
- },
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(setMountEnabled).toHaveBeenCalledWith("codex", "coder-studio-review", false);
- expect(sync).toHaveBeenCalledTimes(1);
-});
-```
-
-- [ ] **Step 2: Run the tests and verify they fail**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/commands.test.ts
-```
-
-Expected: FAIL for missing `skills.builtin.sync` and `skills.builtin.setMountEnabled` command handlers.
-
-- [ ] **Step 3: Add command handlers**
-
-Modify `packages/server/src/commands/skills.ts`:
-
-```ts
-function requireBuiltinSkillSyncSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- builtinSkillSyncMgr: NonNullable;
-} {
- if (!ctx.builtinSkillSyncMgr) {
- throw {
- code: "builtin_skills_unavailable",
- message: "Built-in skill sync is not configured",
- };
- }
-}
-```
-
-Add handlers:
-
-```ts
-registerCommand("skills.builtin.sync", z.object({}), async (_args, ctx) => {
- requireBuiltinSkillSyncSupport(ctx);
- return ctx.builtinSkillSyncMgr.sync();
-});
-
-registerCommand(
- "skills.builtin.setMountEnabled",
- z.object({
- providerId: z.string().trim().min(1),
- skillSlug: z.string().trim().min(1),
- enabled: z.boolean(),
- }),
- async (args, ctx) => {
- requireBuiltinSkillSyncSupport(ctx);
- ctx.builtinSkillSyncMgr.setMountEnabled(args.providerId, args.skillSlug, args.enabled);
- return ctx.builtinSkillSyncMgr.sync();
- }
-);
-```
-
-No extra implementation is needed for library metadata if `skills.library.list` returns `...entry`.
-
-- [ ] **Step 4: Run tests and verify they pass**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/commands.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add packages/server/src/commands/skills.ts packages/server/src/__tests__/skills/commands.test.ts
-git commit -m "feat: add built-in skill commands"
-```
-
----
-
-### Task 5: Inject Agent Runtime Context into Session Terminals
-
-**Files:**
-- Modify: `packages/server/src/session/manager.ts`
-- Modify: `packages/server/src/session/types.ts` if needed
-- Test: `packages/server/src/__tests__/session-commands.test.ts`
-
-- [ ] **Step 1: Write the failing session env test**
-
-Add a test in `packages/server/src/__tests__/session-commands.test.ts` near the existing session create tests:
-
-```ts
-it("injects Coder Studio runtime context into agent terminal env", async () => {
- const testDir = join(tmpdir(), `coder-studio-session-env-${Date.now()}`);
- mkdirSync(join(testDir, ".git"), { recursive: true });
- writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n");
-
- const createdSpecs: Array<{ env?: Record }> = [];
- terminalMgrStub = {
- create: (spec: { env?: Record }) => {
- createdSpecs.push(spec);
- return { id: "terminal-env-1" };
- },
- kill: async () => {},
- close: async () => {},
- } as unknown as TerminalManager;
- sessionMgr = new SessionManager({
- terminalMgr: terminalMgrStub,
- eventBus,
- db: sessionDbStub,
- broadcaster,
- providerRegistry: [],
- providerConfigRepo: createProviderConfigRepo(join(stateDir, "provider-configs-env.json")),
- runtimeContext: {
- apiUrl: "http://127.0.0.1:4173",
- },
- });
- ctx.sessionMgr = sessionMgr;
- ctx.providerRegistry = providerRegistry as ProviderDefinition[];
- ctx.providerRuntimeDeps = {
- commandExists: async (command: string) => command === "claude",
- };
-
- try {
- const openResult = await dispatch(
- {
- kind: "command",
- id: "workspace-env",
- op: "workspace.open",
- args: { path: testDir },
- },
- ctx
- );
-
- expect(openResult.ok).toBe(true);
-
- const result = await dispatch(
- {
- kind: "command",
- id: "session-env",
- op: "session.create",
- args: {
- workspaceId: openResult.data!.id,
- providerId: "claude",
- },
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(createdSpecs[0]?.env).toMatchObject({
- CODER_STUDIO: "1",
- CODER_STUDIO_WORKSPACE_ID: openResult.data!.id,
- CODER_STUDIO_SESSION_ID: expect.stringMatching(/^sess_/),
- CODER_STUDIO_PROVIDER_ID: "claude",
- CODER_STUDIO_API_URL: "http://127.0.0.1:4173",
- });
- } finally {
- rmSync(testDir, { recursive: true, force: true });
- }
-});
-```
-
-- [ ] **Step 2: Run the test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/session-commands.test.ts
-```
-
-Expected: FAIL because `runtimeContext` is not accepted by `SessionManagerDeps` or env values are missing.
-
-- [ ] **Step 3: Extend SessionManager deps and env injection**
-
-Modify `packages/server/src/session/manager.ts`:
-
-```ts
-export interface SessionRuntimeContext {
- apiUrl?: string;
-}
-
-export interface SessionManagerDeps {
- terminalMgr: TerminalManager;
- eventBus: EventBus;
- db: SessionDatabase;
- broadcaster: Broadcaster;
- providerRegistry: ProviderDefinition[];
- providerConfigRepo: ProviderConfigRepo;
- runtimeContext?: SessionRuntimeContext;
- logger?: SessionLogger;
-}
-```
-
-In `create`, update terminal env:
-
-```ts
-env: {
- ...cmd.env,
- CODER_STUDIO: "1",
- CODER_STUDIO_WORKSPACE_ID: req.workspaceId,
- CODER_STUDIO_SESSION_ID: sessionId,
- CODER_STUDIO_PROVIDER_ID: req.providerId,
- ...(this.deps.runtimeContext?.apiUrl
- ? { CODER_STUDIO_API_URL: this.deps.runtimeContext.apiUrl }
- : {}),
-},
-```
-
-Do not inject `CODER_STUDIO_TERMINAL_ID` here because the terminal id is only known after `terminalMgr.create()`. Keep the PRD's terminal id variable for a later implementation that can safely update env through PTY launch changes.
-
-- [ ] **Step 4: Wire runtimeContext in server**
-
-Modify `packages/server/src/server.ts` where `SessionManager` is created:
-
-```ts
-runtimeContext: {
- apiUrl: `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`,
-},
-```
-
-If there is an existing URL helper, prefer it; otherwise use the explicit expression above.
-
-- [ ] **Step 5: Run the test and verify it passes**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/session-commands.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add packages/server/src/session/manager.ts packages/server/src/server.ts packages/server/src/__tests__/session-commands.test.ts
-git commit -m "feat: inject coder studio agent context"
-```
-
----
-
-### Task 6: Add Automation Identify and Capabilities Metadata
-
-**Files:**
-- Create: `packages/server/src/automation/identify.ts`
-- Create: `packages/server/src/automation/capabilities.ts`
-- Create: `packages/server/src/commands/automation.ts`
-- Modify: `packages/server/src/commands/index.ts`
-- Test: `packages/server/src/__tests__/automation/identify.test.ts`
-- Test: `packages/server/src/__tests__/automation/capabilities.test.ts`
-
-- [ ] **Step 1: Write failing automation tests**
-
-Create `packages/server/src/__tests__/automation/identify.test.ts`:
-
-```ts
-import { describe, expect, it } from "vitest";
-import { buildIdentifyResult } from "../../automation/identify.js";
-
-describe("automation identify", () => {
- it("returns outside-Coder-Studio when env marker is absent", () => {
- expect(buildIdentifyResult({ env: {} })).toEqual({ insideCoderStudio: false });
- });
-
- it("returns runtime context from env", () => {
- expect(
- buildIdentifyResult({
- env: {
- CODER_STUDIO: "1",
- CODER_STUDIO_WORKSPACE_ID: "ws-1",
- CODER_STUDIO_SESSION_ID: "sess-1",
- CODER_STUDIO_PROVIDER_ID: "codex",
- CODER_STUDIO_API_URL: "http://127.0.0.1:4173",
- },
- cwd: "/repo",
- })
- ).toEqual({
- insideCoderStudio: true,
- workspaceId: "ws-1",
- sessionId: "sess-1",
- providerId: "codex",
- cwd: "/repo",
- apiUrl: "http://127.0.0.1:4173",
- permissions: ["workspace:read", "session:read", "terminal:read", "git:read"],
- });
- });
-});
-```
-
-Create `packages/server/src/__tests__/automation/capabilities.test.ts`:
-
-```ts
-import { describe, expect, it } from "vitest";
-import { listAutomationCapabilities } from "../../automation/capabilities.js";
-
-describe("automation capabilities", () => {
- it("lists MVP read capabilities with command examples", () => {
- const capabilities = listAutomationCapabilities({
- permissions: ["workspace:read", "session:read", "terminal:read", "git:read"],
- });
-
- expect(capabilities.map((capability) => capability.name)).toContain("git.status");
- expect(capabilities.find((capability) => capability.name === "git.status")).toMatchObject({
- cli: "coder-studio git status",
- riskLevel: "read",
- permissions: ["git:read"],
- available: true,
- });
- });
-
- it("filters capabilities by permissions", () => {
- const capabilities = listAutomationCapabilities({
- permissions: ["workspace:read"],
- });
-
- expect(capabilities.map((capability) => capability.name)).toContain("workspace.list");
- expect(capabilities.map((capability) => capability.name)).not.toContain("git.status");
- });
-});
-```
-
-- [ ] **Step 2: Run tests and verify they fail**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/automation/identify.test.ts src/__tests__/automation/capabilities.test.ts
-```
-
-Expected: FAIL because automation modules do not exist.
-
-- [ ] **Step 3: Implement identify**
-
-Create `packages/server/src/automation/identify.ts`:
-
-```ts
-export interface IdentifyInput {
- env?: NodeJS.ProcessEnv | Record;
- cwd?: string;
-}
-
-export interface IdentifyResult {
- insideCoderStudio: boolean;
- workspaceId?: string;
- sessionId?: string;
- terminalId?: string;
- providerId?: string;
- cwd?: string;
- apiUrl?: string;
- permissions?: string[];
-}
-
-const DEFAULT_AGENT_PERMISSIONS = ["workspace:read", "session:read", "terminal:read", "git:read"];
-
-export function buildIdentifyResult(input: IdentifyInput = {}): IdentifyResult {
- const env = input.env ?? process.env;
- if (env.CODER_STUDIO !== "1") {
- return { insideCoderStudio: false };
- }
-
- return {
- insideCoderStudio: true,
- workspaceId: env.CODER_STUDIO_WORKSPACE_ID,
- sessionId: env.CODER_STUDIO_SESSION_ID,
- terminalId: env.CODER_STUDIO_TERMINAL_ID,
- providerId: env.CODER_STUDIO_PROVIDER_ID,
- cwd: input.cwd ?? process.cwd(),
- apiUrl: env.CODER_STUDIO_API_URL,
- permissions: DEFAULT_AGENT_PERMISSIONS,
- };
-}
-```
-
-- [ ] **Step 4: Implement capabilities**
-
-Create `packages/server/src/automation/capabilities.ts`:
-
-```ts
-export type AutomationRiskLevel = "read" | "write" | "dangerous";
-
-export interface AutomationCapability {
- name: string;
- cli: string;
- description: string;
- inputSchema: Record;
- output: string;
- permissions: string[];
- riskLevel: AutomationRiskLevel;
- examples: string[];
- available: boolean;
-}
-
-const MVP_CAPABILITIES: AutomationCapability[] = [
- {
- name: "workspace.list",
- cli: "coder-studio workspace list",
- description: "List known workspaces.",
- inputSchema: {},
- output: "Workspace summaries as JSON.",
- permissions: ["workspace:read"],
- riskLevel: "read",
- examples: ["coder-studio workspace list --json"],
- available: true,
- },
- {
- name: "session.list",
- cli: "coder-studio session list",
- description: "List sessions visible to the current caller.",
- inputSchema: { workspaceId: "string optional" },
- output: "Session summaries as JSON.",
- permissions: ["session:read"],
- riskLevel: "read",
- examples: ["coder-studio session list --workspace ws_123 --json"],
- available: true,
- },
- {
- name: "terminal.read",
- cli: "coder-studio terminal read",
- description: "Read terminal output tail.",
- inputSchema: { terminalId: "string", bytes: "number optional" },
- output: "Terminal text tail.",
- permissions: ["terminal:read"],
- riskLevel: "read",
- examples: ["coder-studio terminal read --terminal term_123 --json"],
- available: true,
- },
- {
- name: "git.status",
- cli: "coder-studio git status",
- description: "Read Git status for a workspace.",
- inputSchema: { workspaceId: "string" },
- output: "Git status summary as JSON.",
- permissions: ["git:read"],
- riskLevel: "read",
- examples: ["coder-studio git status --workspace ws_123 --json"],
- available: true,
- },
- {
- name: "git.diff",
- cli: "coder-studio git diff",
- description: "Read Git diff for a workspace.",
- inputSchema: { workspaceId: "string", path: "string optional" },
- output: "Git diff text or structured diff data.",
- permissions: ["git:read"],
- riskLevel: "read",
- examples: ["coder-studio git diff --workspace ws_123 --json"],
- available: true,
- },
-];
-
-export function listAutomationCapabilities(input: { permissions: string[] }): AutomationCapability[] {
- const allowed = new Set(input.permissions);
- return MVP_CAPABILITIES.filter((capability) =>
- capability.permissions.every((permission) => allowed.has(permission))
- );
-}
-```
-
-- [ ] **Step 5: Add WebSocket commands**
-
-Create `packages/server/src/commands/automation.ts`:
-
-```ts
-import { z } from "zod";
-import { listAutomationCapabilities } from "../automation/capabilities.js";
-import { buildIdentifyResult } from "../automation/identify.js";
-import { registerCommand } from "../ws/dispatch.js";
-
-registerCommand("automation.identify", z.object({}), async () => {
- return buildIdentifyResult();
-});
-
-registerCommand(
- "automation.capabilities",
- z.object({
- permissions: z.array(z.string()).optional(),
- }),
- async (args) => {
- return {
- version: 1,
- commands: listAutomationCapabilities({
- permissions: args.permissions ?? ["workspace:read", "session:read", "terminal:read", "git:read"],
- }),
- };
- }
-);
-```
-
-Modify `packages/server/src/commands/index.ts`:
-
-```ts
-import "./automation.js";
-```
-
-- [ ] **Step 6: Run tests and verify they pass**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/automation/identify.test.ts src/__tests__/automation/capabilities.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add packages/server/src/automation packages/server/src/commands/automation.ts packages/server/src/commands/index.ts packages/server/src/__tests__/automation
-git commit -m "feat: add automation discovery metadata"
-```
-
----
-
-### Task 7: Add CLI `identify` and `capabilities`
-
-**Files:**
-- Create: `packages/cli/src/automation-client.ts`
-- Modify: `packages/cli/src/parse-args.ts`
-- Modify: `packages/cli/src/cli.ts`
-- Test: `packages/cli/src/bin.test.ts`
-
-- [ ] **Step 1: Write failing CLI tests**
-
-In `packages/cli/src/bin.test.ts`, add imports/mocks for automation helpers:
-
-```ts
-const {
- printCapabilities,
- printIdentify,
- // existing hoisted mocks...
-} = vi.hoisted(() => ({
- printCapabilities: vi.fn(),
- printIdentify: vi.fn(),
- // existing mocks...
-}));
-
-vi.mock("./automation-client.js", () => ({
- printCapabilities,
- printIdentify,
-}));
-```
-
-Add tests:
-
-```ts
-it("parses identify command with --json", () => {
- expect(parseArgs(["identify", "--json"])).toEqual({
- command: "identify",
- json: true,
- });
-});
-
-it("prints identify output", async () => {
- await main(["identify", "--json"]);
- expect(printIdentify).toHaveBeenCalledWith({ json: true });
-});
-
-it("prints capabilities output", async () => {
- await main(["capabilities", "--json"]);
- expect(printCapabilities).toHaveBeenCalledWith({ json: true });
-});
-```
-
-Adjust the existing hoisted mock block carefully rather than creating a second conflicting `vi.hoisted`.
-
-- [ ] **Step 2: Run CLI tests and verify they fail**
-
-Run:
-
-```bash
-pnpm --filter @spencer-kit/coder-studio test -- src/bin.test.ts
-```
-
-Expected: FAIL because parser and CLI do not know `identify` or `capabilities`.
-
-- [ ] **Step 3: Implement `automation-client.ts`**
-
-Create `packages/cli/src/automation-client.ts`:
-
-```ts
-interface PrintOptions {
- json?: boolean;
-}
-
-function defaultPermissions(): string[] {
- return ["workspace:read", "session:read", "terminal:read", "git:read"];
-}
-
-export function printIdentify(options: PrintOptions = {}): void {
- const insideCoderStudio = process.env.CODER_STUDIO === "1";
- const result = insideCoderStudio
- ? {
- insideCoderStudio: true,
- workspaceId: process.env.CODER_STUDIO_WORKSPACE_ID,
- sessionId: process.env.CODER_STUDIO_SESSION_ID,
- terminalId: process.env.CODER_STUDIO_TERMINAL_ID,
- providerId: process.env.CODER_STUDIO_PROVIDER_ID,
- cwd: process.cwd(),
- apiUrl: process.env.CODER_STUDIO_API_URL,
- permissions: defaultPermissions(),
- }
- : { insideCoderStudio: false };
-
- if (options.json) {
- console.log(JSON.stringify(result, null, 2));
- return;
- }
-
- console.log(result.insideCoderStudio ? "Inside Coder Studio" : "Not running inside Coder Studio");
-}
-
-export function printCapabilities(options: PrintOptions = {}): void {
- const result = {
- version: 1,
- commands: [
- {
- name: "workspace.list",
- cli: "coder-studio workspace list",
- description: "List known workspaces.",
- inputSchema: {},
- output: "Workspace summaries as JSON.",
- permissions: ["workspace:read"],
- riskLevel: "read",
- examples: ["coder-studio workspace list --json"],
- available: true,
- },
- {
- name: "git.status",
- cli: "coder-studio git status",
- description: "Read Git status for a workspace.",
- inputSchema: { workspaceId: "string" },
- output: "Git status summary as JSON.",
- permissions: ["git:read"],
- riskLevel: "read",
- examples: ["coder-studio git status --workspace ws_123 --json"],
- available: true,
- },
- ],
- };
-
- if (options.json) {
- console.log(JSON.stringify(result, null, 2));
- return;
- }
-
- console.log(result.commands.map((command) => `${command.name}: ${command.cli}`).join("\n"));
-}
-```
-
-This duplicates a minimal MVP capabilities subset in the CLI package because the published CLI bundle cannot import server source directly without affecting bundling. A later refactor can move shared metadata into `@coder-studio/core`.
-
-- [ ] **Step 4: Extend parser**
-
-Modify `packages/cli/src/parse-args.ts`:
-
-```ts
-type CliCommand =
- | "serve"
- | "open"
- | "config"
- | "stop"
- | "status"
- | "logs"
- | "help"
- | "version"
- | "auth"
- | "identify"
- | "capabilities";
-```
-
-Add to `CliArgs`:
-
-```ts
-json?: boolean;
-```
-
-Add cases:
-
-```ts
-case "identify":
-case "capabilities":
- setCommand(args, arg);
- break;
-
-case "--json":
- if (getActiveCommand(args) !== "identify" && getActiveCommand(args) !== "capabilities") {
- throwUnknownOption(arg);
- }
- args.json = true;
- break;
-```
-
-When `setCommand` switches away from `identify` or `capabilities`, it can leave `json` harmlessly or delete it for stricter parsing. Prefer deleting it unless the active command supports JSON.
-
-- [ ] **Step 5: Extend CLI main**
-
-Modify `packages/cli/src/cli.ts` imports:
-
-```ts
-import { printCapabilities, printIdentify } from "./automation-client.js";
-```
-
-In `showHelp`, add commands:
-
-```text
- identify Print Coder Studio agent runtime context
- capabilities Print agent-facing automation capabilities
-```
-
-In `main` after version/help handling:
-
-```ts
-if (args.command === "identify") {
- printIdentify({ json: args.json });
- return;
-}
-
-if (args.command === "capabilities") {
- printCapabilities({ json: args.json });
- return;
-}
-```
-
-- [ ] **Step 6: Run CLI tests**
-
-Run:
-
-```bash
-pnpm --filter @spencer-kit/coder-studio test -- src/bin.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add packages/cli/src/automation-client.ts packages/cli/src/parse-args.ts packages/cli/src/cli.ts packages/cli/src/bin.test.ts
-git commit -m "feat: add automation discovery cli commands"
-```
-
----
-
-### Task 8: Add Minimal Automation Audit Log
-
-**Files:**
-- Create: `packages/server/src/automation/audit-log.ts`
-- Modify: `packages/server/src/ws/dispatch.ts`
-- Modify: `packages/server/src/server.ts`
-- Test: `packages/server/src/__tests__/automation/audit-log.test.ts`
-
-- [ ] **Step 1: Write failing audit log tests**
-
-Create `packages/server/src/__tests__/automation/audit-log.test.ts`:
-
-```ts
-import { mkdtemp, readFile, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { AutomationAuditLog } from "../../automation/audit-log.js";
-
-describe("AutomationAuditLog", () => {
- let tempDir: string | undefined;
-
- afterEach(async () => {
- if (tempDir) {
- await rm(tempDir, { recursive: true, force: true });
- }
- });
-
- it("writes sanitized JSONL audit records", async () => {
- tempDir = await mkdtemp(join(tmpdir(), "coder-studio-audit-"));
- const log = new AutomationAuditLog({
- filePath: join(tempDir, "automation.jsonl"),
- now: () => 1234,
- });
-
- await log.append({
- workspaceId: "ws-1",
- sessionId: "sess-1",
- providerId: "codex",
- commandName: "terminal.send",
- riskLevel: "write",
- decision: "allowed",
- success: true,
- args: {
- text: "secret-token",
- token: "abc",
- },
- });
-
- const lines = (await readFile(join(tempDir, "automation.jsonl"), "utf8")).trim().split("\n");
- expect(lines).toHaveLength(1);
- expect(JSON.parse(lines[0]!)).toMatchObject({
- timestamp: 1234,
- workspaceId: "ws-1",
- commandName: "terminal.send",
- args: {
- text: "secret-token",
- token: "[redacted]",
- },
- });
- });
-});
-```
-
-- [ ] **Step 2: Run test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/automation/audit-log.test.ts
-```
-
-Expected: FAIL because `audit-log.ts` does not exist.
-
-- [ ] **Step 3: Implement audit log**
-
-Create `packages/server/src/automation/audit-log.ts`:
-
-```ts
-import { appendFile, mkdir } from "node:fs/promises";
-import { dirname } from "node:path";
-
-export interface AutomationAuditRecordInput {
- workspaceId?: string;
- sessionId?: string;
- providerId?: string;
- commandName: string;
- riskLevel: "read" | "write" | "dangerous";
- decision: "allowed" | "denied" | "approval_required";
- success: boolean;
- args?: Record;
-}
-
-export interface AutomationAuditLogDeps {
- filePath: string;
- now?: () => number;
-}
-
-export class AutomationAuditLog {
- constructor(private readonly deps: AutomationAuditLogDeps) {}
-
- async append(input: AutomationAuditRecordInput): Promise {
- await mkdir(dirname(this.deps.filePath), { recursive: true });
- const record = {
- timestamp: this.deps.now?.() ?? Date.now(),
- ...input,
- args: sanitizeArgs(input.args ?? {}),
- };
- await appendFile(this.deps.filePath, JSON.stringify(record) + "\n", "utf8");
- }
-}
-
-function sanitizeArgs(args: Record): Record {
- return Object.fromEntries(
- Object.entries(args).map(([key, value]) => [
- key,
- /token|password|secret|apiKey|apikey|authorization/i.test(key) ? "[redacted]" : value,
- ])
- );
-}
-```
-
-- [ ] **Step 4: Wire audit log into context**
-
-Modify `packages/server/src/ws/dispatch.ts`:
-
-```ts
-import type { AutomationAuditLog } from "../automation/audit-log.js";
-```
-
-Add to `CommandContext`:
-
-```ts
-automationAuditLog?: AutomationAuditLog;
-```
-
-Modify `packages/server/src/server.ts`:
-
-```ts
-import { AutomationAuditLog } from "./automation/audit-log.js";
-```
-
-Instantiate:
-
-```ts
-const automationAuditLog = new AutomationAuditLog({
- filePath: join(stateRoot, "state", "automation-audit.jsonl"),
-});
-```
-
-Add to `commandContext`.
-
-MVP does not need to audit every command globally. Later tasks can call `automationAuditLog.append` from agent-triggered command routes once those command routes exist.
-
-- [ ] **Step 5: Run test and verify it passes**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/server test -- src/__tests__/automation/audit-log.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add packages/server/src/automation/audit-log.ts packages/server/src/__tests__/automation/audit-log.test.ts packages/server/src/ws/dispatch.ts packages/server/src/server.ts
-git commit -m "feat: add automation audit log"
-```
-
----
-
-### Task 9: Surface Built-in Skills in Skills Panel
-
-**Files:**
-- Modify: `packages/web/src/features/workspace/actions/use-skills-panel.ts`
-- Modify: `packages/web/src/features/workspace/views/shared/skills-panel.tsx`
-- Modify: `packages/web/src/features/workspace/views/shared/skills-panel.test.tsx`
-
-- [ ] **Step 1: Write failing UI test**
-
-In `packages/web/src/features/workspace/views/shared/skills-panel.test.tsx`, add translation fixture entries:
-
-```ts
-"workspace.skills.source.builtin": "Built-in",
-"skills.builtin_title": "Built-in Skills",
-```
-
-Add test:
-
-```tsx
-it("shows built-in skills with a built-in source label", async () => {
- mockDispatch((op) => {
- if (op === "skills.library.list") {
- return Promise.resolve({
- ok: true,
- data: [
- {
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Teach agents",
- version: "1.0.0",
- source: "builtin",
- libraryPath: "/skills/builtin/coder-studio-automation",
- installState: "installed",
- installedAt: 1,
- updatedAt: 2,
- mountedProviderIds: ["codex"],
- mountStatus: "partially_mounted",
- errorCount: 0,
- builtin: { defaultEnabled: true, autoMount: true },
- },
- ],
- });
- }
- if (op === "skills.health.scan") {
- return Promise.resolve({ ok: true, data: { targets: [], mounts: [] } });
- }
- if (op === "skills.targets.list") {
- return Promise.resolve({ ok: true, data: [] });
- }
- return Promise.resolve({ ok: true, data: [] });
- });
-
- renderPanel();
-
- expect(await screen.findByText("Coder Studio Automation")).toBeInTheDocument();
- expect(screen.getByText("Built-in")).toBeInTheDocument();
-});
-```
-
-Use the file's existing mock helper names. If it uses a different dispatch mocking helper than `mockDispatch`, adapt the test to the local helper.
-
-- [ ] **Step 2: Run UI test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/skills-panel.test.tsx
-```
-
-Expected: FAIL because source `builtin` has no label handling.
-
-- [ ] **Step 3: Extend UI types**
-
-Modify `packages/web/src/features/workspace/actions/use-skills-panel.ts` only if TypeScript complains. Since `SkillLibraryEntry.source` comes from core, the type should update after Task 1.
-
-- [ ] **Step 4: Render built-in source label**
-
-Modify source label mapping in `packages/web/src/features/workspace/views/shared/skills-panel.tsx` wherever existing `skillhub`/`local` labels are rendered:
-
-```tsx
-{item.source === "builtin"
- ? t("workspace.skills.source.builtin")
- : item.source === "local"
- ? t("workspace.skills.source.local")
- : t("workspace.skills.source.skillhub")}
-```
-
-If the component already has a helper function for source labels, update the helper instead of adding inline branching.
-
-- [ ] **Step 5: Add i18n keys**
-
-Find the locale files under `packages/web/src` with existing `workspace.skills.source.local` keys and add:
-
-```json
-"workspace.skills.source.builtin": "Built-in"
-```
-
-For Chinese locale:
-
-```json
-"workspace.skills.source.builtin": "内置"
-```
-
-If translations are in TypeScript records instead of JSON, update those records.
-
-- [ ] **Step 6: Run UI test and verify it passes**
-
-Run:
-
-```bash
-pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/skills-panel.test.tsx
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add packages/web/src/features/workspace/actions/use-skills-panel.ts packages/web/src/features/workspace/views/shared/skills-panel.tsx packages/web/src/features/workspace/views/shared/skills-panel.test.tsx packages/web/src
-git commit -m "feat: show built-in skills in skills panel"
-```
-
----
-
-### Task 10: Document MVP CLI and Skill Behavior
-
-**Files:**
-- Modify: `docs/help/cli.md`
-- Modify: `docs/help/app-overview.md`
-- Modify: `docs/PRD-agent-automation-skills.md` if implementation decisions changed during execution.
-
-- [ ] **Step 1: Update CLI docs**
-
-In `docs/help/cli.md`, add sections:
-
-```md
-### coder-studio identify
-
-Prints machine-readable Coder Studio runtime context for an agent or local script.
-
-```bash
-coder-studio identify --json
-```
-
-When called inside a Coder Studio-managed agent session, the JSON includes workspace, session, provider, API URL, and permission context. Outside Coder Studio it returns `{"insideCoderStudio": false}`.
-
-### coder-studio capabilities
-
-Prints machine-readable automation capabilities available to the caller.
-
-```bash
-coder-studio capabilities --json
-```
-
-Agents should use this command instead of relying on a hardcoded command list.
-```
-```
-
-- [ ] **Step 2: Update app overview**
-
-In `docs/help/app-overview.md`, add a short section after Provider or Settings:
-
-```md
-### Built-in Skills
-
-Coder Studio can distribute first-party skills to supported agent providers. These skills teach agents how to discover Coder Studio runtime context and automation capabilities through `coder-studio identify --json` and `coder-studio capabilities --json`.
-```
-
-- [ ] **Step 3: Review PRD alignment**
-
-Open `docs/PRD-agent-automation-skills.md` and update any MVP details that changed during implementation. Do not mark Phase 2 browser automation or Phase 3 plugin/status as shipped.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add docs/help/cli.md docs/help/app-overview.md docs/PRD-agent-automation-skills.md
-git commit -m "docs: document agent automation skills mvp"
-```
-
----
-
-### Task 11: Final Verification
-
-**Files:**
-- No new files.
-
-- [ ] **Step 1: Run focused package tests**
-
-Run:
-
-```bash
-pnpm vitest run packages/core/src/domain/skill-management.test.ts
-pnpm --filter @coder-studio/server test -- src/__tests__/skills/builtin-registry.test.ts src/__tests__/skills/builtin-sync-manager.test.ts src/__tests__/skills/commands.test.ts src/__tests__/automation/identify.test.ts src/__tests__/automation/capabilities.test.ts src/__tests__/automation/audit-log.test.ts src/__tests__/session-commands.test.ts
-pnpm --filter @spencer-kit/coder-studio test -- src/bin.test.ts
-pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/skills-panel.test.tsx
-```
-
-Expected: all commands exit 0.
-
-- [ ] **Step 2: Run typecheck**
-
-Run:
-
-```bash
-pnpm ci:typecheck
-```
-
-Expected: exit 0.
-
-- [ ] **Step 3: Run lint/check if typecheck passes**
-
-Run:
-
-```bash
-pnpm exec biome check --diagnostic-level=error --max-diagnostics=none packages/core packages/server packages/cli packages/web docs/PRD-agent-automation-skills.md docs/help/cli.md docs/help/app-overview.md
-```
-
-Expected: exit 0.
-
-- [ ] **Step 4: Manual smoke test**
-
-Run:
-
-```bash
-pnpm --filter @spencer-kit/coder-studio exec tsx src/bin.ts identify --json
-pnpm --filter @spencer-kit/coder-studio exec tsx src/bin.ts capabilities --json
-```
-
-Expected:
-
-- `identify --json` outside a managed agent session prints JSON with `"insideCoderStudio": false`.
-- `capabilities --json` prints JSON with a `commands` array containing `git.status`.
-
-- [ ] **Step 5: Inspect git status**
-
-Run:
-
-```bash
-git status --short
-```
-
-Expected: only files related to this plan are modified, plus the existing PRD if it has not already been committed.
-
-- [ ] **Step 6: Final commit**
-
-If previous tasks were not committed individually, commit all MVP changes:
-
-```bash
-git add packages/core packages/server packages/cli packages/web docs
-git commit -m "feat: add agent automation skills mvp"
-```
-
-Do not commit unrelated user changes.
diff --git a/docs/superpowers/plans/2026-06-08-supervisor-plan-tree.md b/docs/superpowers/plans/2026-06-08-supervisor-plan-tree.md
new file mode 100644
index 000000000..27fd840be
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-supervisor-plan-tree.md
@@ -0,0 +1,101 @@
+# Supervisor Plan Tree Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Redesign the supervisor plan tree so it renders as an expandable hierarchy with clear indentation and active-node emphasis.
+
+**Architecture:** Keep the current `SupervisorTargetMemory.planTree` data model intact and update only the web detail view. Introduce local tree expansion state in the shared supervisor details component, defaulting to a collapsed tree with the active path opened when available. Update styles to make parent/child structure obvious and make the active node visually distinct.
+
+**Tech Stack:** React, TypeScript, Jotai, Vitest, Testing Library, existing app CSS, Lucide icons.
+
+---
+
+### Task 1: Add regression tests for expandable supervisor tree behavior
+
+**Files:**
+- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-details-content.test.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+it("keeps plan tree children collapsed until the user expands a node", () => {
+ // render a planTree with children and no active path
+ // expect the child node not to be visible initially
+ // click the root expand button
+ // expect the child node to become visible
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @coder-studio/web exec vitest run src/features/supervisor/views/shared/supervisor-details-content.test.tsx`
+Expected: FAIL because the tree has no expand/collapse control yet and children are rendered immediately.
+
+- [ ] **Step 3: Write minimal implementation**
+
+No code yet.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @coder-studio/web exec vitest run src/features/supervisor/views/shared/supervisor-details-content.test.tsx`
+Expected: PASS after the tree interaction is implemented.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/features/supervisor/views/shared/supervisor-details-content.test.tsx
+git commit -m "test: cover expandable supervisor plan tree"
+```
+
+### Task 2: Implement expandable supervisor plan tree UI
+
+**Files:**
+- Modify: `packages/web/src/features/supervisor/views/shared/supervisor-details-content.tsx`
+- Modify: `packages/web/src/styles/components.css`
+- Modify: `packages/web/src/locales/en.json`
+- Modify: `packages/web/src/locales/zh.json`
+
+- [ ] **Step 1: Write the failing test**
+
+Use the regression test from Task 1.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run the same focused Vitest command and confirm the tree interaction is still missing.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add local expanded-node state, tree toggle buttons, active-path aware default expansion, and tree styling with indentation and highlight states.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @coder-studio/web exec vitest run src/features/supervisor/views/shared/supervisor-details-content.test.tsx`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/features/supervisor/views/shared/supervisor-details-content.tsx packages/web/src/styles/components.css packages/web/src/locales/en.json packages/web/src/locales/zh.json
+git commit -m "feat: render supervisor plan tree as expandable hierarchy"
+```
+
+### Task 3: Verify related supervisor tests
+
+**Files:**
+- Modify: none
+
+- [ ] **Step 1: Run the focused supervisor UI tests**
+
+Run:
+`pnpm --filter @coder-studio/web exec vitest run src/features/supervisor/views/shared/supervisor-details-content.test.tsx src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx`
+
+- [ ] **Step 2: Confirm the supervisor error/detail regression still passes**
+
+Expected: both suites pass and the details panel still renders during error state.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add -A
+git commit -m "test: verify supervisor detail tree rendering"
+```
diff --git a/docs/superpowers/plans/2026-06-11-dev-browser-editor-tab.md b/docs/superpowers/plans/2026-06-11-dev-browser-editor-tab.md
new file mode 100644
index 000000000..23521a258
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-dev-browser-editor-tab.md
@@ -0,0 +1,58 @@
+# Dev Browser Editor Tab Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the dev browser entry from workspace-level navigation into the editor header, opening a browser editor tab with its own URL toolbar.
+
+**Architecture:** Keep the existing server proxy and service worker implementation. Add a typed editor-tab layer in web state so file tabs remain file paths while the browser tab is a non-file target; render file and browser tabs through the existing editor header shell.
+
+**Tech Stack:** React 19, Jotai, Vitest, Testing Library, TypeScript, existing `DevBrowserSurface`.
+
+---
+
+## File Structure
+
+- Modify `packages/web/src/features/workspace/atoms/files.ts`: add typed editor tab and active editor target atoms.
+- Modify `packages/web/src/features/workspace/actions/open-editor-state.ts`: add helpers for file/browser tab normalization and cleanup.
+- Modify `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts`: expose editor-tab actions while preserving file-only state.
+- Modify `packages/web/src/features/code-editor/views/shared/code-editor-tabs-header.tsx`: render file tabs plus browser tab and add a tabbar Browser action.
+- Modify `packages/web/src/features/code-editor/views/shared/editor-surface.tsx`: render `DevBrowserSurface` when the browser tab is active.
+- Modify `packages/web/src/features/code-editor/views/shared/code-editor-host.tsx`: suppress file mode controls when the browser tab is active.
+- Modify desktop/mobile workspace views: remove Activity Bar/Dock browser entry points and keep browser inside the editor surface.
+- Modify tests under `packages/web/src/features/code-editor` and `packages/web/src/features/workspace/views`: cover Browser header action, browser tab rendering, and removed workspace-level entries.
+- Modify docs/help as needed to describe the editor-header entry.
+
+## Tasks
+
+### Task 1: Typed Editor Tab State
+
+- [ ] Add failing tests proving browser tabs are normalized separately from file paths.
+- [ ] Add `WorkspaceEditorTab`, `activeEditorTargetAtomFamily`, and `openEditorTabsAtomFamily`.
+- [ ] Keep existing file-path atoms as the file buffer compatibility layer.
+- [ ] Run focused atom/helper tests.
+- [ ] Commit.
+
+### Task 2: Editor Header Entry And Browser Tab Rendering
+
+- [ ] Add failing component tests for opening/focusing a Browser tab from the editor header.
+- [ ] Extend `useCodeEditorActions` with `openBrowserTab`, `activateEditorTab`, and `closeEditorTab`.
+- [ ] Extend `CodeEditorTabsHeader` to render file and browser tabs plus the Browser action.
+- [ ] Render `DevBrowserSurface` from `EditorSurface` when the browser tab is active.
+- [ ] Run focused code-editor tests.
+- [ ] Commit.
+
+### Task 3: Remove Workspace-Level Browser Entry Points
+
+- [ ] Add/update failing desktop and mobile workspace tests proving Browser is not in Activity Bar/Dock.
+- [ ] Remove `browser` from workspace sidebar/main-area mode and mobile sheet routing.
+- [ ] Remove desktop `Ctrl/Cmd+6` Browser shortcut behavior if present.
+- [ ] Update docs to say the entry is in the editor header.
+- [ ] Run focused workspace tests.
+- [ ] Commit.
+
+### Task 4: Verification
+
+- [ ] Run web focused tests for code editor, dev browser, desktop workspace, and mobile workspace.
+- [ ] Run `pnpm --filter @coder-studio/web build`.
+- [ ] Run `git diff --check`.
+- [ ] Report full `pnpm ci:verify` status only if run; otherwise call out focused verification.
diff --git a/docs/superpowers/plans/2026-06-11-dev-browser-loopback-proxy.md b/docs/superpowers/plans/2026-06-11-dev-browser-loopback-proxy.md
new file mode 100644
index 000000000..a46035808
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-dev-browser-loopback-proxy.md
@@ -0,0 +1,2198 @@
+# Dev Browser Loopback Proxy Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Coder Studio built-in browser that opens manually entered loopback dev-server URLs through an authenticated HTTP proxy and service-worker resource router.
+
+**Architecture:** Server owns loopback target validation, short-lived sessions, dev-browser shell HTML, and HTTP proxying under `/dev-browser/session/:id/proxy/*`. Web owns the workspace browser UI, service worker script, URL mapping, lifecycle cleanup, and visible unsupported states. WebSocket/HMR are explicitly unsupported in v1 and fail with a clear message.
+
+**Tech Stack:** Fastify 5, Node 24 `fetch`, React 19, Jotai, Vite public assets, Vitest, Testing Library, TypeScript.
+
+---
+
+## File Structure
+
+- Create `packages/server/src/dev-browser/target-url.ts`: parse and validate manual loopback URLs.
+- Create `packages/server/src/dev-browser/target-url.test.ts`: URL validation tests.
+- Create `packages/server/src/dev-browser/session-store.ts`: in-memory short-lived dev browser session store.
+- Create `packages/server/src/dev-browser/session-store.test.ts`: session lifecycle and expiry tests.
+- Create `packages/server/src/dev-browser/proxy-headers.ts`: request and response header filtering plus redirect location rewriting.
+- Create `packages/server/src/dev-browser/proxy-headers.test.ts`: hop-by-hop stripping and redirect rewrite tests.
+- Create `packages/server/src/routes/dev-browser.ts`: create/read/delete session API, dev-browser shell route, HTTP proxy route.
+- Create `packages/server/src/routes/dev-browser.test.ts`: route, proxy, and error handling tests.
+- Modify `packages/server/src/app.ts`: register dev browser routes before static fallback routes.
+- Modify `packages/server/src/app-routing.test.ts`: assert `/dev-browser/session/:id/proxy/*` is not swallowed by SPA fallback.
+- Create `packages/web/public/dev-browser-sw.js`: service worker script scoped to `/dev-browser/`.
+- Create `packages/web/src/features/dev-browser/dev-browser-sw.test.ts`: load the public SW script in a mocked worker global and test mapper behavior.
+- Create `packages/web/src/features/dev-browser/api.ts`: client API for create/read/delete dev browser sessions.
+- Create `packages/web/src/features/dev-browser/api.test.ts`: credentialed fetch and error handling tests.
+- Create `packages/web/src/features/dev-browser/dev-browser-surface.tsx`: address bar, session lifecycle, iframe, and unsupported-state UI.
+- Create `packages/web/src/features/dev-browser/dev-browser-surface.test.tsx`: form, iframe, cleanup, and unsupported-state tests.
+- Modify `packages/web/src/features/workspace/atoms/layout.ts`: add `browser` desktop sidebar view.
+- Modify `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`: add browser main-area mode and mobile sheet kind.
+- Modify `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`: add desktop Browser activity item.
+- Modify `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`: render `DevBrowserSurface` in the desktop main stage.
+- Modify `packages/web/src/features/workspace/views/mobile/mobile-dock.tsx`: add mobile Browser dock item.
+- Modify `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx`: render Browser sheet.
+- Modify related workspace view tests under `packages/web/src/features/workspace/views/**`: cover desktop and mobile browser entry points.
+- Modify `packages/web/src/theme/icon-theme.ts`: add `nav.browser` and `mobile.dock.browser`.
+- Modify `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: add dev browser labels and errors.
+- Modify `packages/web/src/styles/components.css`: add dev browser layout styles.
+- Modify `packages/web/vite.config.ts`: proxy `/dev-browser` during web dev so Vite-hosted UI can reach backend shell and proxy routes.
+- Modify `docs/help/mobile-guide.md`: document manual local dev-server browser use and WebSocket/HMR limitation.
+
+---
+
+### Task 1: Server Target URL Validation
+
+**Files:**
+- Create: `packages/server/src/dev-browser/target-url.ts`
+- Create: `packages/server/src/dev-browser/target-url.test.ts`
+
+- [ ] **Step 1: Write failing validation tests**
+
+Create `packages/server/src/dev-browser/target-url.test.ts` with:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { parseDevBrowserTargetUrl, DevBrowserTargetUrlError } from "./target-url.js";
+
+describe("parseDevBrowserTargetUrl", () => {
+ it("accepts loopback HTTP URLs with explicit ports", () => {
+ expect(parseDevBrowserTargetUrl("http://localhost:8000/app?draft=1#top")).toMatchObject({
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app?draft=1",
+ targetHash: "#top",
+ connectHost: "127.0.0.1",
+ port: 8000,
+ });
+
+ expect(parseDevBrowserTargetUrl("http://127.0.0.1:5173/")).toMatchObject({
+ targetOrigin: "http://127.0.0.1:5173",
+ targetPath: "/",
+ targetHash: "",
+ connectHost: "127.0.0.1",
+ port: 5173,
+ });
+
+ expect(parseDevBrowserTargetUrl("http://[::1]:3000/docs")).toMatchObject({
+ targetOrigin: "http://[::1]:3000",
+ targetPath: "/docs",
+ targetHash: "",
+ connectHost: "::1",
+ port: 3000,
+ });
+ });
+
+ it("normalizes manual input without an explicit protocol to HTTP", () => {
+ expect(parseDevBrowserTargetUrl("localhost:8000")).toMatchObject({
+ displayUrl: "http://localhost:8000/",
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/",
+ });
+ });
+
+ it("rejects non-loopback and unsafe targets", () => {
+ const invalidInputs = [
+ "https://localhost:8000",
+ "http://localhost",
+ "http://localhost:0",
+ "http://localhost:8000@evil.test",
+ "http://user:pass@localhost:8000",
+ "http://192.168.1.20:8000",
+ "http://example.com:8000",
+ "file:///tmp/index.html",
+ "",
+ ];
+
+ for (const input of invalidInputs) {
+ expect(() => parseDevBrowserTargetUrl(input), input).toThrow(DevBrowserTargetUrlError);
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run the validation test and confirm it fails**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser/target-url.test.ts
+```
+
+Expected: fails because `packages/server/src/dev-browser/target-url.ts` does not exist.
+
+- [ ] **Step 3: Implement the validator**
+
+Create `packages/server/src/dev-browser/target-url.ts` with:
+
+```ts
+export class DevBrowserTargetUrlError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "DevBrowserTargetUrlError";
+ }
+}
+
+export interface DevBrowserTarget {
+ connectHost: "127.0.0.1" | "::1";
+ displayUrl: string;
+ originalHost: "localhost" | "127.0.0.1" | "[::1]";
+ port: number;
+ targetHash: string;
+ targetOrigin: string;
+ targetPath: string;
+}
+
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+function withDefaultProtocol(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ throw new DevBrowserTargetUrlError("empty_url");
+ }
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
+}
+
+function parsePort(url: URL): number {
+ if (!url.port) {
+ throw new DevBrowserTargetUrlError("missing_port");
+ }
+
+ const port = Number(url.port);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new DevBrowserTargetUrlError("invalid_port");
+ }
+
+ return port;
+}
+
+export function parseDevBrowserTargetUrl(input: string): DevBrowserTarget {
+ let url: URL;
+ try {
+ url = new URL(withDefaultProtocol(input));
+ } catch {
+ throw new DevBrowserTargetUrlError("invalid_url");
+ }
+
+ if (url.protocol !== "http:") {
+ throw new DevBrowserTargetUrlError("unsupported_protocol");
+ }
+
+ if (url.username || url.password) {
+ throw new DevBrowserTargetUrlError("credentials_not_allowed");
+ }
+
+ const host = url.hostname as "localhost" | "127.0.0.1" | "[::1]";
+ if (!LOOPBACK_HOSTS.has(host)) {
+ throw new DevBrowserTargetUrlError("host_not_allowed");
+ }
+
+ const port = parsePort(url);
+ const connectHost = host === "[::1]" ? "::1" : "127.0.0.1";
+ const targetOrigin = connectHost === "::1" ? `http://[::1]:${port}` : `http://127.0.0.1:${port}`;
+ const targetPath = `${url.pathname || "/"}${url.search}`;
+
+ return {
+ connectHost,
+ displayUrl: url.href,
+ originalHost: host,
+ port,
+ targetHash: url.hash,
+ targetOrigin,
+ targetPath,
+ };
+}
+```
+
+- [ ] **Step 4: Run the validation test and confirm it passes**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser/target-url.test.ts
+```
+
+Expected: passes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/dev-browser/target-url.ts packages/server/src/dev-browser/target-url.test.ts
+git commit -m "feat: validate dev browser loopback targets"
+```
+
+---
+
+### Task 2: Server Session Store
+
+**Files:**
+- Create: `packages/server/src/dev-browser/session-store.ts`
+- Create: `packages/server/src/dev-browser/session-store.test.ts`
+
+- [ ] **Step 1: Write failing session store tests**
+
+Create `packages/server/src/dev-browser/session-store.test.ts` with:
+
+```ts
+import { describe, expect, it } from "vitest";
+import type { DevBrowserTarget } from "./target-url.js";
+import { DevBrowserSessionStore } from "./session-store.js";
+
+function target(overrides: Partial = {}): DevBrowserTarget {
+ return {
+ connectHost: "127.0.0.1",
+ displayUrl: "http://localhost:8000/app",
+ originalHost: "localhost",
+ port: 8000,
+ targetHash: "",
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app",
+ ...overrides,
+ };
+}
+
+describe("DevBrowserSessionStore", () => {
+ it("creates and reads short-lived sessions", () => {
+ let now = 1_000;
+ const store = new DevBrowserSessionStore({ now: () => now, ttlMs: 10_000 });
+ const session = store.create(target());
+
+ expect(session.id).toMatch(/^dev_/);
+ expect(session.createdAt).toBe(1_000);
+ expect(session.expiresAt).toBe(11_000);
+ expect(store.get(session.id)).toMatchObject({
+ id: session.id,
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app",
+ });
+
+ now = 2_000;
+ expect(store.get(session.id)?.lastAccessedAt).toBe(2_000);
+ });
+
+ it("expires inactive sessions", () => {
+ let now = 1_000;
+ const store = new DevBrowserSessionStore({ now: () => now, ttlMs: 500 });
+ const session = store.create(target());
+
+ now = 1_501;
+
+ expect(store.get(session.id)).toBeNull();
+ });
+
+ it("deletes sessions explicitly", () => {
+ const store = new DevBrowserSessionStore({ now: () => 1_000, ttlMs: 10_000 });
+ const session = store.create(target());
+
+ expect(store.delete(session.id)).toBe(true);
+ expect(store.get(session.id)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run the session store test and confirm it fails**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser/session-store.test.ts
+```
+
+Expected: fails because `session-store.ts` does not exist.
+
+- [ ] **Step 3: Implement the session store**
+
+Create `packages/server/src/dev-browser/session-store.ts` with:
+
+```ts
+import { randomUUID } from "node:crypto";
+import type { DevBrowserTarget } from "./target-url.js";
+
+export interface DevBrowserSession extends DevBrowserTarget {
+ createdAt: number;
+ expiresAt: number;
+ id: string;
+ lastAccessedAt: number;
+}
+
+export interface DevBrowserSessionStoreOptions {
+ now?: () => number;
+ ttlMs?: number;
+}
+
+const DEFAULT_TTL_MS = 30 * 60 * 1_000;
+
+function cloneSession(session: DevBrowserSession): DevBrowserSession {
+ return { ...session };
+}
+
+export class DevBrowserSessionStore {
+ readonly #now: () => number;
+ readonly #ttlMs: number;
+ readonly #sessions = new Map();
+
+ constructor(options: DevBrowserSessionStoreOptions = {}) {
+ this.#now = options.now ?? (() => Date.now());
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
+ }
+
+ create(target: DevBrowserTarget): DevBrowserSession {
+ const now = this.#now();
+ const session: DevBrowserSession = {
+ ...target,
+ id: `dev_${randomUUID()}`,
+ createdAt: now,
+ lastAccessedAt: now,
+ expiresAt: now + this.#ttlMs,
+ };
+ this.#sessions.set(session.id, session);
+ return cloneSession(session);
+ }
+
+ get(id: string): DevBrowserSession | null {
+ const session = this.#sessions.get(id);
+ if (!session) {
+ return null;
+ }
+
+ const now = this.#now();
+ if (session.expiresAt <= now) {
+ this.#sessions.delete(id);
+ return null;
+ }
+
+ const nextSession = {
+ ...session,
+ lastAccessedAt: now,
+ expiresAt: now + this.#ttlMs,
+ };
+ this.#sessions.set(id, nextSession);
+ return cloneSession(nextSession);
+ }
+
+ delete(id: string): boolean {
+ return this.#sessions.delete(id);
+ }
+}
+```
+
+- [ ] **Step 4: Run target and session tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser/target-url.test.ts src/dev-browser/session-store.test.ts
+```
+
+Expected: passes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/dev-browser/session-store.ts packages/server/src/dev-browser/session-store.test.ts
+git commit -m "feat: store dev browser proxy sessions"
+```
+
+---
+
+### Task 3: Proxy Header Filtering
+
+**Files:**
+- Create: `packages/server/src/dev-browser/proxy-headers.ts`
+- Create: `packages/server/src/dev-browser/proxy-headers.test.ts`
+
+- [ ] **Step 1: Write failing header tests**
+
+Create `packages/server/src/dev-browser/proxy-headers.test.ts` with:
+
+```ts
+import { describe, expect, it } from "vitest";
+import {
+ filterProxyRequestHeaders,
+ filterProxyResponseHeaders,
+ rewriteProxyLocationHeader,
+} from "./proxy-headers.js";
+
+describe("dev browser proxy headers", () => {
+ it("strips hop-by-hop request headers and sets the target host", () => {
+ expect(
+ Object.fromEntries(
+ filterProxyRequestHeaders(
+ {
+ connection: "upgrade",
+ host: "coder.example",
+ upgrade: "websocket",
+ cookie: "coder_studio_auth=secret",
+ accept: "text/html",
+ },
+ "127.0.0.1:8000"
+ )
+ )
+ ).toEqual({
+ accept: "text/html",
+ host: "127.0.0.1:8000",
+ });
+ });
+
+ it("strips unsafe response headers", () => {
+ const headers = new Headers({
+ "content-type": "text/html",
+ "content-length": "200",
+ "content-encoding": "gzip",
+ connection: "keep-alive",
+ "set-cookie": "sid=abc",
+ });
+
+ expect(Object.fromEntries(filterProxyResponseHeaders(headers))).toEqual({
+ "content-type": "text/html",
+ });
+ });
+
+ it("rewrites loopback redirect locations into browser proxy paths", () => {
+ expect(
+ rewriteProxyLocationHeader("http://localhost:8000/dashboard?tab=1", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/dashboard?tab=1");
+
+ expect(
+ rewriteProxyLocationHeader("/login", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/login");
+ });
+});
+```
+
+- [ ] **Step 2: Run the header test and confirm it fails**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser/proxy-headers.test.ts
+```
+
+Expected: fails because `proxy-headers.ts` does not exist.
+
+- [ ] **Step 3: Implement header filtering**
+
+Create `packages/server/src/dev-browser/proxy-headers.ts` with:
+
+```ts
+import type { IncomingHttpHeaders } from "node:http";
+
+const HOP_BY_HOP_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+const BLOCKED_REQUEST_HEADERS = new Set(["cookie", "authorization", "origin"]);
+const BLOCKED_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "set-cookie"]);
+
+function appendHeader(target: Headers, key: string, value: string | string[] | undefined): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ target.append(key, entry);
+ }
+ return;
+ }
+
+ target.set(key, value);
+}
+
+export function filterProxyRequestHeaders(
+ headers: IncomingHttpHeaders,
+ targetHost: string
+): Headers {
+ const filtered = new Headers();
+
+ for (const [rawKey, value] of Object.entries(headers)) {
+ const key = rawKey.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_REQUEST_HEADERS.has(key) || key === "host") {
+ continue;
+ }
+
+ appendHeader(filtered, key, value);
+ }
+
+ filtered.set("host", targetHost);
+ return filtered;
+}
+
+export function filterProxyResponseHeaders(headers: Headers): Headers {
+ const filtered = new Headers();
+
+ headers.forEach((value, rawKey) => {
+ const key = rawKey.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_RESPONSE_HEADERS.has(key)) {
+ return;
+ }
+
+ filtered.set(key, value);
+ });
+
+ return filtered;
+}
+
+export function rewriteProxyLocationHeader(
+ location: string,
+ input: { browserProxyBase: string; port: number; targetOrigin: string }
+): string {
+ if (location.startsWith("/")) {
+ return `${input.browserProxyBase}${location}`;
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(location);
+ } catch {
+ return location;
+ }
+
+ const isLoopback =
+ parsed.protocol === "http:" &&
+ Number(parsed.port) === input.port &&
+ (parsed.hostname === "localhost" ||
+ parsed.hostname === "127.0.0.1" ||
+ parsed.hostname === "[::1]");
+
+ if (!isLoopback) {
+ return location;
+ }
+
+ return `${input.browserProxyBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
+}
+```
+
+- [ ] **Step 4: Run dev-browser server unit tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser
+```
+
+Expected: passes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/dev-browser/proxy-headers.ts packages/server/src/dev-browser/proxy-headers.test.ts
+git commit -m "feat: filter dev browser proxy headers"
+```
+
+---
+
+### Task 4: Server Routes And App Wiring
+
+**Files:**
+- Create: `packages/server/src/routes/dev-browser.ts`
+- Create: `packages/server/src/routes/dev-browser.test.ts`
+- Modify: `packages/server/src/app.ts`
+- Modify: `packages/server/src/app-routing.test.ts`
+
+- [ ] **Step 1: Write failing route tests**
+
+Create `packages/server/src/routes/dev-browser.test.ts` with focused tests for create, proxy, redirect, invalid target, and unsupported websocket:
+
+```ts
+import Fastify from "fastify";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { DevBrowserSessionStore } from "../dev-browser/session-store.js";
+import { registerDevBrowserRoutes } from "./dev-browser.js";
+
+describe("dev browser routes", () => {
+ let target: ReturnType;
+ let app: ReturnType;
+ let targetOrigin: string;
+
+ beforeEach(async () => {
+ target = Fastify({ logger: false });
+ target.get("/app/", async (_request, reply) =>
+ reply
+ .type("text/html")
+ .send('')
+ );
+ target.get("/assets/app.js", async (_request, reply) =>
+ reply.type("application/javascript").send('window.loaded = true;')
+ );
+ target.post("/api/echo", async (request) => request.body);
+ target.get("/redirect", async (_request, reply) => reply.redirect("/app/"));
+ await target.listen({ host: "127.0.0.1", port: 0 });
+
+ const address = target.server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("target listen failed");
+ }
+ targetOrigin = `http://127.0.0.1:${address.port}`;
+
+ app = Fastify({ logger: false });
+ registerDevBrowserRoutes(app, {
+ sessions: new DevBrowserSessionStore(),
+ });
+ await app.ready();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ await target.close();
+ });
+
+ async function createSession(path = "/app/") {
+ const response = await app.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: `${targetOrigin}${path}` },
+ });
+ expect(response.statusCode).toBe(200);
+ return response.json() as {
+ browserProxyBase: string;
+ browserUrl: string;
+ id: string;
+ targetOrigin: string;
+ };
+ }
+
+ it("creates sessions for loopback targets", async () => {
+ const created = await createSession();
+
+ expect(created.id).toMatch(/^dev_/);
+ expect(created.browserUrl).toBe(`/dev-browser/session/${created.id}/`);
+ expect(created.browserProxyBase).toBe(`/dev-browser/session/${created.id}/proxy`);
+ expect(created.targetOrigin).toBe(targetOrigin);
+ });
+
+ it("rejects invalid session targets", async () => {
+ const response = await app.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: "http://example.com:8000" },
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.json()).toMatchObject({ error: "invalid_dev_browser_target" });
+ });
+
+ it("serves shell HTML that registers the service worker", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: created.browserUrl,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("/dev-browser-sw.js");
+ expect(response.body).toContain(created.browserProxyBase);
+ });
+
+ it("proxies HTML and injects the websocket failure bootstrap", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/app/`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("Coder Studio dev browser does not proxy WebSocket");
+ expect(response.body).toContain('');
+ });
+
+ it("proxies static assets and JSON posts", async () => {
+ const created = await createSession();
+ const asset = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/assets/app.js`,
+ });
+ const post = await app.inject({
+ method: "POST",
+ url: `${created.browserProxyBase}/api/echo`,
+ headers: { "content-type": "application/json" },
+ payload: { ok: true },
+ });
+
+ expect(asset.statusCode).toBe(200);
+ expect(asset.headers["content-type"]).toContain("javascript");
+ expect(asset.body).toContain("window.loaded");
+ expect(post.statusCode).toBe(200);
+ expect(post.json()).toEqual({ ok: true });
+ });
+
+ it("rewrites loopback redirects to browser proxy paths", async () => {
+ const created = await createSession("/redirect");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/redirect`,
+ });
+
+ expect(response.statusCode).toBeGreaterThanOrEqual(300);
+ expect(response.statusCode).toBeLessThan(400);
+ expect(response.headers.location).toBe(`${created.browserProxyBase}/app/`);
+ });
+
+ it("rejects websocket upgrade attempts", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/socket`,
+ headers: {
+ connection: "upgrade",
+ upgrade: "websocket",
+ },
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.json()).toMatchObject({ error: "websocket_not_supported" });
+ });
+});
+```
+
+- [ ] **Step 2: Run route tests and confirm they fail**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/routes/dev-browser.test.ts
+```
+
+Expected: fails because `registerDevBrowserRoutes` does not exist.
+
+- [ ] **Step 3: Implement dev browser routes**
+
+Create `packages/server/src/routes/dev-browser.ts` with the following route shape:
+
+```ts
+import { Readable } from "node:stream";
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import { z } from "zod";
+import {
+ filterProxyRequestHeaders,
+ filterProxyResponseHeaders,
+ rewriteProxyLocationHeader,
+} from "../dev-browser/proxy-headers.js";
+import type { DevBrowserSession } from "../dev-browser/session-store.js";
+import { DevBrowserSessionStore } from "../dev-browser/session-store.js";
+import { DevBrowserTargetUrlError, parseDevBrowserTargetUrl } from "../dev-browser/target-url.js";
+
+const createSessionSchema = z.object({
+ url: z.string().min(1),
+});
+
+function browserProxyBase(id: string): string {
+ return `/dev-browser/session/${id}/proxy`;
+}
+
+function browserUrl(id: string): string {
+ return `/dev-browser/session/${id}/`;
+}
+
+function serializeSession(session: DevBrowserSession) {
+ return {
+ id: session.id,
+ browserUrl: browserUrl(session.id),
+ browserProxyBase: browserProxyBase(session.id),
+ displayUrl: session.displayUrl,
+ targetOrigin: session.targetOrigin,
+ targetPath: session.targetPath,
+ targetHash: session.targetHash,
+ expiresAt: session.expiresAt,
+ };
+}
+
+function escapeJsonForScript(value: unknown): string {
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
+}
+
+function renderDevBrowserShell(session: DevBrowserSession): string {
+ const payload = serializeSession(session);
+ return `
+
+
+
+
+ Coder Studio Dev Browser
+
+
+ Opening local preview...
+
+
+`;
+}
+
+function createHtmlBootstrap(session: DevBrowserSession): string {
+ const payload = serializeSession(session);
+ return ``;
+}
+
+function injectHtmlBootstrap(html: string, session: DevBrowserSession): string {
+ const bootstrap = createHtmlBootstrap(session);
+ return html.includes("")
+ ? html.replace("", `${bootstrap}`)
+ : `${bootstrap}${html}`;
+}
+
+function isWebSocketUpgrade(request: FastifyRequest): boolean {
+ return (
+ String(request.headers.upgrade ?? "").toLowerCase() === "websocket" ||
+ String(request.headers.connection ?? "").toLowerCase().includes("upgrade")
+ );
+}
+
+function resolveProxyTargetUrl(session: DevBrowserSession, request: FastifyRequest): URL {
+ const incoming = new URL(request.url, "http://coder-studio.local");
+ const prefix = `/dev-browser/session/${session.id}/proxy`;
+ const path = incoming.pathname.startsWith(prefix)
+ ? incoming.pathname.slice(prefix.length) || "/"
+ : "/";
+ return new URL(`${path}${incoming.search}`, session.targetOrigin);
+}
+
+async function proxyRequest(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ session: DevBrowserSession
+) {
+ if (isWebSocketUpgrade(request)) {
+ return reply.status(400).send({ error: "websocket_not_supported" });
+ }
+
+ const targetUrl = resolveProxyTargetUrl(session, request);
+ const targetHeaders = filterProxyRequestHeaders(request.headers, targetUrl.host);
+ const method = request.method.toUpperCase();
+ const init: RequestInit = {
+ method,
+ headers: targetHeaders,
+ redirect: "manual",
+ };
+
+ if (method !== "GET" && method !== "HEAD" && request.body !== undefined) {
+ if (Buffer.isBuffer(request.body) || typeof request.body === "string") {
+ init.body = request.body;
+ } else {
+ init.body = JSON.stringify(request.body);
+ targetHeaders.set("content-type", "application/json");
+ }
+ }
+
+ let upstream: Response;
+ try {
+ upstream = await fetch(targetUrl, init);
+ } catch {
+ return reply.status(502).send({ error: "dev_browser_target_unavailable" });
+ }
+
+ const responseHeaders = filterProxyResponseHeaders(upstream.headers);
+ const location = upstream.headers.get("location");
+ if (location) {
+ responseHeaders.set(
+ "location",
+ rewriteProxyLocationHeader(location, {
+ browserProxyBase: browserProxyBase(session.id),
+ port: session.port,
+ targetOrigin: session.targetOrigin,
+ })
+ );
+ }
+
+ responseHeaders.forEach((value, key) => reply.header(key, value));
+ reply.status(upstream.status);
+
+ const contentType = upstream.headers.get("content-type") ?? "";
+ if (contentType.includes("text/html")) {
+ const html = injectHtmlBootstrap(await upstream.text(), session);
+ return reply.type("text/html; charset=utf-8").send(html);
+ }
+
+ if (!upstream.body) {
+ return reply.send();
+ }
+
+ return reply.send(Readable.fromWeb(upstream.body));
+}
+
+export function registerDevBrowserRoutes(
+ app: FastifyInstance,
+ deps: { sessions?: DevBrowserSessionStore } = {}
+): void {
+ const sessions = deps.sessions ?? new DevBrowserSessionStore();
+
+ app.post("/api/dev-proxy/session", async (request, reply) => {
+ const parsed = createSessionSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "invalid_dev_browser_payload" });
+ }
+
+ try {
+ const session = sessions.create(parseDevBrowserTargetUrl(parsed.data.url));
+ return reply.send(serializeSession(session));
+ } catch (error) {
+ if (error instanceof DevBrowserTargetUrlError) {
+ return reply.status(400).send({ error: "invalid_dev_browser_target" });
+ }
+ throw error;
+ }
+ });
+
+ app.get("/api/dev-proxy/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ const session = sessions.get(id);
+ return session
+ ? reply.send(serializeSession(session))
+ : reply.status(404).send({ error: "dev_browser_session_not_found" });
+ });
+
+ app.delete("/api/dev-proxy/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ sessions.delete(id);
+ return reply.send({ ok: true });
+ });
+
+ app.get("/dev-browser/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ return reply.redirect(`/dev-browser/session/${id}/`);
+ });
+
+ app.get("/dev-browser/session/:id/", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ const session = sessions.get(id);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+ return reply.type("text/html; charset=utf-8").send(renderDevBrowserShell(session));
+ });
+
+ app.all("/dev-browser/session/:id/proxy/*", async (request, reply) => {
+ const { id } = request.params as { id: string; "*": string };
+ const session = sessions.get(id);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+ return proxyRequest(request, reply, session);
+ });
+}
+```
+
+- [ ] **Step 4: Register routes in the app**
+
+Modify `packages/server/src/app.ts`:
+
+```ts
+import { registerDevBrowserRoutes } from "./routes/dev-browser.js";
+```
+
+Register before `registerPreviewRoutes` and before static fallback:
+
+```ts
+ registerDevBrowserRoutes(app);
+
+ const previewSessions = new PreviewSessionStore();
+ registerPreviewRoutes(app, {
+ workspaceMgr: deps.workspaceMgr,
+ previewSessions,
+ });
+```
+
+- [ ] **Step 5: Extend app routing test**
+
+Add this test to `packages/server/src/app-routing.test.ts`:
+
+```ts
+ it("does not serve index.html for dev browser proxy paths", async () => {
+ const instance = await createApp();
+
+ const response = await instance.inject({
+ method: "GET",
+ url: "/dev-browser/session/missing/proxy/app.js",
+ headers: {
+ accept: "application/javascript",
+ },
+ });
+
+ expect(response.statusCode).toBe(404);
+ expect(response.body).not.toContain("");
+ });
+```
+
+- [ ] **Step 6: Run server route tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test src/dev-browser src/routes/dev-browser.test.ts src/app-routing.test.ts
+```
+
+Expected: passes.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/server/src/dev-browser packages/server/src/routes/dev-browser.ts packages/server/src/routes/dev-browser.test.ts packages/server/src/app.ts packages/server/src/app-routing.test.ts
+git commit -m "feat: proxy loopback dev browser HTTP requests"
+```
+
+---
+
+### Task 5: Service Worker Routing
+
+**Files:**
+- Create: `packages/web/public/dev-browser-sw.js`
+- Create: `packages/web/src/features/dev-browser/dev-browser-sw.test.ts`
+
+- [ ] **Step 1: Write failing service worker tests**
+
+Create `packages/web/src/features/dev-browser/dev-browser-sw.test.ts` with:
+
+```ts
+// @vitest-environment node
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import vm from "node:vm";
+import { describe, expect, it } from "vitest";
+
+interface SwHarness {
+ mapRequest(input: {
+ clientSessionId?: string;
+ referrer?: string;
+ requestUrl: string;
+ sessions: Record;
+ }): string | null;
+}
+
+function loadHarness(): SwHarness {
+ const script = readFileSync(resolve(process.cwd(), "public/dev-browser-sw.js"), "utf8");
+ const listeners: Record = {};
+ const context = {
+ URL,
+ console,
+ clients: { get: async () => null },
+ fetch: async () => new Response("ok"),
+ self: {
+ __coderStudioDevBrowserSwTest: undefined as SwHarness | undefined,
+ addEventListener: (name: string, handler: unknown) => {
+ listeners[name] = handler;
+ },
+ location: {
+ origin: "https://studio.example",
+ },
+ skipWaiting: () => undefined,
+ },
+ };
+
+ vm.runInNewContext(script, context);
+ const harness = context.self.__coderStudioDevBrowserSwTest;
+ if (!harness) {
+ throw new Error("service worker test harness missing");
+ }
+ return harness;
+}
+
+describe("dev browser service worker mapper", () => {
+ const session = {
+ id: "dev_1",
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app/",
+ };
+
+ it("maps root-relative resource URLs using the proxied referrer", () => {
+ const harness = loadHarness();
+
+ expect(
+ harness.mapRequest({
+ requestUrl: "https://studio.example/assets/app.js",
+ referrer: "https://studio.example/dev-browser/session/dev_1/proxy/app/",
+ sessions: { dev_1: session },
+ })
+ ).toBe("https://studio.example/dev-browser/session/dev_1/proxy/assets/app.js");
+ });
+
+ it("maps hardcoded localhost URLs to the active proxy base", () => {
+ const harness = loadHarness();
+
+ expect(
+ harness.mapRequest({
+ requestUrl: "http://localhost:8000/chunk.js?x=1",
+ referrer: "https://studio.example/dev-browser/session/dev_1/proxy/app/",
+ sessions: { dev_1: session },
+ })
+ ).toBe("https://studio.example/dev-browser/session/dev_1/proxy/chunk.js?x=1");
+ });
+
+ it("does not rewrite unrelated Coder Studio requests", () => {
+ const harness = loadHarness();
+
+ expect(
+ harness.mapRequest({
+ requestUrl: "https://studio.example/assets/main-app.js",
+ referrer: "https://studio.example/workspace",
+ sessions: { dev_1: session },
+ })
+ ).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run the service worker test and confirm it fails**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/dev-browser/dev-browser-sw.test.ts
+```
+
+Expected: fails because `public/dev-browser-sw.js` does not exist.
+
+- [ ] **Step 3: Implement the service worker**
+
+Create `packages/web/public/dev-browser-sw.js` with:
+
+```js
+const SESSION_MESSAGE_TYPE = "coder-studio-dev-browser-session";
+const sessions = new Map();
+const clientSessions = new Map();
+
+function getSessionIdFromProxyPath(pathname) {
+ const match = /^\/dev-browser\/session\/([^/]+)\/proxy(?:\/|$)/.exec(pathname);
+ return match ? decodeURIComponent(match[1]) : null;
+}
+
+function getSessionForRequest(input) {
+ if (input.clientSessionId && input.sessions[input.clientSessionId]) {
+ return input.sessions[input.clientSessionId];
+ }
+
+ const requestUrl = new URL(input.requestUrl);
+ const fromRequest = getSessionIdFromProxyPath(requestUrl.pathname);
+ if (fromRequest && input.sessions[fromRequest]) {
+ return input.sessions[fromRequest];
+ }
+
+ if (input.referrer) {
+ const referrerUrl = new URL(input.referrer);
+ const fromReferrer = getSessionIdFromProxyPath(referrerUrl.pathname);
+ if (fromReferrer && input.sessions[fromReferrer]) {
+ return input.sessions[fromReferrer];
+ }
+ }
+
+ return null;
+}
+
+function isLoopbackUrlForSession(url, session) {
+ if (url.protocol !== "http:") {
+ return false;
+ }
+
+ const target = new URL(session.targetOrigin);
+ return (
+ url.port === target.port &&
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")
+ );
+}
+
+function mapRequestToProxy(input) {
+ const requestUrl = new URL(input.requestUrl);
+ const session = getSessionForRequest(input);
+ if (!session) {
+ return null;
+ }
+
+ if (requestUrl.pathname.startsWith(session.browserProxyBase)) {
+ return null;
+ }
+
+ if (requestUrl.origin === self.location.origin) {
+ if (requestUrl.pathname.startsWith("/dev-browser/")) {
+ return null;
+ }
+ return `${self.location.origin}${session.browserProxyBase}${requestUrl.pathname}${requestUrl.search}`;
+ }
+
+ if (isLoopbackUrlForSession(requestUrl, session)) {
+ return `${self.location.origin}${session.browserProxyBase}${requestUrl.pathname}${requestUrl.search}`;
+ }
+
+ return null;
+}
+
+self.addEventListener("install", (event) => {
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type !== SESSION_MESSAGE_TYPE || !event.data.session?.id) {
+ return;
+ }
+
+ sessions.set(event.data.session.id, event.data.session);
+ if (event.source?.id) {
+ clientSessions.set(event.source.id, event.data.session.id);
+ }
+});
+
+self.addEventListener("fetch", (event) => {
+ const clientSessionId = event.clientId ? clientSessions.get(event.clientId) : undefined;
+ const mappedUrl = mapRequestToProxy({
+ requestUrl: event.request.url,
+ referrer: event.request.referrer,
+ clientSessionId,
+ sessions: Object.fromEntries(sessions.entries()),
+ });
+
+ if (!mappedUrl) {
+ return;
+ }
+
+ if (event.request.mode === "navigate") {
+ event.respondWith(Response.redirect(mappedUrl, 302));
+ return;
+ }
+
+ const proxiedRequest = new Request(mappedUrl, event.request);
+ event.respondWith(fetch(proxiedRequest));
+});
+
+self.__coderStudioDevBrowserSwTest = {
+ mapRequest: mapRequestToProxy,
+};
+```
+
+- [ ] **Step 4: Run service worker tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/dev-browser/dev-browser-sw.test.ts
+```
+
+Expected: passes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/public/dev-browser-sw.js packages/web/src/features/dev-browser/dev-browser-sw.test.ts
+git commit -m "feat: route dev browser resources with service worker"
+```
+
+---
+
+### Task 6: Web API And Dev Browser Surface
+
+**Files:**
+- Create: `packages/web/src/features/dev-browser/api.ts`
+- Create: `packages/web/src/features/dev-browser/api.test.ts`
+- Create: `packages/web/src/features/dev-browser/dev-browser-surface.tsx`
+- Create: `packages/web/src/features/dev-browser/dev-browser-surface.test.tsx`
+
+- [ ] **Step 1: Write failing API tests**
+
+Create `packages/web/src/features/dev-browser/api.test.ts`:
+
+```ts
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createDevBrowserSession, deleteDevBrowserSession } from "./api";
+
+describe("dev browser api", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("creates and deletes sessions with credentialed fetch", async () => {
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+ if (url === "/api/dev-proxy/session" && init?.method === "POST") {
+ return new Response(
+ JSON.stringify({
+ id: "dev_1",
+ browserUrl: "/dev-browser/session/dev_1/",
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ }
+
+ if (url === "/api/dev-proxy/session/dev_1" && init?.method === "DELETE") {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+
+ return new Response("missing", { status: 404 });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const created = await createDevBrowserSession("localhost:8000");
+ await deleteDevBrowserSession("dev_1");
+
+ expect(created.browserUrl).toBe("/dev-browser/session/dev_1/");
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/dev-proxy/session",
+ expect.objectContaining({
+ method: "POST",
+ credentials: "include",
+ body: JSON.stringify({ url: "localhost:8000" }),
+ })
+ );
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/dev-proxy/session/dev_1",
+ expect.objectContaining({ method: "DELETE", credentials: "include" })
+ );
+ });
+});
+```
+
+- [ ] **Step 2: Implement API helpers**
+
+Create `packages/web/src/features/dev-browser/api.ts`:
+
+```ts
+export interface DevBrowserSessionResponse {
+ browserProxyBase: string;
+ browserUrl: string;
+ displayUrl?: string;
+ expiresAt?: number;
+ id: string;
+ targetOrigin: string;
+}
+
+async function readJson(response: Response): Promise {
+ if (!response.ok) {
+ throw new Error(`dev_browser_request_failed:${response.status}`);
+ }
+ return response.json() as Promise;
+}
+
+export async function createDevBrowserSession(url: string): Promise {
+ const response = await fetch("/api/dev-proxy/session", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url }),
+ });
+
+ return readJson(response);
+}
+
+export async function deleteDevBrowserSession(sessionId: string): Promise {
+ const response = await fetch(`/api/dev-proxy/session/${sessionId}`, {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ await readJson<{ ok: true }>(response);
+}
+```
+
+- [ ] **Step 3: Run API tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/dev-browser/api.test.ts
+```
+
+Expected: passes.
+
+- [ ] **Step 4: Write failing surface tests**
+
+Create `packages/web/src/features/dev-browser/dev-browser-surface.test.tsx`:
+
+```tsx
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DevBrowserSurface } from "./dev-browser-surface";
+
+vi.mock("../../lib/i18n", () => ({
+ useTranslation: () => (key: string) => {
+ const translations: Record = {
+ "dev_browser.title": "Browser",
+ "dev_browser.url_label": "Local URL",
+ "dev_browser.url_placeholder": "localhost:8000",
+ "dev_browser.open": "Open",
+ "dev_browser.loading": "Opening local preview",
+ "dev_browser.unsupported": "Service workers are unavailable",
+ "dev_browser.error": "Could not open local preview",
+ };
+ return translations[key] ?? key;
+ },
+}));
+
+vi.mock("./api", () => ({
+ createDevBrowserSession: vi.fn(async () => ({
+ id: "dev_1",
+ browserUrl: "/dev-browser/session/dev_1/",
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ })),
+ deleteDevBrowserSession: vi.fn(async () => undefined),
+}));
+
+describe("DevBrowserSurface", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("creates a session and renders the iframe", async () => {
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ Object.defineProperty(window.navigator, "serviceWorker", {
+ configurable: true,
+ value: {},
+ });
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Local URL"), {
+ target: { value: "localhost:8000" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Open" }));
+
+ const frame = await screen.findByTitle("Browser");
+ expect(frame).toHaveAttribute("src", "/dev-browser/session/dev_1/");
+ });
+
+ it("shows unsupported state when service workers are unavailable", () => {
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: false });
+ Object.defineProperty(window.navigator, "serviceWorker", {
+ configurable: true,
+ value: undefined,
+ });
+
+ render( );
+
+ expect(screen.getByText("Service workers are unavailable")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open" })).toBeDisabled();
+ });
+
+ it("deletes the active session on unmount", async () => {
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ Object.defineProperty(window.navigator, "serviceWorker", {
+ configurable: true,
+ value: {},
+ });
+ const api = await import("./api");
+
+ const { unmount } = render( );
+ fireEvent.change(screen.getByLabelText("Local URL"), {
+ target: { value: "localhost:8000" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Open" }));
+ await screen.findByTitle("Browser");
+
+ unmount();
+
+ await waitFor(() => expect(api.deleteDevBrowserSession).toHaveBeenCalledWith("dev_1"));
+ });
+});
+```
+
+- [ ] **Step 5: Implement `DevBrowserSurface`**
+
+Create `packages/web/src/features/dev-browser/dev-browser-surface.tsx`:
+
+```tsx
+import { type FormEvent, useEffect, useRef, useState } from "react";
+import { EmptyState, Notice } from "../../components/ui";
+import { useTranslation } from "../../lib/i18n";
+import {
+ createDevBrowserSession,
+ deleteDevBrowserSession,
+ type DevBrowserSessionResponse,
+} from "./api";
+
+function supportsDevBrowserServiceWorker(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ window.isSecureContext &&
+ typeof navigator !== "undefined" &&
+ "serviceWorker" in navigator
+ );
+}
+
+export function DevBrowserSurface() {
+ const t = useTranslation();
+ const [url, setUrl] = useState("");
+ const [session, setSession] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const sessionRef = useRef(null);
+ const supported = supportsDevBrowserServiceWorker();
+
+ useEffect(() => {
+ sessionRef.current = session;
+ }, [session]);
+
+ useEffect(() => {
+ return () => {
+ const activeSession = sessionRef.current;
+ if (activeSession) {
+ void deleteDevBrowserSession(activeSession.id).catch(() => undefined);
+ }
+ };
+ }, []);
+
+ const open = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!supported || loading || !url.trim()) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ const previousSession = sessionRef.current;
+ try {
+ const created = await createDevBrowserSession(url.trim());
+ setSession(created);
+ if (previousSession) {
+ void deleteDevBrowserSession(previousSession.id).catch(() => undefined);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "dev_browser_open_failed");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {!supported ? : null}
+ {error ? : null}
+
+
+ {session ? (
+
+ ) : (
+
{t("dev_browser.title")}}
+ description={{t("dev_browser.empty_description")}
}
+ />
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 6: Run dev browser web tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/dev-browser
+```
+
+Expected: passes.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/web/src/features/dev-browser
+git commit -m "feat: add dev browser workspace surface"
+```
+
+---
+
+### Task 7: Desktop And Mobile Workspace Integration
+
+**Files:**
+- Modify: `packages/web/src/features/workspace/atoms/layout.ts`
+- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`
+- Modify: `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`
+- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`
+- Modify: `packages/web/src/features/workspace/views/mobile/mobile-dock.tsx`
+- Modify: `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx`
+- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx`
+- Modify: `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx`
+- Modify: `packages/web/src/theme/icon-theme.ts`
+- Modify: `packages/web/src/locales/en.json`
+- Modify: `packages/web/src/locales/zh.json`
+- Modify: `packages/web/src/styles/components.css`
+
+- [ ] **Step 1: Write failing desktop integration test**
+
+In `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx`, mock the dev browser component:
+
+```ts
+vi.mock("../../../dev-browser/dev-browser-surface", () => ({
+ DevBrowserSurface: () =>
,
+}));
+```
+
+Add translation entries to the existing mock:
+
+```ts
+ "workspace.sidebar.browser": "Browser",
+```
+
+Add a test:
+
+```tsx
+ it("opens the dev browser from the activity bar", () => {
+ renderDesktopView("explorer");
+
+ fireEvent.click(screen.getByRole("button", { name: "Browser" }));
+
+ expect(screen.getByTestId("dev-browser-surface")).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 2: Write failing mobile integration test**
+
+In `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx`, update the `MobileDock` mock type to include `browser` and expose a button:
+
+```tsx
+ MobileDock: ({
+ activeItem,
+ onSelectItem,
+ }: {
+ activeItem: "agent" | "files" | "terminal" | "browser" | null;
+ onSelectItem: (item: "agent" | "files" | "terminal" | "browser") => void;
+ }) => (
+
+ onSelectItem("browser")}>
+ Browser
+
+
+ ),
+```
+
+Mock dev browser:
+
+```ts
+vi.mock("../../../dev-browser/dev-browser-surface", () => ({
+ DevBrowserSurface: () =>
,
+}));
+```
+
+Add a test near the existing sheet tests:
+
+```tsx
+ it("opens the dev browser sheet from the mobile dock", async () => {
+ renderMobileView();
+
+ fireEvent.click(screen.getByRole("button", { name: "Browser" }));
+
+ expect(await screen.findByTestId("dev-browser-surface")).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 3: Run workspace integration tests and confirm they fail**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx
+```
+
+Expected: fails because the browser view is not wired.
+
+- [ ] **Step 4: Add browser layout state**
+
+Modify `packages/web/src/features/workspace/atoms/layout.ts`:
+
+```ts
+export type DesktopSidebarView =
+ | "explorer"
+ | "search"
+ | "source-control"
+ | "agent-instructions"
+ | "skills"
+ | "browser";
+```
+
+Add to `DESKTOP_SIDEBAR_VIEW_VALUES`:
+
+```ts
+ "browser",
+```
+
+- [ ] **Step 5: Add icon semantics**
+
+Modify `packages/web/src/theme/icon-theme.ts`:
+
+```ts
+ "nav.browser",
+ "mobile.dock.browser",
+```
+
+Add to `BASE_ICON_THEME.icons`:
+
+```ts
+ "nav.browser": { glyph: Globe, tone: "current" },
+ "mobile.dock.browser": { glyph: Globe, tone: "current" },
+```
+
+- [ ] **Step 6: Add desktop activity item**
+
+Modify `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx` items:
+
+```ts
+ { view: "browser", label: t("workspace.sidebar.browser"), icon: "nav.browser" },
+```
+
+- [ ] **Step 7: Add browser main area mode**
+
+Modify `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`:
+
+```ts
+export type WorkspaceMainAreaMode = "agent" | "editor" | "browser";
+export type MobileWorkspaceSheetKind = "files" | "terminal" | "supervisor" | "browser" | null;
+```
+
+Change main area mode derivation:
+
+```ts
+ const mainAreaMode: WorkspaceMainAreaMode =
+ desktopSidebarView === "browser"
+ ? "browser"
+ : activeFilePath ||
+ editorViewVisible ||
+ diffPreview?.kind === "commit-file-list" ||
+ diffPreview?.kind === "commit-file-diff"
+ ? "editor"
+ : "agent";
+```
+
+- [ ] **Step 8: Render desktop browser surface**
+
+Modify `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` imports:
+
+```ts
+import { DevBrowserSurface } from "../../../dev-browser/dev-browser-surface";
+```
+
+Render sidebar helper content:
+
+```tsx
+ {activeSidebarView === "browser" ? (
+
+
+
{t("dev_browser.sidebar_description")}
+
+
+ ) : null}
+```
+
+Render browser in the main stage before the agent panes:
+
+```tsx
+ {mainAreaMode === "browser" ? : null}
+
+ {mainAreaMode === "browser" ? null :
}
+
+```
+
+- [ ] **Step 9: Add mobile dock browser item**
+
+Modify `packages/web/src/features/workspace/views/mobile/mobile-dock.tsx`:
+
+```ts
+type MobileDockItem = "agent" | "files" | "terminal" | "browser";
+
+interface MobileDockProps {
+ activeItem: MobileDockItem | null;
+ onSelectItem: (item: MobileDockItem) => void;
+}
+```
+
+Add the browser button:
+
+```tsx
+ onSelectItem("browser")}
+ aria-label={t("mobile.dock.open_browser")}
+ >
+
+
+
+ {t("dev_browser.short_title")}
+
+```
+
+- [ ] **Step 10: Render mobile browser sheet**
+
+Modify `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx` imports:
+
+```ts
+import { DevBrowserSurface } from "../../../dev-browser/dev-browser-surface";
+```
+
+Update `handleDockSelect`:
+
+```ts
+ const handleDockSelect = (item: "agent" | "files" | "terminal" | "browser") => {
+```
+
+Update active dock item:
+
+```ts
+ : mobileSheet === "files" || mobileSheet === "terminal" || mobileSheet === "browser"
+ ? mobileSheet
+ : null;
+```
+
+Add sheet body branch:
+
+```tsx
+ : mobileSheet === "browser"
+ ? {
+ title: t("dev_browser.title"),
+ body: ,
+ footer: activeWorkspaceId ? (
+
+ ) : null,
+ kicker: null,
+ fullscreen: true,
+ bodyClassName: "mobile-sheet__body--flush mobile-sheet__body--fullscreen",
+ contentClassName: "mobile-sheet--browser",
+ }
+```
+
+- [ ] **Step 11: Add locale entries**
+
+Modify `packages/web/src/locales/en.json` under the existing nested objects:
+
+```json
+{
+ "workspace": {
+ "sidebar": {
+ "browser": "Browser"
+ }
+ },
+ "mobile": {
+ "dock": {
+ "open_browser": "Open Browser sheet"
+ }
+ },
+ "dev_browser": {
+ "title": "Browser",
+ "short_title": "Browser",
+ "url_label": "Local URL",
+ "url_placeholder": "localhost:8000",
+ "open": "Open",
+ "loading": "Opening...",
+ "unsupported": "Service workers are unavailable in this browser or connection. Use HTTPS or localhost to enable the built-in browser.",
+ "error": "Could not open the local preview.",
+ "empty_description": "Enter a loopback URL from a local dev server, such as localhost:8000.",
+ "sidebar_description": "Open a local dev server through Coder Studio without exposing that server directly."
+ }
+}
+```
+
+Modify `packages/web/src/locales/zh.json` with equivalent Chinese labels:
+
+```json
+{
+ "workspace": {
+ "sidebar": {
+ "browser": "浏览器"
+ }
+ },
+ "mobile": {
+ "dock": {
+ "open_browser": "打开浏览器面板"
+ }
+ },
+ "dev_browser": {
+ "title": "浏览器",
+ "short_title": "浏览器",
+ "url_label": "本地 URL",
+ "url_placeholder": "localhost:8000",
+ "open": "打开",
+ "loading": "正在打开...",
+ "unsupported": "当前浏览器或连接不支持 Service Worker。请使用 HTTPS 或 localhost 访问 Coder Studio 后再使用内置浏览器。",
+ "error": "无法打开本地预览。",
+ "empty_description": "输入本地开发服务的 loopback 地址,例如 localhost:8000。",
+ "sidebar_description": "通过 Coder Studio 打开本地开发服务,无需直接暴露该服务端口。"
+ }
+}
+```
+
+- [ ] **Step 12: Add CSS**
+
+Append to `packages/web/src/styles/components.css` near workspace/editor surface styles:
+
+```css
+.dev-browser-surface {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ min-height: 0;
+ height: 100%;
+ width: 100%;
+ background: var(--surface-base);
+}
+
+.dev-browser-toolbar {
+ display: flex;
+ align-items: end;
+ gap: var(--sp-3);
+ padding: var(--sp-3);
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--surface-raised);
+}
+
+.dev-browser-toolbar__label {
+ display: grid;
+ gap: var(--sp-1);
+ flex: 1;
+ min-width: 0;
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+}
+
+.dev-browser-toolbar__input {
+ width: 100%;
+ min-height: 34px;
+ padding: 0 var(--sp-3);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--surface-input);
+ color: var(--text-primary);
+}
+
+.dev-browser-toolbar__button {
+ min-height: 34px;
+ padding: 0 var(--sp-4);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--button-primary-bg);
+ color: var(--button-primary-text);
+}
+
+.dev-browser-frame-shell {
+ min-height: 0;
+ height: 100%;
+}
+
+.dev-browser-frame {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: white;
+}
+
+.dev-browser-empty {
+ min-height: 100%;
+}
+```
+
+- [ ] **Step 13: Run workspace integration tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx src/features/dev-browser
+```
+
+Expected: passes.
+
+- [ ] **Step 14: Commit**
+
+```bash
+git add packages/web/src/features/workspace packages/web/src/features/dev-browser packages/web/src/theme/icon-theme.ts packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/styles/components.css
+git commit -m "feat: add dev browser workspace entry points"
+```
+
+---
+
+### Task 8: Development Proxy Config And Docs
+
+**Files:**
+- Modify: `packages/web/vite.config.ts`
+- Modify: `docs/help/mobile-guide.md`
+- Create or modify tests if existing docs validation flags links or headings.
+
+- [ ] **Step 1: Add Vite dev proxy paths**
+
+Modify `packages/web/vite.config.ts` server proxy. Add only `/dev-browser`; do not proxy `/dev-browser-sw.js` because Vite serves `packages/web/public/dev-browser-sw.js` directly during frontend development:
+
+```ts
+ "/dev-browser": {
+ target: backendHttpTarget,
+ },
+```
+
+After this change, verify `http://localhost:5173/dev-browser-sw.js` serves the public service worker file while `http://localhost:5173/dev-browser/session/missing/` proxies to the backend.
+
+- [ ] **Step 2: Add mobile guide documentation**
+
+Add this section to `docs/help/mobile-guide.md` after the remote-access options:
+
+```md
+## 打开本机开发服务
+
+如果你在 Coder Studio 终端里启动了本地开发服务,例如:
+
+ pnpm dev --host 127.0.0.1 --port 8000
+
+可以在 Coder Studio 的内置浏览器中输入:
+
+ localhost:8000
+
+Coder Studio 会通过自身服务转发到本机 loopback 地址,因此手机或外部浏览器不需要直接访问 `localhost:8000`。
+
+v1 支持普通 HTTP 页面、CSS、JS、图片、字体和常规 `fetch` / `XMLHttpRequest` 请求。v1 不支持 WebSocket 和 HMR。如果框架页面依赖 Vite HMR 或应用 WebSocket,页面主体通常仍可加载,但热更新或实时连接会失败。
+```
+
+- [ ] **Step 3: Run focused docs and build checks**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test src/features/dev-browser src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx
+pnpm --filter @coder-studio/server test src/dev-browser src/routes/dev-browser.test.ts src/app-routing.test.ts
+pnpm --filter @coder-studio/web build
+```
+
+Expected: all commands pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/vite.config.ts docs/help/mobile-guide.md
+git commit -m "docs: document dev browser loopback preview"
+```
+
+---
+
+### Task 9: Final Verification
+
+**Files:**
+- No new files.
+- Verify all files changed by prior tasks.
+
+- [ ] **Step 1: Run repository verification**
+
+Run:
+
+```bash
+pnpm ci:verify
+```
+
+Expected: passes.
+
+- [ ] **Step 2: Manual smoke test**
+
+Run a simple local target in one terminal:
+
+```bash
+node -e "require('node:http').createServer((req,res)=>{res.setHeader('content-type','text/html');res.end('Dev Browser Smoke ')}).listen(8000,'127.0.0.1',()=>console.log('http://localhost:8000'))"
+```
+
+Start Coder Studio, open the workspace browser, enter:
+
+```text
+localhost:8000
+```
+
+Expected: the iframe shows `Dev Browser Smoke`.
+
+- [ ] **Step 3: Inspect final diff**
+
+Run:
+
+```bash
+git status --short
+git log --oneline -5
+```
+
+Expected: only intentional feature changes are present, and commits from Tasks 1-8 are visible.
+
+- [ ] **Step 4: Handoff summary**
+
+Report:
+
+```text
+Implemented dev browser loopback HTTP proxy.
+Verification: pnpm ci:verify passed.
+Limitations: WebSocket and HMR are intentionally unsupported in v1.
+Manual smoke: localhost:8000 loaded through the built-in browser.
+```
diff --git a/docs/superpowers/plans/2026-06-12-ui-action-protocol.md b/docs/superpowers/plans/2026-06-12-ui-action-protocol.md
new file mode 100644
index 000000000..2165eb6ae
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-12-ui-action-protocol.md
@@ -0,0 +1,1508 @@
+# UI Action Protocol Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build phase 1 of the UI Action protocol: shared action types, server dispatch/broadcast, frontend execution subscription, and CLI commands, without registering or materializing a built-in skill.
+
+**Architecture:** `packages/core` owns the protocol, descriptors, validation helpers, and workspace-scoped topic. `packages/server` validates incoming `uiAction.dispatch` requests and broadcasts accepted events; `packages/web` subscribes to those events and maps action intents to existing UI state/action hooks; `packages/cli` gives agents a provider-neutral `coder-studio ui ...` bridge. The server accepts and routes, while completion remains frontend-owned and asynchronous.
+
+**Tech Stack:** TypeScript, pnpm workspaces, Vitest, zod, React, Jotai, WebSocket topics.
+
+---
+
+## Scope
+
+Included in this plan:
+
+- UI Action protocol types and descriptors.
+- UI-specific automation permissions exposed through capabilities.
+- Workspace UI action topic.
+- Server commands `uiAction.capabilities` and `uiAction.dispatch`.
+- CLI commands under `coder-studio ui`.
+- Frontend registry and workspace subscription for executing MVP actions.
+
+Explicitly excluded:
+
+- Built-in skill registration or `SKILL.md` materialization.
+- General DOM automation, screenshots, arbitrary browser automation, destructive UI actions.
+- Replacing all Command Palette commands with a global command system.
+- Completion acknowledgements from frontend back to server.
+
+## File Structure
+
+- Create `packages/core/src/domain/ui-actions.ts`: protocol types, action descriptors, URL/path safety helpers, request normalization, validation.
+- Create `packages/core/src/domain/ui-actions.test.ts`: unit coverage for descriptors, workspace routing, path and URL validation, request normalization.
+- Modify `packages/core/src/domain/automation.ts`: add UI permissions to default automation permissions and append UI action capabilities to `listAutomationCapabilities`.
+- Modify `packages/core/src/domain/automation.test.ts`: verify UI capabilities and permission filtering.
+- Modify `packages/core/src/protocol/topics.ts`: add `workspaceUiAction(workspaceId)`.
+- Modify `packages/core/src/protocol/messages.test.ts`: verify topic builder and `UiActionEvent` shape.
+- Modify `packages/core/src/index.ts`: export `domain/ui-actions`.
+- Create `packages/server/src/commands/ui-actions.ts`: zod schema, command handlers, validation, broadcast.
+- Create `packages/server/src/__tests__/ui-actions-commands.test.ts`: dispatch command coverage and broadcast safety failures.
+- Modify `packages/server/src/commands/index.ts`: import `./ui-actions.js` for registration.
+- Modify `packages/server/src/ws/dispatch.ts`: add `uiAction.capabilities` and `uiAction.dispatch` to activation allowlist.
+- Modify `packages/server/src/__tests__/dispatch.test.ts`: verify `uiAction.dispatch` is callable without an active browser lease.
+- Modify `packages/cli/src/parse-args.ts`: parse `coder-studio ui ...` commands and options.
+- Modify `packages/cli/src/cli.ts`: route UI CLI commands through `callCoderStudioCommand`.
+- Modify `packages/cli/src/bin.test.ts`: test parsing and command dispatch for UI CLI.
+- Create `packages/web/src/features/ui-actions/registry.ts`: pure executor registry helpers and command allowlist.
+- Create `packages/web/src/features/ui-actions/registry.test.ts`: pure tests for registry behavior.
+- Create `packages/web/src/features/ui-actions/use-ui-action-subscription.ts`: workspace topic subscription hook and action executor wiring.
+- Create `packages/web/src/features/ui-actions/use-ui-action-subscription.test.tsx`: hook/component tests with mocked WebSocket client and UI atoms/actions.
+- Modify `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`: mount the subscription for the active workspace.
+
+## Task 1: Core UI Action Protocol
+
+**Files:**
+- Create: `packages/core/src/domain/ui-actions.ts`
+- Create: `packages/core/src/domain/ui-actions.test.ts`
+- Modify: `packages/core/src/index.ts`
+
+- [ ] **Step 1: Write failing protocol tests**
+
+Create `packages/core/src/domain/ui-actions.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import {
+ createUiActionEvent,
+ listUiActionCapabilities,
+ normalizeUiActionDispatchRequest,
+ validateUiActionIntent,
+ type UiActionDispatchRequest,
+} from "./ui-actions.js";
+
+describe("ui action domain", () => {
+ it("lists MVP UI action capabilities with CLI examples", () => {
+ const capabilities = listUiActionCapabilities({ permissions: ["ui:navigate", "ui:command"] });
+
+ expect(capabilities.map((capability) => capability.type)).toEqual([
+ "editor.openFile",
+ "browser.openUrl",
+ "workspace.focus",
+ "panel.show",
+ "command.run",
+ ]);
+ expect(capabilities.find((capability) => capability.type === "editor.openFile")).toMatchObject({
+ cli: "coder-studio ui open-file --path ",
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ });
+ });
+
+ it("filters UI action capabilities by permissions", () => {
+ expect(
+ listUiActionCapabilities({ permissions: ["ui:navigate"] }).map((capability) => capability.type)
+ ).toEqual(["editor.openFile", "browser.openUrl", "workspace.focus", "panel.show"]);
+
+ expect(
+ listUiActionCapabilities({ permissions: ["ui:command"] }).map((capability) => capability.type)
+ ).toEqual(["command.run"]);
+ });
+
+ it("rejects unsafe workspace paths", () => {
+ expect(() =>
+ validateUiActionIntent({ type: "editor.openFile", path: "/etc/passwd" })
+ ).toThrow("workspace-relative");
+ expect(() =>
+ validateUiActionIntent({ type: "editor.openFile", path: "../secret.txt" })
+ ).toThrow("workspace-relative");
+ expect(() =>
+ validateUiActionIntent({ type: "editor.openFile", path: "src/app.ts", line: 0 })
+ ).toThrow("positive integer");
+ });
+
+ it("accepts localhost URLs and rejects external URLs", () => {
+ expect(validateUiActionIntent({ type: "browser.openUrl", url: "http://127.0.0.1:5173" })).toEqual({
+ type: "browser.openUrl",
+ url: "http://127.0.0.1:5173/",
+ });
+
+ expect(() =>
+ validateUiActionIntent({ type: "browser.openUrl", url: "https://example.com" })
+ ).toThrow("localhost URLs");
+ });
+
+ it("rejects non-allowlisted command.run ids", () => {
+ expect(validateUiActionIntent({ type: "command.run", commandId: "quickOpen.open" })).toEqual({
+ type: "command.run",
+ commandId: "quickOpen.open",
+ });
+ expect(() =>
+ validateUiActionIntent({ type: "command.run", commandId: "workspace.deleteAll" })
+ ).toThrow("not allowed");
+ });
+
+ it("normalizes requests and creates workspace-scoped events", () => {
+ const request: UiActionDispatchRequest = normalizeUiActionDispatchRequest({
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ });
+
+ expect(request).toEqual({
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ });
+ expect(createUiActionEvent({ request, workspaceId: "ws-1", dispatchedAt: 123 })).toEqual({
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ dispatchedAt: 123,
+ });
+ });
+});
+```
+
+- [ ] **Step 2: Run the core protocol test and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec vitest run src/domain/ui-actions.test.ts
+```
+
+Expected: FAIL because `./ui-actions.js` does not exist.
+
+- [ ] **Step 3: Implement minimal core protocol**
+
+Create `packages/core/src/domain/ui-actions.ts`:
+
+```ts
+import type { AutomationPermission } from "./automation.js";
+import { Topics } from "../protocol/topics.js";
+
+export type UiActionRiskLevel = "read" | "write" | "dangerous";
+export type UiPanelId = "terminal" | "explorer" | "search" | "git" | "skills" | "agentInstructions";
+export type UiCommandId = "quickOpen.open" | "commandPalette.open";
+
+export type UiActionIntent =
+ | {
+ type: "editor.openFile";
+ workspaceId?: string;
+ path: string;
+ line?: number;
+ column?: number;
+ target?: "active" | "newPane" | { paneId: string };
+ }
+ | {
+ type: "browser.openUrl";
+ workspaceId?: string;
+ url: string;
+ target?: "preview" | "external";
+ }
+ | {
+ type: "workspace.focus";
+ workspaceId: string;
+ }
+ | {
+ type: "panel.show";
+ workspaceId?: string;
+ panel: UiPanelId;
+ }
+ | {
+ type: "command.run";
+ commandId: UiCommandId;
+ args?: Record;
+ };
+
+export interface UiActionDescriptor {
+ type: UiActionIntent["type"];
+ cli: string;
+ description: string;
+ inputSchema: Record;
+ permissions: AutomationPermission[];
+ riskLevel: UiActionRiskLevel;
+ available: boolean;
+ examples: string[];
+}
+
+export interface UiActionDispatchRequest {
+ intent: UiActionIntent;
+ source?: {
+ kind: "agent" | "user" | "system";
+ sessionId?: string;
+ providerId?: string;
+ };
+ requestId?: string;
+}
+
+export interface UiActionDispatchResult {
+ accepted: boolean;
+ requestId: string;
+ topic: string;
+}
+
+export interface UiActionEvent {
+ requestId: string;
+ workspaceId: string;
+ intent: UiActionIntent;
+ source?: UiActionDispatchRequest["source"];
+ dispatchedAt: number;
+}
+
+export const ALLOWED_UI_COMMAND_IDS: readonly UiCommandId[] = [
+ "quickOpen.open",
+ "commandPalette.open",
+];
+
+const UI_ACTION_CAPABILITIES: UiActionDescriptor[] = [
+ {
+ type: "editor.openFile",
+ cli: "coder-studio ui open-file --path ",
+ description: "Open a workspace-relative file path in the built-in editor.",
+ inputSchema: {
+ workspaceId: "string optional",
+ path: "workspace-relative string",
+ line: "positive integer optional",
+ column: "positive integer optional",
+ target: "active | newPane | pane id optional",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui open-file --path src/index.ts --line 12 --json"],
+ },
+ {
+ type: "browser.openUrl",
+ cli: "coder-studio ui open-url --url ",
+ description: "Open a localhost URL from the Coder Studio UI.",
+ inputSchema: {
+ workspaceId: "string optional",
+ url: "localhost URL",
+ target: "preview | external optional",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui open-url --url http://127.0.0.1:5173 --json"],
+ },
+ {
+ type: "workspace.focus",
+ cli: "coder-studio ui focus-workspace --workspace ",
+ description: "Focus a known workspace in the Coder Studio UI.",
+ inputSchema: { workspaceId: "string" },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui focus-workspace --workspace ws_123 --json"],
+ },
+ {
+ type: "panel.show",
+ cli: "coder-studio ui show-panel --panel ",
+ description: "Show a common workspace panel.",
+ inputSchema: {
+ workspaceId: "string optional",
+ panel: "terminal | explorer | search | git | skills | agentInstructions",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui show-panel --panel terminal --json"],
+ },
+ {
+ type: "command.run",
+ cli: "coder-studio ui run-command --command ",
+ description: "Run a small allowlist of frontend-only commands.",
+ inputSchema: {
+ commandId: "quickOpen.open | commandPalette.open",
+ args: "object optional",
+ },
+ permissions: ["ui:command"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui run-command --command quickOpen.open --json"],
+ },
+];
+
+export function listUiActionCapabilities(input: {
+ permissions: readonly string[];
+}): UiActionDescriptor[] {
+ const allowed = new Set(input.permissions);
+ return UI_ACTION_CAPABILITIES.filter((capability) =>
+ capability.permissions.every((permission) => allowed.has(permission))
+ );
+}
+
+function assertPositiveInteger(value: number | undefined, field: string): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new Error(`${field} must be a positive integer`);
+ }
+}
+
+export function isSafeWorkspaceRelativePath(path: string): boolean {
+ if (!path || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) {
+ return false;
+ }
+
+ const segments = path.replaceAll("\\", "/").split("/");
+ return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
+}
+
+export function normalizeLocalhostUrl(url: string): string {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw new Error("url must be a valid URL");
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error("url must use http or https");
+ }
+
+ const hostname = parsed.hostname.toLowerCase();
+ const isLocalhost =
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "::1" ||
+ hostname.endsWith(".localhost");
+ if (!isLocalhost) {
+ throw new Error("browser.openUrl only supports localhost URLs");
+ }
+
+ return parsed.toString();
+}
+
+export function validateUiActionIntent(intent: UiActionIntent): UiActionIntent {
+ switch (intent.type) {
+ case "editor.openFile": {
+ if (!isSafeWorkspaceRelativePath(intent.path)) {
+ throw new Error("editor.openFile path must be workspace-relative");
+ }
+ assertPositiveInteger(intent.line, "line");
+ assertPositiveInteger(intent.column, "column");
+ return { ...intent };
+ }
+ case "browser.openUrl":
+ return { ...intent, url: normalizeLocalhostUrl(intent.url) };
+ case "workspace.focus":
+ if (!intent.workspaceId) {
+ throw new Error("workspace.focus requires workspaceId");
+ }
+ return { ...intent };
+ case "panel.show":
+ return { ...intent };
+ case "command.run":
+ if (!ALLOWED_UI_COMMAND_IDS.includes(intent.commandId)) {
+ throw new Error(`UI command is not allowed: ${intent.commandId}`);
+ }
+ return { ...intent };
+ }
+}
+
+export function normalizeUiActionDispatchRequest(
+ request: UiActionDispatchRequest
+): UiActionDispatchRequest {
+ return {
+ ...request,
+ intent: validateUiActionIntent(request.intent),
+ };
+}
+
+export function resolveUiActionWorkspaceId(
+ request: UiActionDispatchRequest,
+ fallbackWorkspaceId?: string
+): string {
+ const workspaceId =
+ "workspaceId" in request.intent ? request.intent.workspaceId : undefined;
+ const resolved = workspaceId ?? fallbackWorkspaceId;
+ if (!resolved) {
+ throw new Error("workspaceId is required for this UI action");
+ }
+ return resolved;
+}
+
+export function createUiActionEvent(input: {
+ request: UiActionDispatchRequest;
+ workspaceId: string;
+ dispatchedAt: number;
+}): UiActionEvent {
+ return {
+ requestId: input.request.requestId ?? crypto.randomUUID(),
+ workspaceId: input.workspaceId,
+ intent: input.request.intent,
+ source: input.request.source,
+ dispatchedAt: input.dispatchedAt,
+ };
+}
+
+export function createUiActionDispatchResult(event: UiActionEvent): UiActionDispatchResult {
+ return {
+ accepted: true,
+ requestId: event.requestId,
+ topic: Topics.workspaceUiAction(event.workspaceId),
+ };
+}
+```
+
+Modify `packages/core/src/index.ts`:
+
+```ts
+export * from "./domain/ui-actions";
+```
+
+- [ ] **Step 4: Run core protocol test and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec vitest run src/domain/ui-actions.test.ts
+```
+
+Expected: PASS.
+
+## Task 2: Automation Capabilities and Topic Export
+
+**Files:**
+- Modify: `packages/core/src/domain/automation.ts`
+- Modify: `packages/core/src/domain/automation.test.ts`
+- Modify: `packages/core/src/protocol/topics.ts`
+- Modify: `packages/core/src/protocol/messages.test.ts`
+
+- [ ] **Step 1: Write failing tests for UI permissions, capabilities, and topic**
+
+Extend `packages/core/src/domain/automation.test.ts`:
+
+```ts
+ it("includes low-risk UI action permissions in the default agent permissions", () => {
+ expect(DEFAULT_AGENT_AUTOMATION_PERMISSIONS).toEqual(
+ expect.arrayContaining(["ui:read", "ui:navigate", "ui:command"])
+ );
+ });
+
+ it("lists UI action capabilities through automation capabilities", () => {
+ const capabilities = listAutomationCapabilities({
+ permissions: DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ });
+
+ expect(capabilities).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "ui.editor.openFile",
+ cli: "coder-studio ui open-file --path ",
+ permissions: ["ui:navigate"],
+ }),
+ expect.objectContaining({
+ name: "ui.command.run",
+ cli: "coder-studio ui run-command --command ",
+ permissions: ["ui:command"],
+ }),
+ ])
+ );
+ });
+```
+
+Extend `packages/core/src/protocol/messages.test.ts`:
+
+```ts
+describe("ui action protocol events", () => {
+ it("exports a workspace UI action topic builder", () => {
+ expect(Topics.workspaceUiAction("ws-1")).toBe("workspace.ws-1.ui.action");
+ });
+});
+```
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec vitest run src/domain/automation.test.ts src/protocol/messages.test.ts
+```
+
+Expected: FAIL because UI permissions/capabilities and topic are missing.
+
+- [ ] **Step 3: Add permissions, bridge capabilities, and topic**
+
+Modify `packages/core/src/domain/automation.ts`:
+
+```ts
+import { listUiActionCapabilities } from "./ui-actions.js";
+
+export const DEFAULT_AGENT_AUTOMATION_PERMISSIONS = [
+ "workspace:read",
+ "session:read",
+ "terminal:read",
+ "git:read",
+ "ui:read",
+ "ui:navigate",
+ "ui:command",
+] as const;
+```
+
+Append UI descriptors in `listAutomationCapabilities`:
+
+```ts
+const uiCapabilities: AutomationCapability[] = listUiActionCapabilities({
+ permissions: input.permissions,
+}).map((capability) => ({
+ name: `ui.${capability.type}`,
+ cli: capability.cli,
+ description: capability.description,
+ inputSchema: capability.inputSchema,
+ output: "Accepted dispatch metadata as JSON. The frontend executes UI actions asynchronously.",
+ permissions: capability.permissions,
+ riskLevel: capability.riskLevel,
+ examples: capability.examples,
+ available: capability.available,
+}));
+
+return [...MVP_CAPABILITIES, ...uiCapabilities].filter((capability) =>
+ capability.permissions.every((permission) => allowed.has(permission))
+);
+```
+
+Modify `packages/core/src/protocol/topics.ts`:
+
+```ts
+workspaceUiAction: (workspaceId: string) => `workspace.${workspaceId}.ui.action`,
+```
+
+- [ ] **Step 4: Run tests and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec vitest run src/domain/ui-actions.test.ts src/domain/automation.test.ts src/protocol/messages.test.ts
+```
+
+Expected: PASS.
+
+## Task 3: Server UI Action Commands
+
+**Files:**
+- Create: `packages/server/src/commands/ui-actions.ts`
+- Create: `packages/server/src/__tests__/ui-actions-commands.test.ts`
+- Modify: `packages/server/src/commands/index.ts`
+
+- [ ] **Step 1: Write failing server command tests**
+
+Create `packages/server/src/__tests__/ui-actions-commands.test.ts`:
+
+```ts
+import { Topics } from "@coder-studio/core";
+import { describe, expect, it, vi } from "vitest";
+import type { CommandContext } from "../ws/dispatch.js";
+import { dispatch } from "../ws/dispatch.js";
+import "../commands/ui-actions.js";
+
+function createContext(overrides: Partial = {}): CommandContext {
+ return {
+ workspaceMgr: {} as never,
+ sessionMgr: {} as never,
+ terminalMgr: {} as never,
+ taskMgr: {} as never,
+ eventBus: {} as never,
+ broadcaster: {
+ publish: vi.fn(),
+ } as never,
+ settingsRepo: {} as never,
+ providerConfigRepo: {} as never,
+ providerRegistry: [],
+ fencingMgr: {} as never,
+ supervisorMgr: {} as never,
+ autoFetch: {} as never,
+ activationMgr: { getLease: () => undefined } as never,
+ lspMgr: {} as never,
+ ...overrides,
+ } as CommandContext;
+}
+
+describe("ui action commands", () => {
+ it("returns UI action capabilities", async () => {
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-capabilities-1",
+ op: "uiAction.capabilities",
+ args: { permissions: ["ui:navigate"] },
+ },
+ createContext()
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toMatchObject({
+ version: 1,
+ actions: [expect.objectContaining({ type: "editor.openFile" })],
+ });
+ });
+
+ it("validates, broadcasts, and returns accepted dispatch metadata", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1234);
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-1",
+ op: "uiAction.dispatch",
+ args: {
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual({
+ accepted: true,
+ requestId: "req-1",
+ topic: Topics.workspaceUiAction("ws-1"),
+ });
+ expect(ctx.broadcaster.publish).toHaveBeenCalledWith(Topics.workspaceUiAction("ws-1"), {
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ dispatchedAt: 1234,
+ });
+ });
+
+ it("uses fallback workspaceId when the intent does not include one", async () => {
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-2",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-fallback",
+ intent: { type: "panel.show", panel: "terminal" },
+ requestId: "req-2",
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toMatchObject({ topic: Topics.workspaceUiAction("ws-fallback") });
+ });
+
+ it("rejects unsafe UI action intents before broadcasting", async () => {
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-unsafe-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: { type: "browser.openUrl", url: "https://example.com" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("internal_error");
+ expect(ctx.broadcaster.publish).not.toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run server command test and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server exec vitest run src/__tests__/ui-actions-commands.test.ts
+```
+
+Expected: FAIL because `../commands/ui-actions.js` does not exist.
+
+- [ ] **Step 3: Implement server commands**
+
+Create `packages/server/src/commands/ui-actions.ts`:
+
+```ts
+import {
+ createUiActionDispatchResult,
+ createUiActionEvent,
+ DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ listUiActionCapabilities,
+ normalizeUiActionDispatchRequest,
+ resolveUiActionWorkspaceId,
+ Topics,
+} from "@coder-studio/core";
+import { z } from "zod";
+import { registerCommand } from "../ws/dispatch.js";
+
+const uiActionIntentSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("editor.openFile"),
+ workspaceId: z.string().optional(),
+ path: z.string(),
+ line: z.number().int().optional(),
+ column: z.number().int().optional(),
+ target: z.union([z.literal("active"), z.literal("newPane"), z.object({ paneId: z.string() })]).optional(),
+ }),
+ z.object({
+ type: z.literal("browser.openUrl"),
+ workspaceId: z.string().optional(),
+ url: z.string(),
+ target: z.union([z.literal("preview"), z.literal("external")]).optional(),
+ }),
+ z.object({
+ type: z.literal("workspace.focus"),
+ workspaceId: z.string(),
+ }),
+ z.object({
+ type: z.literal("panel.show"),
+ workspaceId: z.string().optional(),
+ panel: z.enum(["terminal", "explorer", "search", "git", "skills", "agentInstructions"]),
+ }),
+ z.object({
+ type: z.literal("command.run"),
+ commandId: z.enum(["quickOpen.open", "commandPalette.open"]),
+ args: z.record(z.string(), z.unknown()).optional(),
+ }),
+]);
+
+const uiActionDispatchSchema = z.object({
+ workspaceId: z.string().optional(),
+ intent: uiActionIntentSchema,
+ requestId: z.string().optional(),
+ source: z
+ .object({
+ kind: z.enum(["agent", "user", "system"]),
+ sessionId: z.string().optional(),
+ providerId: z.string().optional(),
+ })
+ .optional(),
+});
+
+registerCommand(
+ "uiAction.capabilities",
+ z.object({
+ permissions: z.array(z.string()).optional(),
+ }),
+ async (args) => ({
+ version: 1,
+ actions: listUiActionCapabilities({
+ permissions: args.permissions ?? DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ }),
+ })
+);
+
+registerCommand("uiAction.dispatch", uiActionDispatchSchema, async (args, ctx) => {
+ const request = normalizeUiActionDispatchRequest({
+ intent: args.intent,
+ requestId: args.requestId,
+ source: args.source,
+ });
+ const workspaceId = resolveUiActionWorkspaceId(request, args.workspaceId);
+ const event = createUiActionEvent({
+ request,
+ workspaceId,
+ dispatchedAt: Date.now(),
+ });
+ const topic = Topics.workspaceUiAction(workspaceId);
+
+ ctx.broadcaster.publish(topic, event);
+
+ return createUiActionDispatchResult(event);
+});
+```
+
+Modify `packages/server/src/commands/index.ts`:
+
+```ts
+import "./ui-actions.js";
+```
+
+- [ ] **Step 4: Run server command test and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server exec vitest run src/__tests__/ui-actions-commands.test.ts
+```
+
+Expected: PASS.
+
+## Task 4: Server Activation Allowlist
+
+**Files:**
+- Modify: `packages/server/src/ws/dispatch.ts`
+- Modify: `packages/server/src/__tests__/dispatch.test.ts`
+
+- [ ] **Step 1: Write failing allowlist test**
+
+Extend `packages/server/src/__tests__/dispatch.test.ts`:
+
+```ts
+ it("allows UI action dispatch from command clients without an active browser lease", async () => {
+ const publish = vi.fn();
+ ctx = {
+ ...ctx,
+ broadcaster: {
+ publish,
+ getRequestMetadata: () => ({ url: "/ws" }),
+ } as never,
+ activationMgr: { getLease: () => undefined } as never,
+ };
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-action-allowlist-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ requestId: "req-1",
+ intent: { type: "panel.show", panel: "terminal" },
+ },
+ },
+ ctx,
+ "cli-client"
+ );
+
+ expect(result.ok).toBe(true);
+ expect(publish).toHaveBeenCalled();
+ });
+```
+
+Also import `../commands/ui-actions.js` in the test file.
+
+- [ ] **Step 2: Run dispatch test and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server exec vitest run src/__tests__/dispatch.test.ts
+```
+
+Expected: FAIL with `activation_required` for `uiAction.dispatch`.
+
+- [ ] **Step 3: Add UI action commands to activation allowlist**
+
+Modify `packages/server/src/ws/dispatch.ts`:
+
+```ts
+ "uiAction.capabilities",
+ "uiAction.dispatch",
+```
+
+- [ ] **Step 4: Run dispatch test and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server exec vitest run src/__tests__/dispatch.test.ts src/__tests__/ui-actions-commands.test.ts
+```
+
+Expected: PASS.
+
+## Task 5: CLI UI Commands
+
+**Files:**
+- Modify: `packages/cli/src/parse-args.ts`
+- Modify: `packages/cli/src/cli.ts`
+- Modify: `packages/cli/src/bin.test.ts`
+
+- [ ] **Step 1: Write failing CLI tests**
+
+Extend `packages/cli/src/bin.test.ts`:
+
+```ts
+ it("prints UI open-file dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-1",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "ui",
+ "open-file",
+ "--workspace",
+ "ws-1",
+ "--path",
+ "src/index.ts",
+ "--line",
+ "12",
+ "--column",
+ "3",
+ "--json",
+ ]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "editor.openFile",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ line: 12,
+ column: 3,
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-1",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+
+ it("prints UI open-url dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-2",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main(["ui", "open-url", "--workspace", "ws-1", "--url", "http://127.0.0.1:5173", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "browser.openUrl",
+ workspaceId: "ws-1",
+ url: "http://127.0.0.1:5173",
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-2",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+```
+
+Add parse tests:
+
+```ts
+ it("parses UI open-file command", () => {
+ expect(
+ parseArgs([
+ "ui",
+ "open-file",
+ "--workspace",
+ "ws-1",
+ "--path",
+ "src/index.ts",
+ "--line",
+ "12",
+ "--column",
+ "3",
+ "--json",
+ ])
+ ).toEqual({
+ command: "ui",
+ uiCommand: "open-file",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ line: 12,
+ column: 3,
+ json: true,
+ });
+ });
+
+ it("parses UI show-panel and run-command commands", () => {
+ expect(parseArgs(["ui", "show-panel", "--panel", "terminal"])).toEqual({
+ command: "ui",
+ uiCommand: "show-panel",
+ panel: "terminal",
+ });
+
+ expect(parseArgs(["ui", "run-command", "--command", "quickOpen.open"])).toEqual({
+ command: "ui",
+ uiCommand: "run-command",
+ uiCommandId: "quickOpen.open",
+ });
+ });
+```
+
+- [ ] **Step 2: Run CLI tests and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @spencer-kit/coder-studio exec vitest run src/bin.test.ts
+```
+
+Expected: FAIL because `ui` command and options are unknown.
+
+- [ ] **Step 3: Implement CLI parser and dispatch routing**
+
+Modify `packages/cli/src/parse-args.ts`:
+
+```ts
+type CliCommand = ... | "ui";
+type UiCommand = "open-file" | "open-url" | "show-panel" | "focus-workspace" | "run-command";
+
+export interface CliArgs {
+ uiCommand?: UiCommand;
+ url?: string;
+ panel?: string;
+ uiCommandId?: string;
+ line?: number;
+ column?: number;
+}
+```
+
+Update command clearing so automation args include `ui`. Accept:
+
+- `ui` as top-level command.
+- `open-file`, `open-url`, `show-panel`, `focus-workspace`, `run-command` as UI subcommands.
+- `--workspace` for UI commands.
+- `--path` only for `ui open-file`.
+- `--url` only for `ui open-url`.
+- `--panel` only for `ui show-panel`.
+- `--command` only for `ui run-command`.
+- `--line` and `--column` as positive integers only for `ui open-file`.
+- `--api-url` and `--json` for `ui`.
+
+Modify `packages/cli/src/cli.ts`:
+
+```ts
+ if (args.command === "ui") {
+ const intent =
+ args.uiCommand === "open-file"
+ ? {
+ type: "editor.openFile" as const,
+ ...(args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {}),
+ path: args.path!,
+ ...(args.line !== undefined ? { line: args.line } : {}),
+ ...(args.column !== undefined ? { column: args.column } : {}),
+ }
+ : args.uiCommand === "open-url"
+ ? {
+ type: "browser.openUrl" as const,
+ ...(args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {}),
+ url: args.url!,
+ }
+ : args.uiCommand === "show-panel"
+ ? {
+ type: "panel.show" as const,
+ ...(args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {}),
+ panel: args.panel!,
+ }
+ : args.uiCommand === "focus-workspace"
+ ? { type: "workspace.focus" as const, workspaceId: args.workspaceId! }
+ : { type: "command.run" as const, commandId: args.uiCommandId! };
+
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "uiAction.dispatch",
+ args: {
+ ...(args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {}),
+ intent,
+ source: { kind: "agent" },
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+```
+
+- [ ] **Step 4: Run CLI tests and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @spencer-kit/coder-studio exec vitest run src/bin.test.ts
+```
+
+Expected: PASS.
+
+## Task 6: Frontend UI Action Registry
+
+**Files:**
+- Create: `packages/web/src/features/ui-actions/registry.ts`
+- Create: `packages/web/src/features/ui-actions/registry.test.ts`
+
+- [ ] **Step 1: Write failing registry tests**
+
+Create `packages/web/src/features/ui-actions/registry.test.ts`:
+
+```ts
+import type { UiActionEvent } from "@coder-studio/core";
+import { describe, expect, it, vi } from "vitest";
+import { createUiActionRegistry, isAllowedFrontendUiCommand } from "./registry";
+
+describe("ui action registry", () => {
+ it("routes events to the registered executor by intent type", async () => {
+ const run = vi.fn().mockResolvedValue(undefined);
+ const registry = createUiActionRegistry();
+ registry.register("panel.show", run);
+
+ const event: UiActionEvent = {
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "panel.show", panel: "terminal" },
+ dispatchedAt: 1,
+ };
+
+ await registry.execute(event);
+
+ expect(run).toHaveBeenCalledWith(event);
+ });
+
+ it("throws when no executor is registered", async () => {
+ const registry = createUiActionRegistry();
+
+ await expect(
+ registry.execute({
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "panel.show", panel: "terminal" },
+ dispatchedAt: 1,
+ })
+ ).rejects.toThrow("No UI action executor registered");
+ });
+
+ it("keeps the frontend command allowlist explicit", () => {
+ expect(isAllowedFrontendUiCommand("quickOpen.open")).toBe(true);
+ expect(isAllowedFrontendUiCommand("workspace.deleteAll")).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run registry test and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web exec vitest run src/features/ui-actions/registry.test.ts
+```
+
+Expected: FAIL because `./registry` does not exist.
+
+- [ ] **Step 3: Implement registry**
+
+Create `packages/web/src/features/ui-actions/registry.ts`:
+
+```ts
+import type { UiActionEvent, UiActionIntent } from "@coder-studio/core";
+
+export type UiActionExecutor = (event: UiActionEvent) => Promise | void;
+
+export interface UiActionRegistry {
+ execute(event: UiActionEvent): Promise;
+ register(type: UiActionIntent["type"], executor: UiActionExecutor): () => void;
+}
+
+const ALLOWED_FRONTEND_COMMANDS = new Set(["quickOpen.open", "commandPalette.open"]);
+
+export function isAllowedFrontendUiCommand(commandId: string): boolean {
+ return ALLOWED_FRONTEND_COMMANDS.has(commandId);
+}
+
+export function createUiActionRegistry(): UiActionRegistry {
+ const executors = new Map();
+
+ return {
+ register(type, executor) {
+ executors.set(type, executor);
+ return () => {
+ if (executors.get(type) === executor) {
+ executors.delete(type);
+ }
+ };
+ },
+ async execute(event) {
+ const executor = executors.get(event.intent.type);
+ if (!executor) {
+ throw new Error(`No UI action executor registered for ${event.intent.type}`);
+ }
+ await executor(event);
+ },
+ };
+}
+```
+
+- [ ] **Step 4: Run registry test and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web exec vitest run src/features/ui-actions/registry.test.ts
+```
+
+Expected: PASS.
+
+## Task 7: Frontend Subscription and Executors
+
+**Files:**
+- Create: `packages/web/src/features/ui-actions/use-ui-action-subscription.ts`
+- Create: `packages/web/src/features/ui-actions/use-ui-action-subscription.test.tsx`
+- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`
+
+- [ ] **Step 1: Write failing frontend subscription tests**
+
+Create `packages/web/src/features/ui-actions/use-ui-action-subscription.test.tsx` with tests that mount a small harness under a Jotai provider, set a fake `wsClientAtom.subscribe`, emit `Topics.workspaceUiAction("ws-1")` payloads, and assert:
+
+```ts
+expect(subscribe).toHaveBeenCalledWith([Topics.workspaceUiAction("ws-1")], expect.any(Function));
+```
+
+For execution behavior, assert:
+
+- `panel.show` with `terminal` sets `terminalPanelVisibleAtomFamily("ws-1")` to `true`.
+- `panel.show` with `git` sets `desktopSidebarViewAtomFamily("ws-1")` to `"source-control"` and `sidebarCollapsedAtomFamily("ws-1")` to `false`.
+- `command.run` with `quickOpen.open` sets `quickOpenOpenAtom` to `true`.
+- `command.run` with `commandPalette.open` sets `commandPaletteOpenAtom` to `true`.
+- invalid payloads do not throw and push an error toast through `pushToastAtom`.
+
+Use this harness shape:
+
+```tsx
+function Harness({ workspaceId }: { workspaceId: string }) {
+ useUiActionSubscription(workspaceId);
+ return null;
+}
+```
+
+- [ ] **Step 2: Run subscription tests and verify RED**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web exec vitest run src/features/ui-actions/use-ui-action-subscription.test.tsx
+```
+
+Expected: FAIL because the subscription hook does not exist.
+
+- [ ] **Step 3: Implement subscription and executors**
+
+Create `packages/web/src/features/ui-actions/use-ui-action-subscription.ts`:
+
+```ts
+import { type UiActionEvent, Topics } from "@coder-studio/core";
+import { useAtomValue, useSetAtom } from "jotai";
+import { useEffect, useMemo } from "react";
+import { commandPaletteOpenAtom, quickOpenOpenAtom } from "../../atoms/app-ui";
+import { wsClientAtom } from "../../atoms/connection";
+import { pushToastAtom } from "../notifications/atoms";
+import { useSelectWorkspaceTarget } from "../workspace/actions/use-select-workspace-target";
+import { useOpenWorkspaceFile } from "../workspace/actions/use-open-workspace-file";
+import {
+ desktopSidebarViewAtomFamily,
+ sidebarCollapsedAtomFamily,
+ terminalPanelVisibleAtomFamily,
+} from "../workspace/atoms";
+import { createUiActionRegistry, isAllowedFrontendUiCommand } from "./registry";
+
+function isUiActionEvent(value: unknown): value is UiActionEvent {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as UiActionEvent).requestId === "string" &&
+ typeof (value as UiActionEvent).workspaceId === "string" &&
+ typeof (value as UiActionEvent).dispatchedAt === "number" &&
+ typeof (value as UiActionEvent).intent === "object" &&
+ (value as UiActionEvent).intent !== null &&
+ typeof (value as UiActionEvent).intent.type === "string"
+ );
+}
+
+export function useUiActionSubscription(workspaceId: string): void {
+ const wsClient = useAtomValue(wsClientAtom);
+ const setQuickOpenOpen = useSetAtom(quickOpenOpenAtom);
+ const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
+ const setTerminalVisible = useSetAtom(terminalPanelVisibleAtomFamily(workspaceId));
+ const setDesktopSidebarView = useSetAtom(desktopSidebarViewAtomFamily(workspaceId));
+ const setSidebarCollapsed = useSetAtom(sidebarCollapsedAtomFamily(workspaceId));
+ const pushToast = useSetAtom(pushToastAtom);
+ const selectWorkspaceTarget = useSelectWorkspaceTarget();
+ const { openWorkspaceFile } = useOpenWorkspaceFile(workspaceId);
+
+ const registry = useMemo(() => {
+ const nextRegistry = createUiActionRegistry();
+
+ nextRegistry.register("editor.openFile", async (event) => {
+ if (event.intent.type !== "editor.openFile") return;
+ await openWorkspaceFile({
+ workspaceId: event.intent.workspaceId ?? event.workspaceId,
+ path: event.intent.path,
+ line: event.intent.line,
+ column: event.intent.column,
+ source: "ui-action",
+ });
+ });
+
+ nextRegistry.register("browser.openUrl", (event) => {
+ if (event.intent.type !== "browser.openUrl") return;
+ window.open(event.intent.url, event.intent.target === "external" ? "_blank" : "_blank", "noopener,noreferrer");
+ });
+
+ nextRegistry.register("workspace.focus", async (event) => {
+ if (event.intent.type !== "workspace.focus") return;
+ await selectWorkspaceTarget(event.intent.workspaceId);
+ });
+
+ nextRegistry.register("panel.show", (event) => {
+ if (event.intent.type !== "panel.show") return;
+ if (event.intent.panel === "terminal") {
+ setTerminalVisible(true);
+ return;
+ }
+
+ const panelMap = {
+ explorer: "explorer",
+ search: "search",
+ git: "source-control",
+ skills: "skills",
+ agentInstructions: "agent-instructions",
+ } as const;
+ setDesktopSidebarView(panelMap[event.intent.panel]);
+ setSidebarCollapsed(false);
+ });
+
+ nextRegistry.register("command.run", (event) => {
+ if (event.intent.type !== "command.run") return;
+ if (!isAllowedFrontendUiCommand(event.intent.commandId)) {
+ throw new Error(`Frontend UI command is not allowed: ${event.intent.commandId}`);
+ }
+ if (event.intent.commandId === "quickOpen.open") {
+ setQuickOpenOpen(true);
+ } else if (event.intent.commandId === "commandPalette.open") {
+ setCommandPaletteOpen(true);
+ }
+ });
+
+ return nextRegistry;
+ }, [
+ openWorkspaceFile,
+ pushToast,
+ selectWorkspaceTarget,
+ setCommandPaletteOpen,
+ setDesktopSidebarView,
+ setQuickOpenOpen,
+ setSidebarCollapsed,
+ setTerminalVisible,
+ ]);
+
+ useEffect(() => {
+ if (!wsClient) {
+ return;
+ }
+
+ return wsClient.subscribe([Topics.workspaceUiAction(workspaceId)], (_topic, payload) => {
+ if (!isUiActionEvent(payload)) {
+ pushToast({
+ kind: "error",
+ title: "UI action failed",
+ body: "Received an invalid UI action event.",
+ });
+ return;
+ }
+
+ void registry.execute(payload).catch((error) => {
+ pushToast({
+ kind: "error",
+ title: "UI action failed",
+ body: error instanceof Error ? error.message : "Unable to execute UI action.",
+ });
+ });
+ });
+ }, [pushToast, registry, workspaceId, wsClient]);
+}
+```
+
+Modify `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`:
+
+```ts
+import { useUiActionSubscription } from "../../../ui-actions/use-ui-action-subscription";
+```
+
+Call inside `WorkspaceDesktopView` after resolving `workspace`:
+
+```ts
+useUiActionSubscription(workspace.id);
+```
+
+- [ ] **Step 4: Run frontend UI action tests and verify GREEN**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web exec vitest run src/features/ui-actions/registry.test.ts src/features/ui-actions/use-ui-action-subscription.test.tsx
+```
+
+Expected: PASS.
+
+## Task 8: Package Typecheck and Focused Verification
+
+**Files:**
+- All files touched above.
+
+- [ ] **Step 1: Run focused package tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec vitest run src/domain/ui-actions.test.ts src/domain/automation.test.ts src/protocol/messages.test.ts
+pnpm --filter @coder-studio/server exec vitest run src/__tests__/ui-actions-commands.test.ts src/__tests__/dispatch.test.ts
+pnpm --filter @spencer-kit/coder-studio exec vitest run src/bin.test.ts
+pnpm --filter @coder-studio/web exec vitest run src/features/ui-actions/registry.test.ts src/features/ui-actions/use-ui-action-subscription.test.tsx
+```
+
+Expected: all PASS.
+
+- [ ] **Step 2: Run typecheck**
+
+Run:
+
+```bash
+pnpm ci:typecheck
+```
+
+Expected: exit 0.
+
+- [ ] **Step 3: Run repository verification**
+
+Run:
+
+```bash
+pnpm ci:verify
+```
+
+Expected: exit 0. If this is too slow or exposes unrelated pre-existing failures, capture exact command output and run the narrower package checks from Step 1 plus `pnpm ci:typecheck`.
+
+- [ ] **Step 4: Review scope**
+
+Run:
+
+```bash
+git diff --stat
+git diff -- packages/server/src/skills packages/server/src/agent-instructions
+```
+
+Expected:
+
+- Diff stat only includes core/server/web/cli plus this plan.
+- No changes under `packages/server/src/skills` or `packages/server/src/agent-instructions`.
+
+## Self-Review
+
+- Spec coverage: The plan covers shared protocol, topic, server command/broadcast, frontend executor subscription, CLI bridge, safety boundaries, and capabilities. It explicitly excludes built-in skill registration for this phase.
+- Placeholder scan: No implementation step contains TBD/TODO/fill-in placeholders. The only intentionally flexible part is the frontend hook test detail, but it lists concrete assertions and harness shape.
+- Type consistency: The shared action names are consistently `editor.openFile`, `browser.openUrl`, `workspace.focus`, `panel.show`, and `command.run`; server operation names are consistently `uiAction.capabilities` and `uiAction.dispatch`; CLI subcommands consistently map to those intents.
diff --git a/docs/superpowers/plans/2026-06-12-workspace-memory-system.md b/docs/superpowers/plans/2026-06-12-workspace-memory-system.md
new file mode 100644
index 000000000..7fa8a3bf1
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-12-workspace-memory-system.md
@@ -0,0 +1,394 @@
+# Workspace Memory System Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build project-scoped structured memory that users can manage in the desktop side panel and agents can read/write on demand through a built-in skill and CLI commands.
+
+**Architecture:** Core owns memory contracts and automation capabilities. Server owns a per-workspace JSON repository, command handlers, and broadcasts. CLI exposes the command family for agents, the built-in Memory Skill teaches the agent when to use it, and web renders a flat workbench-native Memory side panel.
+
+**Tech Stack:** TypeScript, pnpm monorepo, Vitest, React 19, Jotai-free local state for panel drafts, existing WebSocket command dispatch, existing JSON atomic storage helpers.
+
+---
+
+## File Structure
+
+- Create `packages/core/src/domain/memory.ts`: shared memory constants, types, validation helpers, and source normalization.
+- Modify `packages/core/src/index.ts`: export memory domain.
+- Modify `packages/core/src/domain/automation.ts`: add `memory:read` and `memory:write` automation permissions and memory capabilities.
+- Create `packages/core/src/domain/memory.test.ts`: type/validator tests.
+- Modify `packages/core/src/domain/automation.test.ts`: capability and default-permission tests.
+- Create `packages/server/src/storage/repositories/memory-repo.ts`: per-workspace JSON storage and search/filter behavior.
+- Create `packages/server/src/storage/repositories/memory-repo.test.ts`: repository persistence, validation, soft-delete, and search tests.
+- Create `packages/server/src/commands/memory.ts`: `memory.list/get/create/update/delete/search` command handlers.
+- Create `packages/server/src/commands/memory.test.ts`: command validation, workspace validation, and broadcast tests.
+- Modify `packages/server/src/commands/index.ts`: register memory commands.
+- Modify `packages/server/src/ws/dispatch.ts`: add `memoryRepo` to `CommandContext`.
+- Modify `packages/server/src/server.ts`: instantiate `MemoryRepo`.
+- Modify `packages/cli/src/parse-args.ts`: parse `coder-studio memory ...`.
+- Modify `packages/cli/src/parse-args.test.ts`: parser tests for memory subcommands.
+- Modify `packages/cli/src/cli.ts`: dispatch memory CLI commands.
+- Create or modify `packages/cli/src/cli.test.ts`: command mapping tests if existing CLI tests provide a command-runner pattern; otherwise keep CLI mapping covered through parser plus focused helper tests.
+- Modify `packages/server/src/skills/builtin/registry.ts`: register `coder-studio-memory`.
+- Modify built-in skill tests under `packages/server/src/skills/builtin/*.test.ts`: assert materialization and auto-mount behavior.
+- Modify `packages/web/src/features/workspace/atoms/layout.ts`: add `memory` sidebar view.
+- Modify `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`: add Memory activity item.
+- Modify `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`: render `MemoryPanel`.
+- Create `packages/web/src/features/workspace/actions/use-memory-panel.ts`: command-facing hook for list/create/update/delete/search refresh.
+- Create `packages/web/src/features/workspace/views/shared/memory-panel.tsx`: flat list + detail editor UI.
+- Create `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx`: UI behavior tests.
+- Modify `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: Memory labels.
+- Modify icon/theme files only if `nav.memory` semantic cannot reuse an existing semantic.
+- Modify `packages/web/src/styles/components.css`: tokenized flat panel styles if existing classes are insufficient.
+
+## Tasks
+
+### Task 1: Core Memory Domain And Automation Capabilities
+
+**Files:**
+- Create: `packages/core/src/domain/memory.ts`
+- Create: `packages/core/src/domain/memory.test.ts`
+- Modify: `packages/core/src/index.ts`
+- Modify: `packages/core/src/domain/automation.ts`
+- Modify: `packages/core/src/domain/automation.test.ts`
+
+- [ ] **Step 1: Write failing core tests**
+
+Add tests that prove supported memory types are exported, title/content/tags are normalized and rejected when invalid, source defaults can be derived, and automation capabilities include `memory.list`, `memory.search`, `memory.get`, `memory.add`, `memory.update`, and `memory.delete` when memory permissions are present.
+
+- [ ] **Step 2: Run core tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core test -- memory.test.ts automation.test.ts
+```
+
+Expected: FAIL because `domain/memory.ts` and memory automation capabilities do not exist yet.
+
+- [ ] **Step 3: Implement core memory domain and automation updates**
+
+Implement:
+
+- `WORKSPACE_MEMORY_TYPES`
+- `WORKSPACE_MEMORY_SOURCE_KINDS`
+- `normalizeWorkspaceMemoryTags(tags: readonly string[]): string[]`
+- `validateWorkspaceMemoryInput(input): { type; title; content; tags }`
+- `resolveWorkspaceMemorySource(input): WorkspaceMemorySource`
+- exports from `packages/core/src/index.ts`
+- memory permissions in `DEFAULT_AGENT_AUTOMATION_PERMISSIONS`
+- memory capabilities in `domain/automation.ts`
+
+- [ ] **Step 4: Run core tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core test -- memory.test.ts automation.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/domain/memory.ts packages/core/src/domain/memory.test.ts packages/core/src/index.ts packages/core/src/domain/automation.ts packages/core/src/domain/automation.test.ts
+git commit -m "feat(core): add workspace memory domain"
+```
+
+### Task 2: Server Memory Repository
+
+**Files:**
+- Create: `packages/server/src/storage/repositories/memory-repo.ts`
+- Create: `packages/server/src/storage/repositories/memory-repo.test.ts`
+
+- [ ] **Step 1: Write failing repository tests**
+
+Cover empty missing-file reads, encoded workspace filenames, create persistence, update persistence, soft delete hidden by default, include archived, and case-insensitive search across title/content/tags/type.
+
+- [ ] **Step 2: Run repository tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- memory-repo.test.ts
+```
+
+Expected: FAIL because `MemoryRepo` does not exist.
+
+- [ ] **Step 3: Implement `MemoryRepo`**
+
+Use `readJsonFile` and `writeJsonFileAtomic`. Store files at `/.json`. Use `validateWorkspaceMemoryInput` and `resolveWorkspaceMemorySource` from core. Sort visible entries by `updatedAt` descending. Generate ids with `mem_${Date.now()}_${randomBytes(4).toString("hex")}`.
+
+- [ ] **Step 4: Run repository tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- memory-repo.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/storage/repositories/memory-repo.ts packages/server/src/storage/repositories/memory-repo.test.ts
+git commit -m "feat(server): add workspace memory repository"
+```
+
+### Task 3: Server Memory Commands And Wiring
+
+**Files:**
+- Create: `packages/server/src/commands/memory.ts`
+- Create: `packages/server/src/commands/memory.test.ts`
+- Modify: `packages/server/src/commands/index.ts`
+- Modify: `packages/server/src/ws/dispatch.ts`
+- Modify: `packages/server/src/server.ts`
+
+- [ ] **Step 1: Write failing command tests**
+
+Cover workspace validation, `memory.create`, `memory.list`, `memory.search`, `memory.get`, `memory.update`, `memory.delete`, invalid type/title/content/tag errors, `memoryRepo` missing error, and `workspace..memory.changed` broadcasts after writes.
+
+- [ ] **Step 2: Run command tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- memory.test.ts
+```
+
+Expected: FAIL because memory command handlers are not registered.
+
+- [ ] **Step 3: Implement command handlers and server wiring**
+
+Register all memory commands with zod schemas. Add `memoryRepo?: MemoryRepo` to `CommandContext`. Throw `memory_storage_unavailable` if missing. Instantiate `new MemoryRepo({ rootDir: join(stateRoot, "state", "memory", "workspaces") })` in `server.ts`. Import `./memory.js` from command index.
+
+- [ ] **Step 4: Run command tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- memory.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/commands/memory.ts packages/server/src/commands/memory.test.ts packages/server/src/commands/index.ts packages/server/src/ws/dispatch.ts packages/server/src/server.ts
+git commit -m "feat(server): add workspace memory commands"
+```
+
+### Task 4: CLI Memory Commands
+
+**Files:**
+- Modify: `packages/cli/src/parse-args.ts`
+- Modify: `packages/cli/src/parse-args.test.ts`
+- Modify: `packages/cli/src/cli.ts`
+- Modify or create: `packages/cli/src/cli.test.ts`
+
+- [ ] **Step 1: Write failing CLI tests**
+
+Cover parsing and command dispatch for `memory list/get/search/add/update/delete`, repeated `--tag`, `--type`, `--title`, `--content`, `--skill`, `--workspace`, workspace fallback from `CODER_STUDIO_WORKSPACE_ID`, and `--json`.
+
+- [ ] **Step 2: Run CLI tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @spencer-kit/coder-studio test -- parse-args.test.ts cli.test.ts
+```
+
+Expected: FAIL because `memory` is not a known CLI command.
+
+- [ ] **Step 3: Implement CLI parsing and dispatch**
+
+Add `memory` command family and options. Map CLI commands to server ops:
+
+- `list` -> `memory.list`
+- `get` -> `memory.get`
+- `search` -> `memory.search`
+- `add` -> `memory.create`
+- `update` -> `memory.update`
+- `delete` -> `memory.delete`
+
+Use `args.workspaceId` when set, otherwise fall back to
+`process.env.CODER_STUDIO_WORKSPACE_ID`; throw `Missing workspace value` if
+neither exists for memory operations. Pass `sourceHint` with `skillSlug` when
+`--skill` is provided.
+
+- [ ] **Step 4: Run CLI tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @spencer-kit/coder-studio test -- parse-args.test.ts cli.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/cli/src/parse-args.ts packages/cli/src/parse-args.test.ts packages/cli/src/cli.ts packages/cli/src/cli.test.ts
+git commit -m "feat(cli): add workspace memory commands"
+```
+
+### Task 5: Built-In Memory Skill
+
+**Files:**
+- Modify: `packages/server/src/skills/builtin/registry.ts`
+- Modify: relevant tests under `packages/server/src/skills/builtin/`
+
+- [ ] **Step 1: Write failing built-in skill tests**
+
+Assert `coder-studio-memory` exists, is default enabled, auto mounts, materializes a `SKILL.md`, contains CLI list/search/add examples, and does not contain actual workspace memory entries.
+
+- [ ] **Step 2: Run built-in skill tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- builtin
+```
+
+Expected: FAIL because `coder-studio-memory` is not registered.
+
+- [ ] **Step 3: Implement built-in skill registration and content**
+
+Add a built-in skill definition with the approved guidance:
+
+- read when durable project context is useful
+- prefer targeted reads
+- write only stable project facts/preferences/decisions/workflows
+- avoid transient scratch notes
+- show CLI read/write examples
+- mention users can edit/delete from Memory side panel
+
+- [ ] **Step 4: Run built-in skill tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/server test -- builtin
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/server/src/skills/builtin/registry.ts packages/server/src/skills/builtin
+git commit -m "feat(server): add built-in memory skill"
+```
+
+### Task 6: Web Memory Panel
+
+**Files:**
+- Modify: `packages/web/src/features/workspace/atoms/layout.ts`
+- Modify: `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`
+- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`
+- Create: `packages/web/src/features/workspace/actions/use-memory-panel.ts`
+- Create: `packages/web/src/features/workspace/views/shared/memory-panel.tsx`
+- Create: `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx`
+- Modify: `packages/web/src/locales/en.json`
+- Modify: `packages/web/src/locales/zh.json`
+- Modify: `packages/web/src/styles/components.css`
+- Modify icon/theme files only if required by compile errors.
+
+- [ ] **Step 1: Write failing web tests**
+
+Cover `sanitizeDesktopSidebarView("memory")`, activity bar rendering Memory after Agent Instructions and before Skills, desktop view rendering `MemoryPanel`, loading entries through command dispatch, creating/selecting new entries, editing/saving, deleting/selecting next entry, search/type filter behavior, command failure notice, and tokenized CSS guard for Memory panel typography.
+
+- [ ] **Step 2: Run web tests to verify failure**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test -- memory-panel.test.tsx workspace-activity-bar
+```
+
+Expected: FAIL because the Memory view and panel do not exist.
+
+- [ ] **Step 3: Implement Memory panel UI and hook**
+
+Use existing workspace sidebar classes where practical. The panel has a header, search input, type filter, flat divider-row list, and detail editor. Keep all panel typography tokenized: selected title maxes at `var(--type-heading-6-size)` or an existing sidebar title class; list titles use body-4/text-base; metadata and badges use body-6/text-xs.
+
+- [ ] **Step 4: Run web tests to verify pass**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/web test -- memory-panel.test.tsx workspace-activity-bar
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/features/workspace/atoms/layout.ts packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx packages/web/src/features/workspace/actions/use-memory-panel.ts packages/web/src/features/workspace/views/shared/memory-panel.tsx packages/web/src/features/workspace/views/shared/memory-panel.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/styles/components.css
+git commit -m "feat(web): add workspace memory panel"
+```
+
+### Task 7: Integration Verification
+
+**Files:**
+- Modify any failing tests or type errors in files touched by Tasks 1-6.
+
+- [ ] **Step 1: Run targeted package tests**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core test
+pnpm --filter @coder-studio/server test -- memory
+pnpm --filter @spencer-kit/coder-studio test -- memory parse-args cli
+pnpm --filter @coder-studio/web test -- memory-panel
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run typecheck for touched packages**
+
+Run:
+
+```bash
+pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit
+pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit
+pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit
+pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run lint/check on touched files**
+
+Run:
+
+```bash
+pnpm exec biome check packages/core/src/domain/memory.ts packages/core/src/domain/memory.test.ts packages/core/src/domain/automation.ts packages/server/src/storage/repositories/memory-repo.ts packages/server/src/storage/repositories/memory-repo.test.ts packages/server/src/commands/memory.ts packages/server/src/commands/memory.test.ts packages/cli/src/parse-args.ts packages/cli/src/cli.ts packages/web/src/features/workspace/actions/use-memory-panel.ts packages/web/src/features/workspace/views/shared/memory-panel.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run repository verification if targeted checks pass**
+
+Run:
+
+```bash
+pnpm ci:verify
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit final fixes if any**
+
+```bash
+git status --short
+git add
+git commit -m "test: verify workspace memory system"
+```
+
+Only commit if Step 1-4 produced necessary fixes.
diff --git a/docs/superpowers/specs/2026-06-09-supervisor-target-details-react-flow-elk-design.md b/docs/superpowers/specs/2026-06-09-supervisor-target-details-react-flow-elk-design.md
new file mode 100644
index 000000000..c0dda88e8
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-09-supervisor-target-details-react-flow-elk-design.md
@@ -0,0 +1,342 @@
+# Supervisor 目标详情脑图迁移到 React Flow + ELK 设计
+
+> **版本:** 1.0
+> **日期:** 2026-06-09
+> **状态:** Draft(待评审)
+> **作者:** Codex
+
+## 1. 背景
+
+当前 Supervisor 的目标详情区域已经完成了信息分区重整:
+
+- 基本信息独立成行
+- 错误信息独立成行
+- 下一可执行项独立成行
+- 计划树被重命名为“目标详情”
+
+但当前计划树仍然是手工 CSS + 递归 DOM 的脑图实现,主要问题是:
+
+- 线段对齐和分支间距需要靠大量伪元素和容器高度推导维护
+- 缩放、拖拽、展开/收起都要自己处理
+- 树层级越深,样式和布局越脆
+- 要继续往“更像真正脑图”的方向走,手写布局的边界已经很明显
+
+用户希望这部分更美观,同时保留更强的交互:
+
+- 更像脑图
+- 可放大/缩小
+- 可拖拽平移
+- 可展开/收起节点
+- 根节点就是目标
+- 不把下一可执行项塞进脑图里
+
+因此,本次设计把目标详情中的脑图部分迁移到 `React Flow + ELK`。
+
+## 2. 目标
+
+- 用更稳定的图形库替代当前手写脑图渲染
+- 让视觉更接近真正的脑图,而不是普通树列表
+- 保留并强化现有交互:缩放、拖拽、fit view、展开/收起、聚焦当前节点
+- 保持当前信息分区不变:脑图只承载结构,其他内容继续独立展示
+- 维持 flat、克制、可扫描的监督面板风格
+- 保证在桌面和移动端都能用
+
+## 3. 非目标
+
+- 不把目标详情做成可编辑流程图
+- 不支持节点创建、删改、连线编辑
+- 不改 Supervisor 的 memory schema 作为本次前置条件
+- 不把“下一可执行项”并回脑图
+- 不恢复“当前叶路径”独立模块
+- 不引入完整项目管理器或通用工作流编辑器能力
+
+## 4. 方案结论
+
+采用 `React Flow + ELK` 作为目标详情脑图实现。
+
+结论原因:
+
+- `React Flow` 负责 viewport、节点、缩放、拖拽和控制器,省掉大量自定义手势与视口管理代码
+- `ELK` 负责层级布局,比手写定位更稳,尤其适合多层树和分支展开/收起
+- 这套组合天然适合“树状脑图”,比继续堆 CSS 伪元素更容易保持对齐和可维护性
+
+脑图只作为只读展示层存在,用户能操作的是:
+
+- 展开/收起节点
+- 缩放
+- 拖拽平移
+- fit 到当前目标路径
+- 聚焦当前执行节点
+
+用户不能:
+
+- 改节点内容
+- 重新连线
+- 拖拽重排计划结构
+
+## 5. 页面结构
+
+目标详情区保持现有分区,不因脑图库替换而重排:
+
+1. 基本信息
+2. 错误信息(如有)
+3. 目标周期推理(如有)
+4. 下一可执行项
+5. 目标详情脑图
+
+其中,只有第 5 项会切换到 `React Flow + ELK`。
+
+## 6. 数据模型
+
+Supervisor 的 memory 结构保持树形计划节点,不强制改成图结构。
+
+新增一层 UI 适配模型:
+
+```ts
+interface SupervisorMindMapNodeData {
+ id: string;
+ title: string;
+ detail?: string;
+ status: SupervisorPlanNodeStatus;
+ depth: number;
+ isRoot: boolean;
+ isActive: boolean;
+ isOnActivePath: boolean;
+ hasChildren: boolean;
+ childCount: number;
+ canExpand: boolean;
+ expanded: boolean;
+}
+
+interface SupervisorMindMapGraph {
+ nodes: Array>;
+ edges: Array;
+ viewportFocusId: string;
+}
+```
+
+转换规则:
+
+- `SupervisorPlanNode` 仍是源数据
+- 只有当前展开的可见节点会进入 graph
+- 当前 active path 默认自动展开
+- root 节点固定是目标本身
+- collapsed 节点保留展开按钮和子节点数量,但不渲染后代
+
+## 7. 布局策略
+
+布局由 `ELK` 负责,采用横向层级脑图:
+
+- 方向:左到右
+- root 在最左侧
+- 子节点向右展开
+- 同级节点纵向错开
+- 边使用肘形 / step 风格,不用自由曲线
+
+布局约束:
+
+- 节点尺寸必须可预测,不能完全依赖渲染后再抖动
+- 标题和 detail 要做行数限制,避免节点高度失控
+- root 节点更宽、更强调
+- active path 节点有更强的视觉权重
+- blocked / done / pending 状态用颜色和边框区分,但不过度抢眼
+
+建议节点尺寸分级:
+
+- root:更宽
+- branch:标准宽度
+- leaf:略窄但仍保持可读
+
+这能让 ELK 在布局时稳定计算,不需要为了每次文字变化反复重排。
+
+## 8. 节点渲染
+
+每个节点使用 `React Flow` 自定义 node component。
+
+节点内容保持当前 supervisor 风格:
+
+- 标题
+- 简短 detail
+- 状态 tag
+- 子节点数量
+- 展开/收起按钮
+
+视觉要求:
+
+- 扁平化
+- 小圆角
+- 低阴影或无阴影
+- 颜色克制
+- 不做卡片里再套卡片的层级堆叠
+
+root 节点:
+
+- 目标文本优先显示
+- 视觉重量高于普通节点
+- 作为 mind map 的唯一起点
+
+active 节点:
+
+- border / background 有更强强调
+- 但不把整个视图染得太亮
+
+active path 节点:
+
+- 保持可见但弱于 active 节点
+- 让用户知道当前路径,而不是所有分支都在抢注意力
+
+## 9. 交互设计
+
+### 9.1 缩放与平移
+
+由 `React Flow` 接管缩放和拖拽平移,不再依赖原生滚动条。
+
+要求:
+
+- 支持鼠标拖拽平移
+- 支持触控拖拽平移
+- 支持滚轮 / 触控板缩放
+- 支持缩放按钮
+- 支持 reset / fit view
+- 禁止可见滚动条出现在脑图容器里
+
+### 9.2 展开 / 收起
+
+节点按钮负责展开/收起单个分支。
+
+同时保留:
+
+- Expand all
+- Collapse all
+
+展开/收起后重新执行 ELK 布局。
+
+### 9.3 当前路径聚焦
+
+加载目标记忆时:
+
+- 自动展开当前 active path
+- 自动 fit 到目标详情脑图范围
+
+当 activeNodeId 改变时:
+
+- 如果用户还没有明显手动平移/缩放过,则自动轻微回焦到新路径
+- 如果用户已经人工调整视角,则不要强行抢回视角
+
+### 9.4 小地图
+
+桌面端显示 mini map,帮助快速定位分支。
+
+移动端默认隐藏 mini map,避免压缩主画布。
+
+### 9.5 节点可点击性
+
+节点主区域只做查看,不做编辑。
+
+节点上的交互只保留:
+
+- 展开/收起
+- 可选的聚焦当前节点
+
+不开放拖拽节点重排。
+
+## 10. 视觉约束
+
+目标是“脑图感”更强,但仍然保持监督面板的克制风格:
+
+- 8px 或更小圆角
+- 低饱和状态色
+- 轻边框,不用厚重阴影
+- 背景保持平面
+- 连接线清晰但不抢内容
+- 节点卡片之间保留足够留白
+
+这个实现要比当前手工脑图更“整洁”,但不能变成花哨图表。
+
+## 11. 响应式策略
+
+桌面端:
+
+- 完整 toolbar
+- mini map 默认可见
+- 支持较大的 mind map 展示区
+
+移动端:
+
+- 仍然支持拖拽和 pinch zoom
+- mini map 默认隐藏
+- 控件收紧,但不取消核心交互
+- 脑图容器高度保持稳定,避免页面跳动
+
+## 12. 技术边界
+
+本次迁移只影响 `packages/web`。
+
+不改:
+
+- server 端调度逻辑
+- supervisor 记忆 schema
+- provider 接口
+- 命令协议
+
+只改:
+
+- 目标详情脑图渲染层
+- 与之相关的样式
+- 对应的 UI 测试
+- preview scene
+
+## 13. 风险与缓解
+
+### 13.1 React Flow 引入后的包体和复杂度
+
+风险:前端依赖增加,组件抽象层更厚。
+
+缓解:
+
+- 只在 supervisor 详情页面使用
+- 只做只读图,不引入编辑能力
+- 把 adapter 和 node component 收敛在单独模块里
+
+### 13.2 ELK 布局成本
+
+风险:树节点变多时,布局计算可能变慢。
+
+缓解:
+
+- 只对可见节点布局
+- 缓存 layout 结果
+- 先不做 worker 化,若后续真实数据量上来再升级
+
+### 13.3 视觉回退
+
+风险:节点尺寸估算不稳会导致换行和边距变化。
+
+缓解:
+
+- 节点宽高采用有限档位
+- 标题和 detail 做行数限制
+- 通过 Playwright 截图验证桌面和移动端
+
+## 14. 验收标准
+
+以下条件都满足时,才算这次迁移完成:
+
+- 目标详情脑图使用 `React Flow + ELK`
+- 根节点是目标本身
+- 节点可展开/收起
+- 支持缩放、拖拽平移、fit view
+- 支持桌面端 mini map
+- 没有可见滚动条
+- 连接线对齐稳定,不再依赖手写伪元素拼接
+- `Next executable` 仍然单独一行
+- 错误信息仍然单独一行
+- 目标详情 section 仍然使用现有的 flat panel 风格
+- Playwright 视觉检查通过
+- 相关 vitest / biome / diff-check 通过
+
+## 15. 结论
+
+本次不是把 Supervisor 改成通用流程编辑器,而是把目标详情脑图升级成一个更稳、更像脑图、更适合读树和聚焦当前路径的只读图视图。
+
+`React Flow` 解决交互和视口,`ELK` 解决布局,Supervisor 只继续负责目标记忆和执行语义。
diff --git a/docs/superpowers/specs/2026-06-11-dev-browser-loopback-proxy-design.md b/docs/superpowers/specs/2026-06-11-dev-browser-loopback-proxy-design.md
new file mode 100644
index 000000000..df1ffe89f
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-11-dev-browser-loopback-proxy-design.md
@@ -0,0 +1,399 @@
+# Dev Browser Loopback Proxy Design
+
+> Status: Draft for user review
+> Date: 2026-06-11
+> Scope: `packages/web` built-in browser UI and service worker, `packages/server` HTTP proxy routes, related tests and help docs
+
+## Goal
+
+Add a Coder Studio built-in browser path that can open a locally running development
+server, such as `http://localhost:8000`, even when the user is connected to Coder
+Studio from another device or an external URL.
+
+The built-in browser must make local validation possible without exposing the target
+development server directly to the network. Coder Studio server remains the only
+externally reachable process. It fetches loopback resources on behalf of the browser
+and returns them through authenticated Coder Studio routes.
+
+## User Flow
+
+The v1 entry is an editor-header browser action plus manual URL input inside the
+browser tab:
+
+1. The user starts a local development server in a Coder Studio terminal.
+2. The user clicks a `Browser` action in the editor header.
+3. Coder Studio opens or activates a browser editor tab.
+4. The browser tab shows a URL input when no target is active. The user enters a
+ loopback URL such as `http://localhost:8000` or `http://127.0.0.1:5173`.
+5. The web client asks Coder Studio server to create a short-lived dev browser
+ session for that target.
+6. The browser tab iframe loads the proxied page under the `/dev-browser/`
+ route scope.
+7. A service worker scoped to the dev browser rewrites resource requests to the
+ matching server proxy session.
+
+Future terminal-output detection can add an `Open in Browser` shortcut, but v1 does
+not depend on framework-specific terminal output parsing.
+
+## In Scope
+
+- manual loopback URL input in the built-in browser
+- an editor-header action that opens or activates a browser editor tab
+- a browser editor tab that is separate from file editor tabs
+- authenticated HTTP proxy sessions for loopback targets
+- service-worker-based request routing for proxied pages loaded under the dev
+ browser scope
+- support for ordinary HTML, CSS, JavaScript, images, fonts, source maps, and
+ normal `fetch` or `XMLHttpRequest` calls
+- clear unsupported-state handling for WebSocket and HMR URLs
+- tests for URL validation, proxy routing, service worker routing decisions, and
+ core UI flow
+- documentation of v1 capabilities and limitations
+
+## Out Of Scope
+
+- WebSocket proxying
+- Vite, Next, or framework HMR support
+- arbitrary external URL proxying
+- automatic terminal-output URL detection
+- wildcard-host or subdomain-based proxying
+- browser automation or remote debugging
+- replacing the existing file-backed Markdown and HTML document preview
+- a desktop Activity Bar or mobile Dock browser entry as the primary v1 entry
+
+## Existing Context
+
+Coder Studio already has `/api/preview/session` for Markdown and HTML file previews.
+That route serves editor document content and local workspace assets. It is not a
+running-server proxy and should remain separate.
+
+The server also has a global Fastify `onRequest` auth guard. New dev proxy routes
+should inherit this guard, then add their own target validation and session checks.
+
+The existing document preview iframe uses a restrictive sandbox that is appropriate
+for file previews. A running app browser needs a separate component and sandbox
+policy, because service workers and many development apps require same-origin
+browser behavior.
+
+The proxied app document must itself load under the service worker scope. If the
+iframe loads `/api/dev-proxy/...` directly while the service worker is scoped to
+`/dev-browser/`, the service worker cannot reliably control that document. The v1
+route model therefore serves app documents and resources under `/dev-browser/...`
+and reserves `/api/dev-proxy/...` for session management APIs if needed.
+
+## UI Placement
+
+The browser lives inside the editor surface, not as an Activity Bar workspace view.
+
+Desktop behavior:
+
+- The editor header tabbar has a compact `Browser` action near the existing tabbar
+ actions.
+- Clicking the action opens a browser editor tab or focuses the existing browser
+ tab for the workspace.
+- The browser tab title is `Browser` before a session is active, then reflects the
+ target when practical, such as `Browser :8000` or `localhost:8000`.
+- The URL input appears only inside the browser tab's own toolbar or empty state.
+ It should not be a persistent global editor-header input because it applies only
+ to browser tabs.
+- File tabs keep the existing path breadcrumb, dirty state, and edit, preview, diff,
+ pin, and close controls.
+- Browser tabs show browser-specific controls such as address input, open, refresh,
+ and close. They do not show file mode controls.
+
+Mobile behavior:
+
+- The mobile files/editor sheet should expose the same editor-header browser action
+ where space permits, or a compact equivalent in the editor header actions.
+- Opening the browser action shows the browser tab content in the editor sheet
+ rather than a separate Dock browser sheet.
+
+The Activity Bar should not include a Browser item in this design. If a future
+version needs terminal-output detection, that shortcut should open the same browser
+editor tab instead of introducing a second browser surface.
+
+## Editor Tab Model
+
+The browser tab should be modeled as a first-class non-file editor tab, not as a
+fake path in `openEditorPaths`.
+
+The current editor state is file-centric:
+
+- `openEditorPaths` stores file paths.
+- `activeFilePath` drives file loading and mode selection.
+- search, open editors, file tree selection, persistence, and document previews all
+ assume paths refer to workspace files.
+
+Using a virtual path such as `__browser__/localhost:8000` would leak into those file
+flows and create brittle exceptions. The design should introduce a small typed tab
+layer, for example:
+
+```ts
+type WorkspaceEditorTab =
+ | { kind: "file"; path: string }
+ | { kind: "browser"; id: "dev-browser"; targetUrl?: string; sessionId?: string };
+```
+
+The implementation can keep existing file atoms as the file-buffer source of truth,
+but the rendered editor tabs and active editor target should understand both file
+and browser tabs. File-only consumers should continue receiving file paths only.
+
+## Architecture
+
+The feature has three parts:
+
+- Web UI: a browser editor tab, opened from the editor header, that accepts a
+ loopback URL and hosts the proxied page in an iframe.
+- Service worker: a dev-browser-scoped routing shim that maps page resource requests
+ to the correct server proxy base.
+- Server proxy: a short-lived session store plus authenticated HTTP proxy routes that
+ fetch resources from the loopback target.
+
+The key rule is that the browser never needs to reach `localhost:8000` directly.
+Every external browser request is sent to Coder Studio first, and Coder Studio server
+performs the local loopback request.
+
+## URL Model
+
+Given a target of:
+
+```text
+http://localhost:8000/app/
+```
+
+Server creates a session like:
+
+```text
+sessionId = dev_abc
+targetOrigin = http://127.0.0.1:8000
+targetBasePath = /app/
+browserBase = /dev-browser/session/dev_abc/
+browserProxyBase = /dev-browser/session/dev_abc/proxy
+```
+
+The iframe first loads a dev browser shell under `browserBase`. That shell registers
+the service worker and then navigates to the app document under `browserProxyBase`.
+Because both the shell and the app document live under `/dev-browser/`, the service
+worker can control the app document and its later resource requests.
+
+Request examples:
+
+```text
+GET /dev-browser/session/dev_abc/proxy/app/
+ -> http://127.0.0.1:8000/app/
+
+GET /dev-browser/session/dev_abc/proxy/assets/main.js
+ -> http://127.0.0.1:8000/assets/main.js
+
+GET /dev-browser/session/dev_abc/proxy/api/data
+ -> http://127.0.0.1:8000/api/data
+```
+
+## Server API
+
+Create session:
+
+```text
+POST /api/dev-proxy/session
+body: { "url": "http://localhost:8000" }
+response: {
+ "id": "dev_abc",
+ "browserUrl": "/dev-browser/session/dev_abc/",
+ "browserProxyBase": "/dev-browser/session/dev_abc/proxy",
+ "targetOrigin": "http://127.0.0.1:8000"
+}
+```
+
+Read session metadata:
+
+```text
+GET /api/dev-proxy/session/:id
+```
+
+Proxy request:
+
+```text
+ANY /dev-browser/session/:id/proxy/*
+```
+
+Delete session:
+
+```text
+DELETE /api/dev-proxy/session/:id
+```
+
+The proxy request route should forward common HTTP methods. Request and response
+headers must be filtered rather than blindly copied. Hop-by-hop headers, websocket
+upgrade headers, and host-specific headers should not be forwarded unchanged.
+
+## Target Validation
+
+Allowed targets:
+
+- `http://localhost:`
+- `http://127.0.0.1:`
+- `http://[::1]:`
+
+Rejected targets:
+
+- non-HTTP protocols
+- external hosts
+- private LAN hosts such as `192.168.x.x`
+- public hosts
+- missing or invalid ports
+- credentials in the URL
+
+The server should canonicalize `localhost` and `::1` to loopback connection targets
+and preserve the original path, search, and hash for browser navigation semantics.
+
+## Service Worker Routing
+
+The service worker is scoped under `/dev-browser/` so it does not affect the main
+Coder Studio application.
+
+The dev browser shell sends active session metadata to the service worker:
+
+- session id
+- target origin
+- browser proxy base
+- initial target pathname
+
+The service worker handles normal HTTP(S) fetch events. It rewrites:
+
+- requests to `http://localhost:/*`
+- requests to `http://127.0.0.1:/*`
+- requests to `http://[::1]:/*`
+- same-origin root paths such as `/assets/*` and `/api/*` when they belong to the
+ active dev browser frame
+- relative URLs after the browser resolves them inside the dev browser scope
+
+For subresource requests, the service worker should fetch the mapped
+`browserProxyBase` URL. For navigation requests that would leave `/dev-browser/`,
+the service worker should redirect to the equivalent `browserProxyBase` URL so the
+new document remains inside the service worker scope.
+
+The service worker should not try to implement a JavaScript parser. It should route
+requests at fetch time, which covers static tags, CSS-triggered resource loads,
+dynamic `import()`, and ordinary `fetch` or `XMLHttpRequest` calls when those calls
+produce fetch events.
+
+## HTML Bootstrap
+
+The proxy can inject a minimal bootstrap into HTML responses when needed. The
+bootstrap should:
+
+- register or refresh dev browser session metadata with the service worker
+- patch `window.WebSocket` only to fail fast for loopback WebSocket URLs with a clear
+ console message
+- avoid broad monkey-patching of `fetch` or `XMLHttpRequest` unless tests show the
+ service worker cannot cover a required v1 case
+
+The default should be request-time routing through the service worker, not fragile
+HTML, CSS, or JavaScript rewriting.
+
+## Unsupported WebSocket Behavior
+
+WebSocket is intentionally out of scope for v1.
+
+When a page tries to open `ws://localhost:` or `wss://localhost:`, the
+browser should fail clearly. The preferred v1 behavior is a console warning such as:
+
+```text
+Coder Studio dev browser does not proxy WebSocket connections yet.
+```
+
+The server should reject websocket upgrade attempts on dev proxy routes rather than
+silently hanging.
+
+## Security
+
+This feature is a controlled local loopback proxy, not a general-purpose proxy.
+
+Required protections:
+
+- inherit existing Coder Studio authentication
+- require a valid dev browser session id for every proxy request
+- allow only loopback targets
+- serve proxied app documents under `/dev-browser/` so the service worker can
+ control them without broadening scope to the whole application
+- reject websocket upgrades in v1
+- strip hop-by-hop headers
+- bound request and response handling to avoid unbounded buffering where practical
+- expire sessions after inactivity
+- delete sessions when the built-in browser tab is closed when possible
+- do not expose raw target URLs in public unauthenticated routes
+
+The important SSRF boundary is server-side target validation. The client and service
+worker may improve ergonomics, but the server must remain the enforcement point.
+
+## Error Handling
+
+The built-in browser should show actionable states:
+
+- invalid URL: ask for a loopback HTTP URL with an explicit port
+- target unavailable: show that Coder Studio could not connect to the local service
+- unsupported websocket: explain that HMR and app WebSockets are not available in v1
+- expired session: offer to reload or recreate the browser session
+- service worker unsupported: show that this browser context cannot run the dev
+ browser proxy
+
+Proxy errors should preserve enough status information for debugging without leaking
+stack traces.
+
+## Testing
+
+Server tests:
+
+- accepts `localhost`, `127.0.0.1`, and `[::1]` loopback HTTP URLs
+- rejects external, LAN, non-HTTP, credentialed, and malformed URLs
+- proxies HTML, CSS, JS, image, JSON, and non-GET requests to a local Fastify target
+- strips hop-by-hop headers
+- rejects missing or expired sessions
+- rejects websocket upgrade attempts
+
+Web tests:
+
+- editor header action opens or focuses the browser editor tab
+- browser tab creates a session from manual URL input
+- file editor tabs still render file breadcrumbs and mode actions
+- browser editor tabs render browser controls and omit file mode actions
+- invalid manual input is reported inline
+- service worker routing maps loopback and same-origin resource URLs to
+ `browserProxyBase`
+- unsupported WebSocket patch produces a clear failure path
+- browser tab cleanup deletes the server session when feasible
+
+Docs:
+
+- document manual URL entry
+- document supported resource types
+- document that WebSocket and HMR are not supported in v1
+
+## Risks
+
+Service worker availability depends on browser security context. It works on HTTPS
+and on localhost, but plain HTTP access from another device may not allow service
+worker registration in all browsers. The UI should detect this and either show a
+clear unsupported message or fall back to a more limited server-side HTML rewrite
+later.
+
+Path-based proxying can still miss unusual application behavior, especially code
+that relies on exact origins or custom WebSocket protocols. v1 should be positioned
+as local page validation, not a complete remote browser replacement.
+
+## Implementation Notes
+
+Keep the new dev browser separate from editor document preview. The file preview
+system has different security assumptions, lifecycle, and sandbox behavior.
+
+Prefer small, testable units:
+
+- target URL parser and validator
+- in-memory dev browser session store
+- Fastify proxy route registration
+- service worker request-to-proxy URL mapper
+- typed editor tab state for file and browser tabs
+- browser editor tab UI state and lifecycle
+
+The server validator and service worker mapper should have direct unit tests because
+they are the highest-risk parts of the feature.
diff --git a/docs/superpowers/specs/2026-06-11-more-features-page-design.md b/docs/superpowers/specs/2026-06-11-more-features-page-design.md
new file mode 100644
index 000000000..2df1f4d76
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-11-more-features-page-design.md
@@ -0,0 +1,318 @@
+# More Features Page Design
+
+> Status: Draft
+> Date: 2026-06-11
+> Scope: `packages/web` more-features routing shell, desktop and mobile navigation behavior, settings/analysis/about information architecture
+
+## Goal
+
+Add a dedicated `更多功能` page family under `/more/*` so that configuration,
+analysis, diagnostics, and about/update entry points are no longer crowded into
+the existing settings page.
+
+The v1 goal is not to redesign every existing tool page. The goal is to create a
+clear information architecture and a stable outer navigation shell that can host
+existing content with less confusion.
+
+## Final Direction
+
+Use a standalone page family under `/more/*`.
+
+Key decisions:
+
+- do not use a floating menu or popover
+- do not render `更多功能` inside the workspace shell chrome
+- desktop uses one stable page shell with top-level category tabs and a left-side
+ section nav
+- mobile keeps the current progressive drill-in interaction instead of forcing the
+ desktop split-pane layout onto a small screen
+- settings content layout and styling remain as-is in v1; only the outer route and
+ navigation shell change
+
+## In Scope
+
+- a new route family rooted at `/more/*`
+- category-level information architecture for settings, analysis/diagnostics, and
+ about/update
+- desktop navigation shell for the new page family
+- mobile navigation flow for entering categories and then concrete pages
+- route mapping for existing settings, analytics, monitoring, diagnostics, and
+ about/update content
+- design constraints for visual weight, roundness, and interaction model
+
+## Out Of Scope
+
+- redesigning current settings form layouts or control styling
+- changing the actual contents of existing settings sections beyond relocation
+- changing work analysis, monitoring, diagnostics, or about internals in v1
+- using a floating menu layer
+- embedding the more-features experience into the workspace page chrome
+- forcing mobile to reuse the desktop left-nav plus right-content pattern
+
+## Existing Context
+
+Current settings content already contains both true configuration sections and
+non-settings destinations.
+
+Visible settings navigation today is centered around:
+
+- `general`
+- `providers`
+- `appearance`
+- `shortcuts`
+
+Additional sections already exist in the current settings page renderer or as
+separate pages:
+
+- `analysis`
+- `monitoring`
+- `diagnostics`
+- `about`
+- standalone routes for `/analytics`, `/monitoring`, and `/diagnostics`
+
+This means the product already has the underlying content. The missing piece is a
+better outer structure for where users discover and switch between these areas.
+
+## Information Architecture
+
+The approved v1 category model has three top-level groups:
+
+1. `设置`
+2. `分析诊断`
+3. `关于与更新`
+
+### 1. 设置
+
+This category keeps only true configuration items:
+
+- `通用`
+- `Agents`
+- `外观`
+- `快捷键`
+
+These are all existing settings-style sections and remain the right fit for a
+configuration category.
+
+### 2. 分析诊断
+
+This category pulls higher-priority operational pages out of settings:
+
+- `工作分析`
+- `性能监控`
+- `环境诊断`
+
+These pages are not configuration-first experiences. They are destination pages
+for understanding system state and work outcomes, so they should not be buried
+inside settings.
+
+### 3. 关于与更新
+
+This category groups app identity and version-management concerns:
+
+- `关于应用`
+- `更新状态`
+- `自动更新`
+
+This split is important because `关于` is no longer treated as one long catch-all
+page. Product metadata and update workflow concerns become independent navigation
+targets.
+
+## Route Model
+
+The approved route structure is:
+
+```text
+/more/settings/:section
+/more/analysis/:section
+/more/about/:section
+```
+
+Recommended initial concrete routes:
+
+```text
+/more/settings/general
+/more/settings/providers
+/more/settings/appearance
+/more/settings/shortcuts
+
+/more/analysis/analytics
+/more/analysis/monitoring
+/more/analysis/diagnostics
+
+/more/about/product
+/more/about/update-status
+/more/about/auto-update
+```
+
+Route naming note:
+
+- UI labels may use user-facing names such as `Agents`
+- route segments should prefer existing internal identifiers where reuse reduces
+ churn, such as keeping `/more/settings/providers` for the `Agents` page
+- the same rule applies to other migrated destinations: keep stable route ids even
+ if the visible navigation copy is refined
+
+Route behavior rules:
+
+- `/more/*` is a standalone page family, not a workspace sub-pane
+- top-level category changes update the first route segment after `/more/`
+- left-side section changes update the `:section` segment
+- each concrete page must have a stable URL for refresh, navigation, and deep-link
+ use
+
+## Desktop Design
+
+Desktop uses a dedicated more-features shell with three fixed regions:
+
+1. page header
+2. top horizontal category navigation
+3. two-column body with left section nav and right content area
+
+### Standalone Page Behavior
+
+The page must visually read as its own destination. It should not appear inside
+workspace tabs, activity rails, or workspace header chrome.
+
+The page header shows:
+
+- page title: `更多功能`
+- current route indicator
+- short explanatory text about the current category model
+
+### Top Category Navigation
+
+The approved final style is intentionally light:
+
+- flat tabs, not heavy cards
+- low roundness
+- active state communicated with a thin accent underline and stronger label color
+- inactive items stay mostly text-only
+- descriptive helper copy is only shown for the active tab
+
+This keeps the category switcher visible without dominating the page.
+
+### Left Section Navigation
+
+The left nav is also intentionally light:
+
+- list-style navigation, not boxed feature cards
+- active item uses a thin accent rail and subtle background tint
+- inactive items mostly show title only
+- helper copy only expands on the active item
+
+This reduces visual weight and keeps attention on the right content panel.
+
+### Right Content Area
+
+The right content area hosts the actual page content for the selected route.
+
+Important v1 rule for `设置`:
+
+- keep the current settings content layout and styling unchanged
+- only relocate it behind the new `/more/settings/*` shell
+
+The desktop design work in this feature therefore affects discovery, routing, and
+outer-page structure, not the internals of the settings forms.
+
+For analysis and about/update pages, the same rule applies in spirit:
+
+- use the new outer shell for navigation
+- keep existing page content patterns unless a later task explicitly redesigns them
+
+## Mobile Design
+
+Mobile does not use the desktop split-pane shell.
+
+The approved mobile interaction stays close to the current product behavior:
+
+1. enter `更多功能`
+2. choose a category such as `设置`
+3. choose a concrete section such as `通用`
+4. open the final content page
+
+This means mobile navigation is progressive drill-in, not side-by-side navigation.
+
+Recommended route progression example:
+
+```text
+/more
+/more/settings
+/more/settings/general
+```
+
+Mobile rules:
+
+- keep the interaction layered and simple
+- do not force a persistent left navigation rail on small screens
+- the final page for a concrete section can reuse the current mobile settings page
+ content behavior
+
+## Visual Constraints
+
+The approved visual direction is:
+
+- flat
+- restrained
+- low roundness
+- light navigation weight
+- no floating menu styling
+- no oversized category cards
+
+Specific constraints:
+
+- top-level navigation should feel like tabs, not marketing cards
+- left-side section nav should feel like a product list, not a dashboard of tiles
+- the content area should remain visually dominant
+
+## Content Mapping
+
+The initial content mapping is:
+
+| Category | Route base | Sections | Content source |
+| --- | --- | --- | --- |
+| 设置 | `/more/settings/*` | 通用 / Agents (`providers`) / 外观 / 快捷键 | existing settings sections |
+| 分析诊断 | `/more/analysis/*` | 工作分析 / 性能监控 / 环境诊断 | existing analytics, monitoring, diagnostics pages |
+| 关于与更新 | `/more/about/*` | 关于应用 / 更新状态 / 自动更新 | existing about and update content |
+
+## Entry Requirement
+
+The user-facing label for the entry is `更多功能`.
+
+This design locks the destination behavior after entry. The exact placement and
+launch affordance for the entry button can be finalized in implementation as long
+as it opens the `/more/*` page family and does not reintroduce a floating menu
+experience.
+
+## Non-Negotiable V1 Rules
+
+- `更多功能` is a page, not a popover
+- the page is standalone, not visually nested inside workspace shell chrome
+- desktop and mobile may use different navigation patterns
+- settings content internals are preserved in v1
+- analysis and diagnostics do not stay hidden inside settings navigation
+
+## Testing Expectations For Implementation
+
+When implementation starts, the minimum validation should cover:
+
+- desktop route switching between the three top-level categories
+- desktop left-nav switching within each category
+- deep linking to concrete `/more/.../...` pages
+- mobile drill-in flow from `/more` to category pages to concrete section pages
+- preservation of current settings content behavior when rendered under the new
+ `/more/settings/*` routes
+- no regression for existing analytics, monitoring, diagnostics, and about content
+
+## Design Artifact
+
+The approved local visual draft for this design is:
+
+- `docs/more-features-page-preview.html`
+
+That preview reflects the current approved direction:
+
+- standalone desktop more-features page
+- light top tabs
+- light left navigation
+- unchanged settings content scope
+- mobile progressive drill-in flow
diff --git a/docs/superpowers/specs/2026-06-12-dev-browser-multi-tab-design.md b/docs/superpowers/specs/2026-06-12-dev-browser-multi-tab-design.md
new file mode 100644
index 000000000..b9628ec1a
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-12-dev-browser-multi-tab-design.md
@@ -0,0 +1,229 @@
+# Dev Browser Multi-Tab Design
+
+> Status: Draft for user review
+> Date: 2026-06-12
+> Scope: `packages/core`, `packages/server`, `packages/web` dev browser editor-tab state and refresh recovery
+
+## Goal
+
+Allow the built-in dev browser to behave like ordinary editor tabs:
+
+- the user can open multiple browser tabs
+- the same URL can be opened multiple times
+- each browser tab is independently closable and activatable
+- refreshing the page restores all browser tabs
+- opening a URL inside a browser tab replaces that tab's URL instead of creating another tab
+
+This change is limited to tab modeling, persistence, and browser-session recovery. It does not add new proxy capabilities.
+
+## Current Problem
+
+The current implementation still models the dev browser as a singleton tab:
+
+- browser tab identity is fixed to `id: "dev-browser"`
+- opening the browser focuses the existing singleton tab instead of creating a new instance
+- duplicate URLs are impossible because there is only one browser tab
+- refresh persistence uses one global `devBrowserTargetUrl`
+- the surface restores only one browser session
+
+That model conflicts with the editor mental model. Browser tabs need instance identity, not feature identity.
+
+## User-Approved Interaction Rules
+
+These rules are fixed by user decision and should drive the implementation:
+
+1. Clicking the editor-header browser action creates a new browser tab instance.
+2. Duplicate URLs are allowed. URL is display data, not identity.
+3. Browser tab labels should just show the URL.
+4. Inside a browser tab, submitting the URL form with `Open` replaces that tab's current URL.
+5. Refresh should restore all browser tabs, not only the active one.
+
+## In Scope
+
+- per-instance browser tab modeling
+- browser-tab persistence in workspace UI state
+- duplicate same-URL browser tabs
+- restore all browser tabs after refresh
+- lazy browser-session recreation for restored tabs
+- per-tab close and activation behavior
+- test updates covering these behaviors
+
+## Out Of Scope
+
+- WebSocket proxy support
+- HMR support
+- changes to the dev proxy HTTP feature set
+- special browser tab naming beyond showing the URL
+- cross-workspace browser sharing
+
+## Design
+
+## 1. Browser Tabs Become Instance Records
+
+Replace the singleton browser tab model with a browser tab record that carries a unique instance id and its current URL.
+
+Target shape:
+
+```ts
+export interface WorkspaceBrowserEditorTab {
+ kind: "browser";
+ id: string;
+ url: string | null;
+}
+```
+
+Rules:
+
+- `id` is a generated stable instance id used for tab identity, activation, replacement, and close.
+- `url` is nullable so a newly created browser tab can exist before the user opens a target.
+- file tabs remain path-based and continue to deduplicate by path.
+- browser tabs do not deduplicate by URL.
+
+`WorkspaceEditorTab` stays the shared union for file and browser tabs.
+
+## 2. Persist Browser Tabs in the Tab List, Not in a Global Browser Field
+
+The persisted browser state should move from the singleton field:
+
+```ts
+devBrowserTargetUrl?: string | null;
+```
+
+to the browser entries inside:
+
+- `openEditorTabs`
+- `activeEditorTab`
+
+The browser tab's `url` becomes the persisted source of truth for restore and label rendering.
+
+`devBrowserTargetUrl` should be removed from the persisted workspace UI contract once the client and server both understand the new browser tab shape.
+
+## 3. New-Tab vs Replace-Current Behavior
+
+Two entrypoints have different behavior:
+
+- editor-header browser action: create a new browser tab with a fresh `id` and `url: null`
+- browser-surface `Open`: replace the current browser tab's `url`
+
+Replacing means:
+
+- update the active browser tab record in `openEditorTabs`
+- keep the same browser tab `id`
+- persist the updated tab list and active tab
+- recreate the browser proxy session for that tab
+
+This preserves the expected meaning of "I am editing the current tab's address bar".
+
+## 4. Browser Session State Is Local and Rebuilt from URL
+
+Proxy session ids should remain runtime-local UI state and should not be persisted in workspace UI state.
+
+Each rendered browser tab surface should derive its live session from:
+
+- workspace id
+- browser tab id
+- browser tab url
+
+Recommended behavior:
+
+- if a browser tab has no URL, show the empty state
+- if a browser tab has a URL and becomes active without a live session, create one
+- if a browser tab URL changes, delete the old live session and create a new one
+- if a browser tab closes, delete its live session if present
+- if the whole app unmounts, best-effort delete active live sessions
+
+This keeps persisted data stable and avoids persisting short-lived server session ids that are invalid after refresh anyway.
+
+## 5. Refresh Recovery Restores All Tabs but Rebuilds Sessions Lazily
+
+Refresh recovery should restore the editor tab list first, then rebuild browser sessions on demand.
+
+Behavior after refresh:
+
+1. hydrate `openEditorTabs` and `activeEditorTab`
+2. render all restored tabs in the tab strip
+3. if the active tab is a browser tab with a URL, recreate its proxy session immediately
+4. if an inactive restored browser tab is later activated, recreate its session then
+
+This is the right tradeoff:
+
+- all tabs visually come back after refresh
+- duplicate same-URL tabs stay distinct
+- startup work stays bounded because hidden browser tabs do not all open sessions at once
+
+## 6. Tab Rendering and Activation Rules
+
+The tab header should render browser tabs from `tab.url`:
+
+- `url` present: show the URL text
+- `url` absent: show the existing browser fallback label
+
+Activation and close logic should match other editor tabs:
+
+- activating one browser tab does not affect sibling browser tabs except switching focus
+- closing one browser tab removes only that tab instance
+- closing the active browser tab falls back using the existing editor-tab close policy
+
+No special singleton branch should remain in tab rendering or tab state normalization.
+
+## 7. Normalization and Backward Compatibility
+
+The normalization layer should accept both old and new browser shapes during rollout, then produce only the new shape in client state.
+
+Compatibility behavior:
+
+- legacy `{ kind: "browser", id: "dev-browser" }` normalizes to one browser tab with a generated deterministic migration id and `url` from `devBrowserTargetUrl` when available
+- legacy `devBrowserTargetUrl` is read only for migration during hydration/normalization
+- newly persisted state writes only browser tabs with `{ kind, id, url }`
+
+This lets existing saved workspaces recover cleanly after upgrade without keeping the legacy singleton model alive in runtime logic.
+
+## 8. Package Boundaries
+
+### `packages/core`
+
+- update `WorkspaceBrowserEditorTab`
+- update `WorkspaceEditorTab`
+- update `UiState` to remove the long-term dependency on `devBrowserTargetUrl`
+
+### `packages/server`
+
+- accept and persist the new `openEditorTabs` and `activeEditorTab` browser records
+- keep normalization compatible with older saved singleton browser state during transition
+
+### `packages/web`
+
+- replace singleton browser tab assumptions in atoms, normalization, actions, and surface rendering
+- open new browser tabs from the header action
+- replace the current browser tab URL from the in-tab toolbar
+- rebuild sessions per browser tab instance
+
+## Testing Strategy
+
+Add focused tests before implementation for these behaviors:
+
+1. opening the browser action twice creates two distinct browser tabs
+2. opening the same URL in two different browser tabs keeps both tabs
+3. using `Open` inside a browser tab replaces only that tab's URL
+4. closing one browser tab does not close sibling browser tabs
+5. refresh hydration restores multiple browser tabs and the active browser tab
+6. legacy singleton browser persistence still hydrates into one browser tab after migration
+7. restored inactive browser tabs defer session creation until activation
+
+Keep existing refresh-recovery tests green and update them to the new per-instance model where needed.
+
+## Acceptance Criteria
+
+- the user can open multiple browser tabs from the editor header
+- the same URL can appear in multiple browser tabs at the same time
+- tab labels display the URL and do not collapse same-URL tabs into one instance
+- using `Open` inside a browser tab updates that tab instead of creating a new one
+- refreshing the page restores all browser tabs and the previously active tab
+- restored browser tabs reopen successfully when activated
+- old persisted singleton browser state migrates without data loss
+
+## Risks and Tradeoffs
+
+- removing the singleton assumption touches shared editor-tab code, so focused regression tests are required around activate/close behavior
+- lazy session recreation means an inactive restored browser tab may show a short loading interval the first time it is revisited after refresh
+- migration logic must stay narrowly scoped so legacy `devBrowserTargetUrl` support does not keep leaking into new runtime code
diff --git a/docs/superpowers/specs/2026-06-12-ui-action-protocol-design.md b/docs/superpowers/specs/2026-06-12-ui-action-protocol-design.md
new file mode 100644
index 000000000..7b12c47ab
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-12-ui-action-protocol-design.md
@@ -0,0 +1,617 @@
+# UI Action Protocol and Agent Skill Design
+
+Date: 2026-06-12
+Status: Draft
+Owner: Codex
+
+## Problem
+
+Coder Studio already has two related but separate command surfaces:
+
+- Agent automation commands in `packages/core/src/domain/automation.ts` and
+ `packages/server/src/commands/automation.ts`. These currently expose a small
+ read-oriented capability list such as workspace, session, terminal, and Git
+ reads.
+- Frontend UI commands in features like Command Palette, Quick Open, workspace
+ panels, and editor actions. These commands are implemented directly in React
+ components and hooks, not as a shared protocol.
+
+Because these surfaces are separate, there is no standard way for an agent
+session to ask Coder Studio to perform a UI interaction such as opening a file
+in the built-in editor, showing the terminal panel, switching workspace focus,
+or opening a localhost web page in the internal preview surface.
+
+The requested built-in skill therefore needs more than a `SKILL.md` file. The
+skill can teach the agent how to request UI actions, but the product also needs
+a stable UI action protocol, a server bridge, and frontend executors that own
+the actual UI state changes.
+
+## Goals
+
+- Define a first-class UI action protocol that is shared through `packages/core`.
+- Give agent sessions a provider-neutral way to request UI interactions.
+- Add an internal server command that validates and broadcasts UI action
+ intents to the active frontend.
+- Add a frontend executor registry so UI features can register supported actions
+ without hard-coding all behavior in one component.
+- Implement an MVP built-in skill that documents how agents should call the UI
+ action bridge.
+- Support opening workspace files, opening localhost URLs, focusing
+ workspaces, showing common panels, and running a small allowlist of existing
+ frontend commands.
+- Keep UI state ownership in the frontend. Server and agents request actions;
+ React executors decide how to apply them.
+- Make the protocol extensible for later agent-facing UI features.
+
+## Non-Goals
+
+- Do not implement general DOM automation, arbitrary clicks, or form filling.
+- Do not give agents unrestricted browser automation or screenshot access.
+- Do not allow arbitrary external URL browsing in the MVP.
+- Do not make server commands mutate frontend-only atoms directly.
+- Do not replace all existing Command Palette behavior in the first
+ implementation.
+- Do not add dangerous UI actions such as discard changes, delete files, or
+ close all sessions in the MVP.
+- Do not depend on provider-specific native tool-call APIs.
+
+## Current Context
+
+Relevant current code:
+
+- `packages/core/src/domain/automation.ts` defines the existing agent
+ automation capability descriptors.
+- `packages/server/src/commands/automation.ts` exposes
+ `automation.identify` and `automation.capabilities`.
+- `packages/server/src/session/manager.ts` injects session environment such as
+ `CODER_STUDIO`, `CODER_STUDIO_WORKSPACE_ID`, `CODER_STUDIO_SESSION_ID`,
+ `CODER_STUDIO_PROVIDER_ID`, and optionally `CODER_STUDIO_API_URL`.
+- `packages/server/src/skills/builtin/registry.ts` currently has an empty
+ `BUILTIN_SKILLS` list.
+- `packages/server/src/skills/builtin/materialize.ts` writes each built-in
+ skill as `SKILL.md`.
+- `packages/web/src/features/code-editor/actions/use-open-location.ts` can open
+ a file and navigate to a location.
+- `packages/web/src/features/workspace/actions/use-open-workspace-file.ts` wraps
+ editor pane and standalone editor targeting.
+- `packages/web/src/features/quick-open/components/quick-open.tsx` opens files
+ through the existing frontend editor flow.
+- `packages/web/src/features/command-palette/components/command-palette.tsx`
+ contains local frontend commands but no shared command registry.
+
+## User Decisions Captured
+
+- The built-in skill should let agents open files and web pages through Coder
+ Studio's built-in UI.
+- The feature should also introduce a standard UI interaction instruction
+ protocol, not just a one-off file opener.
+- The protocol should make future agent-facing UI features easy to add.
+- The first implementation should remain practical and scoped.
+
+## Approaches Considered
+
+### Option A: Skill-only instructions
+
+Add a built-in skill that tells agents to ask the user to open files or paste
+URLs manually.
+
+Pros:
+
+- Very small implementation.
+- No protocol or server changes.
+
+Cons:
+
+- Does not actually let agents operate the built-in editor or preview surface.
+- Does not create an extension point for future UI actions.
+- Produces a weak user experience.
+
+Decision: reject.
+
+### Option B: Provider-native tools per agent
+
+Implement native tool-call integrations for each provider and map those tools
+to Coder Studio UI actions.
+
+Pros:
+
+- Could feel natural in providers that support tool calls.
+- Could return structured results directly to the model.
+
+Cons:
+
+- Provider support is inconsistent.
+- Requires separate implementations for Codex, Claude, Gemini, OpenCode, and
+ custom providers.
+- Increases coupling to provider-specific protocols.
+
+Decision: reject for MVP.
+
+### Option C: Shared UI action protocol with CLI bridge
+
+Define UI action intents in core, expose them through server automation
+commands, execute them in frontend subscribers, and teach agents to call a
+provider-neutral CLI helper.
+
+Pros:
+
+- Works across providers because agents can run a local command.
+- Keeps UI state changes in the frontend.
+- Creates a stable extension point for future UI capabilities.
+- Fits the existing WebSocket dispatch and topic model.
+
+Cons:
+
+- Requires coordinated changes across core, server, web, CLI, and built-in
+ skills.
+- First version needs careful security boundaries around URL and command
+ execution.
+
+Decision: accept.
+
+## Final Design
+
+### 1. Core Protocol
+
+Add `packages/core/src/domain/ui-actions.ts`.
+
+The MVP protocol defines an intent union:
+
+```ts
+export type UiActionIntent =
+ | {
+ type: "editor.openFile";
+ workspaceId?: string;
+ path: string;
+ line?: number;
+ column?: number;
+ target?: "active" | "newPane" | { paneId: string };
+ }
+ | {
+ type: "browser.openUrl";
+ workspaceId?: string;
+ url: string;
+ target?: "preview" | "external";
+ }
+ | {
+ type: "workspace.focus";
+ workspaceId: string;
+ }
+ | {
+ type: "panel.show";
+ workspaceId?: string;
+ panel:
+ | "terminal"
+ | "explorer"
+ | "search"
+ | "git"
+ | "skills"
+ | "agentInstructions";
+ }
+ | {
+ type: "command.run";
+ commandId: string;
+ args?: Record;
+ };
+```
+
+Add companion types:
+
+```ts
+export type UiActionRiskLevel = "read" | "write" | "dangerous";
+
+export interface UiActionDescriptor {
+ type: UiActionIntent["type"];
+ description: string;
+ inputSchema: Record;
+ permissions: AutomationPermission[];
+ riskLevel: UiActionRiskLevel;
+ available: boolean;
+ examples: string[];
+}
+
+export interface UiActionDispatchRequest {
+ intent: UiActionIntent;
+ source?: {
+ kind: "agent" | "user" | "system";
+ sessionId?: string;
+ providerId?: string;
+ };
+ requestId?: string;
+}
+
+export interface UiActionDispatchResult {
+ accepted: boolean;
+ requestId: string;
+ topic: string;
+}
+```
+
+Extend automation permissions with UI-specific entries:
+
+- `ui:read`
+- `ui:navigate`
+- `ui:command`
+
+MVP actions should use `read` or low-risk navigation semantics. Destructive UI
+actions remain out of scope.
+
+### 2. Topic Model
+
+Add a workspace-scoped topic to `packages/core/src/protocol/topics.ts`:
+
+```ts
+workspaceUiAction: (workspaceId: string) => `workspace.${workspaceId}.ui.action`
+```
+
+The event payload is a normalized `UiActionDispatchRequest` plus server-derived
+metadata:
+
+```ts
+export interface UiActionEvent {
+ requestId: string;
+ workspaceId: string;
+ intent: UiActionIntent;
+ source?: UiActionDispatchRequest["source"];
+ dispatchedAt: number;
+}
+```
+
+The server only accepts and broadcasts the request. The frontend applies it and
+may show success or error feedback locally. The server result should not claim
+that the UI action completed, only that it was accepted and routed.
+
+### 3. Server Command
+
+Add `packages/server/src/commands/ui-actions.ts` and import it from
+`packages/server/src/commands/index.ts`.
+
+Register:
+
+```ts
+uiAction.dispatch
+uiAction.capabilities
+```
+
+`uiAction.dispatch` accepts:
+
+```ts
+{
+ intent: UiActionIntent;
+ source?: {
+ kind: "agent" | "user" | "system";
+ sessionId?: string;
+ providerId?: string;
+ };
+ requestId?: string;
+}
+```
+
+Server responsibilities:
+
+- Resolve missing `workspaceId` from `source.sessionId` when possible.
+- Validate the workspace exists.
+- Validate session ownership when `source.sessionId` is supplied.
+- Normalize editor paths to workspace-relative paths.
+- Reject absolute paths or paths escaping the workspace.
+- Validate line and column are positive integers when supplied.
+- Restrict `browser.openUrl` in MVP to:
+ - `http://localhost:*`
+ - `http://127.0.0.1:*`
+ - `http://[::1]:*`
+ - existing Coder Studio preview URLs if they are already server-local
+- Restrict `command.run` to an explicit allowlist.
+- Broadcast the action on `Topics.workspaceUiAction(workspaceId)`.
+- Return `UiActionDispatchResult`.
+
+`uiAction.capabilities` returns descriptors for currently enabled UI actions.
+It can be used by both CLI and future UI documentation surfaces.
+
+### 4. Agent Automation Capability Integration
+
+Extend `automation.capabilities` so agent-visible capability discovery includes
+UI action commands. The existing `AutomationCapability` shape can either:
+
+- include UI action entries directly, or
+- add a `uiActions` property to the response.
+
+Prefer adding `uiActions` to avoid overloading command-style capabilities with
+intent-style descriptors:
+
+```ts
+{
+ version: 1,
+ commands: AutomationCapability[],
+ uiActions: UiActionDescriptor[]
+}
+```
+
+Existing callers that only read `commands` continue to work.
+
+### 5. CLI Bridge
+
+Extend `packages/cli` with a provider-neutral UI action entrypoint:
+
+```bash
+coder-studio ui editor.openFile --path src/app.ts --line 42 --column 5
+coder-studio ui browser.openUrl --url http://localhost:5173
+coder-studio ui panel.show --panel terminal
+coder-studio ui workspace.focus --workspace ws_123
+coder-studio ui command.run --command quickOpen.open
+```
+
+CLI behavior:
+
+- Read `CODER_STUDIO_API_URL`, `CODER_STUDIO_WORKSPACE_ID`,
+ `CODER_STUDIO_SESSION_ID`, and `CODER_STUDIO_PROVIDER_ID` from the
+ environment.
+- Allow explicit flags to override workspace or session when needed.
+- Call the server endpoint or command bridge that maps to `uiAction.dispatch`.
+- Print compact JSON on `--json`.
+- Print a short human-readable success or failure message by default.
+
+The CLI should not talk directly to WebSocket topics. It should call a server
+command-facing HTTP endpoint or existing automation client path so validation is
+centralized in the server.
+
+### 6. Built-In Skill
+
+Add a built-in skill in `packages/server/src/skills/builtin/registry.ts`:
+
+- `slug`: `coder-studio-ui`
+- `displayName`: `Coder Studio UI`
+- `description`: `Open files, URLs, panels, and supported UI commands in Coder Studio.`
+- `defaultEnabled`: `true`
+- `autoMountInMvp`: `true`
+
+The skill body should be concise. It should teach agents:
+
+- Use `coder-studio ui ...` when running inside Coder Studio.
+- Prefer `editor.openFile` when referencing code or asking the user to inspect a
+ specific location.
+- Prefer `browser.openUrl` for localhost development servers and generated
+ previews.
+- Prefer `panel.show` to surface relevant built-in panels.
+- Check command output and report failures.
+- Do not attempt arbitrary browser automation with this skill.
+
+Example skill snippet:
+
+````markdown
+---
+name: coder-studio-ui
+description: Use Coder Studio's built-in UI to open files, localhost URLs, panels, and supported commands for the user.
+---
+
+# Coder Studio UI
+
+When you are running inside Coder Studio and need the user to inspect a file,
+web page, or panel, call the `coder-studio ui` command.
+
+Examples:
+
+```bash
+coder-studio ui editor.openFile --path packages/server/src/commands/file.ts --line 94
+coder-studio ui browser.openUrl --url http://localhost:5173
+coder-studio ui panel.show --panel terminal
+```
+
+Only use supported UI actions. Treat command failures as real failures and
+explain them briefly to the user.
+````
+
+### 7. Frontend Executor Registry
+
+Add a web-side executor registry, for example:
+
+- `packages/web/src/features/ui-actions/types.ts`
+- `packages/web/src/features/ui-actions/registry.ts`
+- `packages/web/src/features/ui-actions/use-ui-action-subscription.ts`
+
+Executor interface:
+
+```ts
+export interface UiActionExecutor {
+ type: TIntent["type"];
+ execute(intent: TIntent, context: UiActionExecutionContext): Promise | void;
+}
+```
+
+Execution context includes:
+
+- current workspace list and active workspace setters
+- navigation helpers
+- toast/notification helpers
+- dispatch command helper if needed
+- editor helpers where available
+
+The first implementation can keep executors in one feature folder and call
+existing hooks. Later, each feature can own its executor registration.
+
+### 8. MVP Executors
+
+#### `editor.openFile`
+
+Behavior:
+
+- Resolve `workspaceId` from the intent or current active workspace.
+- Switch to the workspace route when needed.
+- Use `useOpenWorkspaceFile`.
+- Pass `line` and `column` as pending editor navigation.
+- Respect `target: "active"` by default.
+- Support `{ paneId }` when supplied and valid.
+- Treat `"newPane"` as a later enhancement unless an existing pane creation
+ flow is straightforward to reuse.
+
+#### `browser.openUrl`
+
+Behavior:
+
+- Open approved localhost URLs in the built-in preview/browser surface.
+- If the current app already has a preview pane component suitable for arbitrary
+ localhost URLs, reuse it.
+- If not, MVP may open a controlled iframe preview panel first and leave richer
+ browser controls for later.
+- Reject unsupported URL targets with a toast.
+
+#### `panel.show`
+
+Behavior:
+
+- `terminal`: show bottom terminal panel and ensure non-zero height.
+- `explorer`, `search`, `git`, `skills`, `agentInstructions`: select the
+ corresponding workspace sidebar section.
+- Switch to the workspace route if needed.
+
+#### `workspace.focus`
+
+Behavior:
+
+- Reuse `useSelectWorkspaceTarget`.
+- Navigate to `/workspace`.
+
+#### `command.run`
+
+Behavior:
+
+- MVP allowlist:
+ - `quickOpen.open`
+ - `commandPalette.open`
+ - `settings.open`
+ - `focusMode.enable`
+ - `focusMode.disable`
+ - `terminal.show`
+- Do not expose arbitrary command IDs.
+- This can become the bridge to a shared Command Palette registry later.
+
+### 9. Command Palette Alignment
+
+The MVP does not need to rewrite Command Palette. However, the design should
+avoid making a second long-lived command system.
+
+Follow-up direction:
+
+- Extract `CommandPalette` command definitions into a shared frontend command
+ registry.
+- Make `command.run` call that registry for allowlisted commands.
+- Let Command Palette render from the same descriptors when possible.
+
+This keeps the first implementation small while preserving a path to one
+frontend command model.
+
+### 10. Security and Permissions
+
+MVP safety rules:
+
+- Agents can request UI navigation, not arbitrary mutation.
+- Server validates workspace/session ownership before broadcasting.
+- Paths must stay inside the workspace.
+- URLs are limited to localhost and server-owned preview URLs.
+- `command.run` is allowlisted.
+- Dangerous actions are not included in descriptors.
+- The frontend may ignore actions for workspaces not currently loaded.
+- Failed actions should show a toast and be logged in testable code paths.
+
+Future expansion should add explicit permission descriptors before adding
+write/dangerous actions.
+
+## Data Flow
+
+Agent flow:
+
+1. Built-in skill tells the agent to call `coder-studio ui editor.openFile`.
+2. CLI reads Coder Studio environment variables.
+3. CLI sends a `uiAction.dispatch` request to the local server.
+4. Server validates and normalizes the intent.
+5. Server broadcasts `workspace..ui.action`.
+6. Frontend subscription receives the action.
+7. The relevant executor updates UI state through existing hooks.
+8. The user sees the file, page, panel, or command result in the UI.
+
+Manual or future UI flow:
+
+1. Internal UI code can construct a `UiActionIntent`.
+2. The same executor registry can execute it locally or route it through server
+ when remote session provenance matters.
+
+## Error Handling
+
+Server errors:
+
+- `workspace_not_found`
+- `session_not_found`
+- `ui_action_invalid_path`
+- `ui_action_invalid_url`
+- `ui_action_unsupported`
+- `ui_action_command_not_allowed`
+
+Frontend errors:
+
+- Missing executor.
+- Workspace not loaded.
+- File open failed.
+- Preview/browser panel unavailable.
+- Unsupported target.
+
+Frontend executor failures should show a concise toast. Server dispatch success
+must be worded as "accepted" rather than "completed" because the server cannot
+observe React state completion.
+
+## Testing Strategy
+
+Core tests:
+
+- Validate UI action schemas and descriptor construction.
+- Verify automation capability response includes `uiActions` without breaking
+ existing `commands`.
+
+Server tests:
+
+- `uiAction.dispatch` resolves workspace from session.
+- It rejects unknown workspaces and sessions.
+- It rejects path traversal and absolute paths.
+- It accepts valid workspace-relative paths.
+- It rejects non-localhost URLs.
+- It accepts localhost URLs.
+- It rejects non-allowlisted `command.run`.
+- It broadcasts to `Topics.workspaceUiAction(workspaceId)`.
+
+Web tests:
+
+- Subscription receives `editor.openFile` and calls existing open file flow.
+- `panel.show terminal` opens the terminal panel.
+- `workspace.focus` changes active workspace and route.
+- `command.run quickOpen.open` opens Quick Open.
+- Unsupported actions show failure feedback without crashing.
+
+CLI tests:
+
+- Parse each MVP subcommand into the expected intent.
+- Read default workspace/session/provider from environment.
+- Respect `--json`.
+- Surface server error messages.
+
+Skill tests:
+
+- Built-in skill materializes into `SKILL.md`.
+- Built-in sync auto-mounts it for providers with skill directories.
+
+## Rollout Plan
+
+1. Add core protocol and descriptors.
+2. Add server command and topic broadcasting.
+3. Add CLI `coder-studio ui` command parsing and dispatch.
+4. Add frontend subscription and MVP executors.
+5. Add built-in skill registration.
+6. Add tests at core, server, web, CLI, and skill sync layers.
+7. Run targeted package tests, then repository-level verification.
+
+## Open Follow-Ups
+
+- Whether `browser.openUrl` should open in an existing preview pane, a new
+ browser pane, or a minimal iframe surface in the first implementation depends
+ on the current preview component boundaries.
+- Whether `target: "newPane"` for `editor.openFile` should be implemented in
+ MVP depends on how much pane creation logic can be reused cleanly.
+- Whether UI action completion acknowledgements are needed later. MVP only
+ guarantees server acceptance and frontend best-effort execution.
diff --git a/docs/superpowers/specs/2026-06-12-workspace-memory-system-design.md b/docs/superpowers/specs/2026-06-12-workspace-memory-system-design.md
new file mode 100644
index 000000000..f9884f369
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-12-workspace-memory-system-design.md
@@ -0,0 +1,563 @@
+# Workspace Memory System Design
+
+> Status: Draft for user review
+> Date: 2026-06-12
+> Scope: `packages/core`, `packages/server`, `packages/cli`, `packages/web`, built-in skills
+
+## Goal
+
+Add a project-scoped memory system for Coder Studio. Users, agents, and skills can
+store structured project memory for a workspace; agents can read and write it on
+demand through a built-in skill and CLI/API commands; users can inspect, edit, and
+delete entries from the existing desktop side panel.
+
+The memory content must not be injected into every agent session by default.
+Instead, Coder Studio will provide a default built-in Memory Skill that tells the
+agent when and how to read or write memory.
+
+## Decisions
+
+- Memory scope for v1 is one workspace. The schema may reserve future extension
+ points, but v1 does not expose global or session-scoped memory.
+- Memory entries are structured records, not a single Markdown document.
+- Entries use a fixed `type` plus free-form `tags`.
+- Agents and skills may write directly. User confirmation is not required before
+ the entry is saved.
+- Users can edit and delete entries in the UI after any user, agent, or skill
+ write.
+- Memory is stored in Coder Studio state, not in the workspace Git tree.
+- Storage is one JSON file per workspace.
+- Deletion is soft delete via `archivedAt`; default lists hide archived entries.
+- No memory content is automatically inserted into agent startup context.
+- A built-in Memory Skill is default-mounted for providers that support skills.
+- The desktop side panel gets the first UI. Mobile integration is deferred.
+
+## Non-Goals
+
+- Do not build vector search, embedding storage, or semantic retrieval in v1.
+- Do not sync memory to Git or cloud storage in v1.
+- Do not add a review queue for agent-written memory in v1.
+- Do not build global user preference memory in v1.
+- Do not expose archived entry restore UI in v1, beyond keeping the data model
+ ready for it.
+- Do not replace `.coder-studio/agent.md` or provider-specific agent instruction
+ files.
+- Do not use memory as a hard instruction channel. Long-lived instructions remain
+ in agent instructions; memory is project knowledge the agent may consult.
+
+## Existing Context
+
+Relevant existing project structure:
+
+- `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`
+ owns desktop side panel activity entries.
+- `packages/web/src/features/workspace/atoms/layout.ts` defines
+ `DesktopSidebarView` and validates persisted sidebar view state.
+- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`
+ renders the active sidebar panel.
+- `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts`
+ is the closest existing example of a workspace side panel calling server
+ commands.
+- `packages/server/src/ws/dispatch.ts` registers command handlers and injects
+ repositories through `CommandContext`.
+- `packages/server/src/storage/repositories/json-file-store.ts` provides atomic
+ JSON writes.
+- `packages/server/src/server.ts` wires state-root-backed repositories into the
+ command context.
+- `packages/cli/src/automation-command-client.ts` already calls server commands
+ over WebSocket and uses `CODER_STUDIO_API_URL`.
+- `packages/server/src/session/manager.ts` injects `CODER_STUDIO`,
+ `CODER_STUDIO_WORKSPACE_ID`, `CODER_STUDIO_SESSION_ID`,
+ `CODER_STUDIO_PROVIDER_ID`, and `CODER_STUDIO_API_URL` into agent sessions.
+- `packages/server/src/skills/builtin/*` can materialize and mount built-in
+ skills.
+
+## Core Domain Model
+
+Create `packages/core/src/domain/memory.ts` and export it from
+`packages/core/src/index.ts`.
+
+```ts
+export const WORKSPACE_MEMORY_TYPES = [
+ "project_fact",
+ "decision",
+ "task_context",
+ "preference",
+ "workflow",
+ "note",
+] as const;
+
+export type WorkspaceMemoryType = (typeof WORKSPACE_MEMORY_TYPES)[number];
+
+export const WORKSPACE_MEMORY_SOURCE_KINDS = ["user", "agent", "skill"] as const;
+
+export type WorkspaceMemorySourceKind =
+ (typeof WORKSPACE_MEMORY_SOURCE_KINDS)[number];
+
+export interface WorkspaceMemorySource {
+ kind: WorkspaceMemorySourceKind;
+ providerId?: string;
+ sessionId?: string;
+ skillSlug?: string;
+}
+
+export interface WorkspaceMemoryEntry {
+ id: string;
+ workspaceId: string;
+ type: WorkspaceMemoryType;
+ title: string;
+ content: string;
+ tags: string[];
+ source: WorkspaceMemorySource;
+ createdAt: number;
+ updatedAt: number;
+ archivedAt?: number;
+}
+
+export interface WorkspaceMemoryListFilter {
+ workspaceId: string;
+ query?: string;
+ type?: WorkspaceMemoryType;
+ tag?: string;
+ includeArchived?: boolean;
+}
+```
+
+Validation rules:
+
+- `title`: trim, 1-160 characters.
+- `content`: trim, 1-20,000 characters.
+- `tags`: lowercase normalized strings, max 20 tags, each 1-40 characters.
+- `type`: one of `WORKSPACE_MEMORY_TYPES`.
+- `source.kind`: defaults to `user` for web UI calls and `agent` for CLI calls
+ that run inside an agent session.
+- `skillSlug`: display/source hint only in v1. It is not an authorization
+ boundary.
+
+## Server Storage
+
+Add `packages/server/src/storage/repositories/memory-repo.ts`.
+
+Each workspace has its own file:
+
+```text
+/state/memory/workspaces/.json
+```
+
+Use `encodeURIComponent(workspaceId)` for the filename to avoid path traversal or
+separator issues. The stored JSON still includes the original `workspaceId`.
+
+File format:
+
+```json
+{
+ "version": 1,
+ "workspaceId": "ws_123",
+ "entries": {
+ "mem_abc": {
+ "id": "mem_abc",
+ "workspaceId": "ws_123",
+ "type": "decision",
+ "title": "Store memory in Coder Studio state",
+ "content": "Project memory is server-owned and should not dirty the Git workspace by default.",
+ "tags": ["architecture", "memory"],
+ "source": { "kind": "user" },
+ "createdAt": 1779120000000,
+ "updatedAt": 1779120000000
+ }
+ }
+}
+```
+
+Repository behavior:
+
+- Missing file means empty memory list.
+- Reads return entries sorted by `updatedAt` descending.
+- `list` hides entries with `archivedAt` unless `includeArchived` is true.
+- `create` generates `mem__` ids.
+- `update` is last-write-wins for v1.
+- `delete` sets `archivedAt` and updates `updatedAt`.
+- `removeWorkspace(workspaceId)` deletes that workspace memory file; wire this
+ into workspace teardown only if the existing workspace close/delete semantics
+ already remove other workspace-local state. Otherwise keep memory when a
+ workspace is closed.
+
+## Server Commands
+
+Create `packages/server/src/commands/memory.ts` and import it from
+`packages/server/src/commands/index.ts`.
+
+Commands:
+
+- `memory.list`
+ - args: `workspaceId`, optional `query`, `type`, `tag`, `includeArchived`
+ - returns: `WorkspaceMemoryEntry[]`
+- `memory.get`
+ - args: `workspaceId`, `id`
+ - returns: `WorkspaceMemoryEntry`
+- `memory.create`
+ - args: `workspaceId`, `type`, `title`, `content`, `tags`, optional
+ `sourceHint`
+ - returns: created `WorkspaceMemoryEntry`
+- `memory.update`
+ - args: `workspaceId`, `id`, optional `type`, `title`, `content`, `tags`,
+ `archivedAt`
+ - returns: updated `WorkspaceMemoryEntry`
+- `memory.delete`
+ - args: `workspaceId`, `id`
+ - returns: archived `WorkspaceMemoryEntry`
+- `memory.search`
+ - args: same as `memory.list`, with required `query`
+ - returns: `WorkspaceMemoryEntry[]`
+
+`memory.search` can call the same repository filter as `memory.list`. Search is
+case-insensitive substring matching across `title`, `content`, `tags`, and
+`type`.
+
+Workspace validation:
+
+- Every command must require an existing workspace through `ctx.workspaceMgr.get`.
+- Unknown workspace returns `workspace_not_found`.
+- Unknown memory id returns `memory_not_found`.
+
+Broadcast:
+
+- After create/update/delete, broadcast:
+
+```text
+workspace..memory.changed
+```
+
+The payload should include `{ workspaceId, entryId, action }` where action is
+`created`, `updated`, or `deleted`.
+
+`CommandContext` additions:
+
+- Add `memoryRepo?: MemoryRepo` or required `memoryRepo: MemoryRepo`.
+- Instantiate it in `packages/server/src/server.ts` with:
+
+```ts
+new MemoryRepo({
+ rootDir: join(stateRoot, "state", "memory", "workspaces"),
+})
+```
+
+## CLI Automation
+
+Extend `packages/cli/src/parse-args.ts` and `packages/cli/src/cli.ts` with a
+`memory` command family.
+
+Commands:
+
+```bash
+coder-studio memory list --workspace ws_123 --json
+coder-studio memory get mem_abc --workspace ws_123 --json
+coder-studio memory search "testing" --workspace ws_123 --json
+coder-studio memory add --workspace ws_123 --type decision --title "..." --content "..." --tag testing --json
+coder-studio memory update mem_abc --workspace ws_123 --title "..." --content "..." --tag pnpm --json
+coder-studio memory delete mem_abc --workspace ws_123 --json
+```
+
+Agent-session ergonomics:
+
+- `--workspace` is optional when `CODER_STUDIO_WORKSPACE_ID` is present.
+- `--api-url` reuses existing automation URL resolution.
+- `memory add` and `memory update` support repeated `--tag`.
+- `memory add` accepts `--skill ` as a display/source hint. The built-in
+ Memory Skill should pass `--skill coder-studio-memory` when it asks an agent
+ to write through the CLI.
+
+Automation domain:
+
+- Add `memory:read` and `memory:write` to the automation permission vocabulary.
+- Include both in `DEFAULT_AGENT_AUTOMATION_PERMISSIONS`.
+- Add `memory.list`, `memory.search`, `memory.get`, `memory.add`,
+ `memory.update`, and `memory.delete` entries to `automation.capabilities`.
+
+## Built-In Memory Skill
+
+Add a built-in skill definition in `packages/server/src/skills/builtin/registry.ts`:
+
+- slug: `coder-studio-memory`
+- display name: `Coder Studio Memory`
+- source: `builtin`
+- default enabled: true
+- auto mount: true
+
+The skill content must not contain actual memory entries. It should teach the
+agent to use Coder Studio memory only when useful:
+
+- Read memory when the project background, prior decisions, user preferences, or
+ workflow expectations are relevant.
+- Prefer targeted reads by type, tag, or query over reading everything.
+- Write memory only for stable project facts, explicit user preferences,
+ durable decisions, or reusable workflow notes.
+- Do not write transient reasoning or one-off task scratch notes as long-term
+ memory.
+- Use the CLI examples from the CLI Automation section.
+- Mention that users can edit or delete entries from the Memory side panel.
+
+Example skill guidance:
+
+```md
+When you need durable project context, run:
+
+coder-studio memory list --workspace "$CODER_STUDIO_WORKSPACE_ID" --json
+
+When you learn a stable project fact or the user states a persistent preference,
+write it with:
+
+coder-studio memory add \
+ --type project_fact \
+ --title "..." \
+ --content "..." \
+ --tag architecture \
+ --skill coder-studio-memory \
+ --json
+```
+
+## Web UI
+
+Add a new desktop side panel view named `memory`.
+
+Files:
+
+- Modify `packages/web/src/features/workspace/atoms/layout.ts`
+ - Add `"memory"` to `DesktopSidebarView`.
+ - Add it to the sanitizer allowlist.
+- Modify
+ `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`
+ - Add Memory activity entry.
+ - Place it after Agent Instructions and before Skills.
+- Modify
+ `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`
+ - Render `MemoryPanel` when `activeSidebarView === "memory"`.
+- Create `packages/web/src/features/workspace/actions/use-memory-panel.ts`.
+- Create `packages/web/src/features/workspace/views/shared/memory-panel.tsx`.
+- Update `packages/web/src/locales/en.json` and
+ `packages/web/src/locales/zh.json`.
+- Add theme/icon semantic support for `nav.memory`, using the existing icon
+ theme system. A notebook or brain semantic is acceptable if it matches the
+ current icon theme.
+
+Panel layout:
+
+- Use the existing `workspace-sidebar-view`,
+ `workspace-sidebar-panel__body`, and related workspace sidebar conventions.
+- Header shows `Project Memory` and active entry count.
+- Top controls:
+ - search input for filtering existing memory entries by title, content, or
+ tags; this is not the create-entry input
+ - type filter segmented/chip control
+ - `New` action
+- List:
+ - title
+ - type badge
+ - short content preview
+ - tags
+ - source summary
+ - updated time
+- Detail editor:
+ - type select
+ - title input
+ - content textarea
+ - tag editor
+ - save action
+ - delete action
+ - source and updated metadata
+
+Interaction:
+
+- Load list on mount.
+- Refresh on `workspace..memory.changed`.
+- Optimistically keep local edits in a draft state until saved.
+- After create, select the created entry.
+- After delete, remove the entry from the visible list and select the next entry
+ if available.
+- Show a `Notice` for command failures.
+- Disable save while request is in flight.
+
+## Visual And Typography Constraints
+
+The Memory panel should feel like a native Coder Studio workbench panel, not a
+new feature landing page.
+
+Visual direction:
+
+- Flat, low-decoration workbench UI.
+- No decorative gradients, glow, glass, floating cards, or heavy shadows.
+- Prefer divider rows for the memory list over card stacks.
+- Use a low-contrast selected row background and a narrow neutral indicator.
+- Keep badges restrained and small.
+- Do not use large hero-style headings inside the side panel.
+- Avoid nested cards.
+
+Typography:
+
+- Follow existing typography tokens in `packages/web/src/styles/tokens.css`.
+- Panel header and selected-entry title should use existing
+ sidebar/workbench title styles. If a new class is needed, cap it at
+ `var(--type-heading-6-size)` or `var(--type-body-3-size)`.
+- Entry titles should use `var(--type-body-4-size)` or `var(--text-base)`.
+- Entry previews, form controls, footer metadata, and secondary helper text
+ should use `var(--type-body-5-size)` or `var(--text-sm)` unless the existing
+ input/button component already defines a tokenized size.
+- Labels, badges, compact counts, tag metadata, and row meta text should use
+ `var(--type-body-6-size)` or `var(--text-xs)`.
+- Do not use hard-coded `18px`, `20px`, or larger typography inside the Memory
+ side panel unless reusing an existing workspace component class that already
+ applies that size in the same context.
+- Inputs, buttons, chips, badges, and row text must use existing component
+ tokens or nearby workspace-sidebar styles instead of ad hoc font sizes.
+- Text must not overflow its parent at desktop side panel widths; use truncation
+ for list titles and wrapping for detail content.
+
+The temporary local HTML mockup is a visual reference only. Implementation must
+adapt the idea to existing CSS tokens and current workbench visual rules.
+
+## Data Flow
+
+User-created memory:
+
+```text
+MemoryPanel form
+ -> dispatchCommand("memory.create" | "memory.update" | "memory.delete")
+ -> MemoryRepo writes workspace file
+ -> server broadcasts workspace..memory.changed
+ -> MemoryPanel refreshes list
+```
+
+Agent/skill-created memory:
+
+```text
+Agent reads built-in Memory Skill
+ -> runs coder-studio memory add/search/list
+ -> CLI calls server WebSocket command
+ -> MemoryRepo writes workspace file
+ -> MemoryPanel receives memory.changed and refreshes
+```
+
+Agent read flow:
+
+```text
+Agent decides memory may help
+ -> coder-studio memory search/list/get
+ -> CLI returns JSON
+ -> agent uses relevant entries in current reasoning
+```
+
+No automatic startup injection occurs.
+
+## Error Handling
+
+Server errors:
+
+- `workspace_not_found`
+- `memory_not_found`
+- `memory_validation_failed`
+- `memory_storage_unavailable`
+
+UI behavior:
+
+- Loading state while fetching.
+- Empty state when no entries exist.
+- Search empty state when filters match nothing.
+- Inline notice for failures.
+- Retry by re-running the failed action or refreshing the panel.
+
+CLI behavior:
+
+- Non-zero exit with readable error for failed command.
+- `--json` prints structured command result or error text consistent with
+ existing CLI behavior.
+- Missing workspace outside an agent session reports that `--workspace` is
+ required.
+
+## Testing
+
+Core:
+
+- `WorkspaceMemoryType` and source constants are exported.
+- Type guards or validators accept only supported memory types.
+
+Server:
+
+- `MemoryRepo` returns empty list for missing workspace file.
+- `MemoryRepo` creates one file per workspace.
+- `MemoryRepo` uses encoded workspace ids for filenames.
+- Create/update/delete persist atomically.
+- Soft-deleted entries are hidden by default and visible with
+ `includeArchived`.
+- Search filters title, content, tags, and type case-insensitively.
+- Commands reject unknown workspaces.
+- Commands reject invalid type, empty title, empty content, or invalid tags.
+- Commands broadcast `workspace..memory.changed` after writes.
+- Server assembly injects `memoryRepo` into `CommandContext`.
+
+CLI:
+
+- Parser accepts all memory subcommands.
+- `memory list/search/get/add/update/delete` map to the correct server op.
+- `--workspace` falls back to `CODER_STUDIO_WORKSPACE_ID`.
+- Repeated `--tag` values are passed as an array.
+- `--skill` is passed as a source hint on writes.
+
+Built-in skill:
+
+- Materialization writes `coder-studio-memory/SKILL.md`.
+- Built-in sync auto-mounts the skill for eligible providers.
+- Skill text contains CLI read/write examples.
+- Skill text does not contain actual workspace memory content.
+
+Web:
+
+- `memory` is accepted by `sanitizeDesktopSidebarView`.
+- Workspace activity bar renders the Memory entry.
+- Desktop workspace renders `MemoryPanel` for the memory view.
+- `MemoryPanel` loads entries and displays empty state.
+- Search and type filters update visible entries.
+- Create, edit, and delete dispatch the expected commands.
+- The panel refreshes on `workspace..memory.changed`.
+- Failure states render notices.
+- Typography checks should assert the panel uses existing class names/tokens where
+ practical rather than hard-coded large font sizes.
+
+## Rollout
+
+Recommended implementation order:
+
+1. Core memory domain types.
+2. Server `MemoryRepo`, commands, command context wiring, and tests.
+3. CLI memory commands and automation capability entries.
+4. Built-in `coder-studio-memory` skill and sync tests.
+5. Web Memory side panel and UI tests.
+6. Full verification with relevant package tests, then repository-level
+ verification before handoff.
+
+## Risks
+
+- Direct agent writes can create noisy memory. Mitigation: source display,
+ compact filters, and user edit/delete controls.
+- Agents may forget to read memory because content is not injected. Mitigation:
+ default-mounted Memory Skill and automation capability discovery.
+- `skillSlug` source is not strongly authenticated in v1. Mitigation: treat it
+ as a display hint, not a permission boundary.
+- Last-write-wins can overwrite concurrent edits. Mitigation: acceptable for v1;
+ optimistic locking is outside the v1 scope.
+- State-owned memory does not follow the Git repo to another machine. Mitigation:
+ future export/import or optional project-file mirror.
+
+## Acceptance Criteria
+
+- A user can open the desktop Memory side panel for a workspace.
+- A user can create, edit, and delete structured memory entries.
+- Entries support fixed type and free-form tags.
+- Entries show source and update metadata.
+- Memory persists in one JSON file per workspace under Coder Studio state.
+- An agent can read memory with `coder-studio memory list/search/get`.
+- An agent can write memory with `coder-studio memory add/update/delete`.
+- The built-in Memory Skill is available to supported providers by default and
+ explains how to use the CLI.
+- Starting a new agent session does not inject memory entry content by default.
+- The Memory panel follows existing workbench typography tokens and flat side
+ panel styling.
diff --git a/docs/superpowers/specs/2026-06-13-agent-canvas-design.md b/docs/superpowers/specs/2026-06-13-agent-canvas-design.md
new file mode 100644
index 000000000..25d0a8d13
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-13-agent-canvas-design.md
@@ -0,0 +1,769 @@
+# Agent Canvas Design
+
+> Status: Draft for user review
+> Date: 2026-06-13
+> Scope: `packages/core`, `packages/server`, `packages/web`, `packages/cli`
+
+## Problem
+
+Coder Studio already has several pieces that are adjacent to a canvas system:
+
+- persisted workspace editor tabs for files and browser tabs
+- a built-in preview surface for markdown and HTML
+- agent-triggered UI actions for opening files and browser URLs
+- workspace-level persistence for open editor state
+
+What it does not have is a first-class agent artifact model.
+
+Today an agent can explain things in chat, open files, or open a localhost page,
+but it cannot create a durable, structured artifact that:
+
+- opens directly as an editor tab
+- has a stable identity in the workspace
+- is backed by editable source
+- renders through a product-owned runtime instead of arbitrary agent HTML
+- can be updated by the agent and re-rendered by the system
+
+The goal of this design is to add a Cursor Canvas-like capability to Coder
+Studio, but aligned to Coder Studio's existing editor model. The primary entry
+point should be an editor tab, not a chat card.
+
+## Goals
+
+- Add a first-class canvas artifact model that agents can create and update.
+- Make canvas artifacts open as editor tabs inside the existing workspace
+ editor surface.
+- Persist canvas source as real workspace files that users can open and edit.
+- Keep agent output structured and typed. Agents must not emit raw HTML.
+- Support two first-class artifact types in v1:
+ - `architecture_canvas`
+ - `report_canvas`
+- Compile canvas source through a server-owned runtime before rendering.
+- Re-render canvas tabs automatically when the backing source file changes.
+- Keep the design extensible for more artifact types later.
+
+## Non-Goals
+
+- Do not make the canvas primary entry point a chat card.
+- Do not let agents emit arbitrary raw HTML as the source contract.
+- Do not add general whiteboard editing or freeform drag-and-drop editing in
+ v1.
+- Do not add sharing, publishing, permissions, or exports in v1.
+- Do not add arbitrary embedded scripts or user-authored runtime code in v1.
+- Do not add complex patch or OT/CRDT editing semantics in v1.
+- Do not make canvas a separate workspace surface outside the editor tab
+ system in v1.
+
+## Current Context
+
+Relevant current code:
+
+- `packages/core/src/domain/types.ts` defines workspace UI state and the
+ persisted editor-tab union.
+- `packages/web/src/features/workspace/atoms/files.ts` mirrors the editor-tab
+ model and owns editor surface atoms.
+- `packages/web/src/features/code-editor/views/shared/editor-surface.tsx`
+ routes between file and browser editor states.
+- `packages/web/src/features/code-editor/views/shared/code-editor-tabs-header.tsx`
+ renders mixed editor tabs.
+- `packages/core/src/domain/ui-actions.ts` defines agent-dispatchable UI
+ actions such as opening files and browser tabs.
+- `packages/web/src/features/ui-actions/use-ui-action-subscription.ts`
+ applies server-dispatched UI actions in the frontend.
+- `packages/web/src/features/code-editor/preview/api.ts` and preview
+ components already provide an internal preview rendering path.
+
+These existing pieces make a canvas system feasible without inventing a second
+editor container or second persistence model.
+
+## User Decisions Captured
+
+The following decisions were fixed during brainstorming:
+
+- The product should support a feature similar to Cursor Canvas.
+- The canvas base rendering technology can be HTML, but the agent must not
+ output raw HTML directly.
+- The agent should output standardized source, and the canvas runtime should
+ own rendering.
+- v1 should prioritize:
+ - architecture / flow diagrams
+ - reports / tables / audit views
+- v1 should use separate artifact types instead of one giant universal schema.
+- The source contract should be a JSON envelope with typed payloads. Diagram
+ content may use an inner DSL.
+- Canvas creation and updates should be explicit agent actions, not automatic
+ message parsing.
+- Users should be able to open and edit canvas source.
+- Canvas source should be stored as real workspace files.
+- Canvas should be a persisted workspace object.
+- The primary entry point should be an editor tab, not a chat card.
+
+## Approaches Considered
+
+### Option A: File-backed source + server compile + editor-tab viewer
+
+Agent calls explicit canvas commands. Server validates and persists typed
+source files. Server compiles source into a render model. Frontend opens a
+dedicated canvas editor tab and allows source editing via a regular file tab.
+
+Pros:
+
+- Clear product and protocol boundary.
+- Fits the user's requirement that source is editable as a real file.
+- Centralizes validation and compilation.
+- Keeps source semantics and view semantics separate.
+- Leaves room for future export, sharing, snapshots, and refresh workflows.
+- Fits the existing editor-tab and workspace persistence model.
+
+Cons:
+
+- Requires a new server-side runtime layer.
+- Requires coordinated changes across core, server, web, and CLI.
+
+Decision: accept.
+
+### Option B: File-backed source + frontend-only rendering from raw source
+
+Persist source as files, but let the browser interpret source directly and
+render without a server compilation step.
+
+Pros:
+
+- Faster to prototype.
+- Less backend work initially.
+
+Cons:
+
+- Pushes validation and compilation logic into the UI.
+- Makes server-side reuse harder later.
+- Weakens a future path to exports, remote rendering, and stable diagnostics.
+
+Decision: reject for v1.
+
+### Option C: Internal canvas store first, project files optional later
+
+Make canvas an internal product object first and optionally sync it to files
+later.
+
+Pros:
+
+- Maximum product flexibility long term.
+- Hides internal runtime details from the project tree.
+
+Cons:
+
+- Conflicts with the explicit user decision that source should be a real file.
+- Makes user editing and Git-based inspection harder.
+- Increases persistence and migration complexity.
+
+Decision: reject for v1.
+
+## Final Design
+
+## 1. High-Level Architecture
+
+The v1 architecture is:
+
+`agent -> canvas.create/update -> server validate/persist -> web opens canvas tab -> canvas route fetches compiled data`
+
+The core boundary is:
+
+- agent writes structured source, not HTML
+- source is persisted as a workspace file
+- server validates and compiles into a render model
+- frontend renders the compiled artifact in a dedicated canvas tab route
+- users edit source through a normal file editor tab
+
+Canvas is therefore a first-class editor artifact, not a transient preview and
+not a chat attachment.
+
+## 2. Persisted Source and Metadata Model
+
+V1 uses two related but distinct concepts:
+
+### 2.1 Canvas source file
+
+This is the source of truth for artifact content and is stored in the
+workspace, for example:
+
+`.coder-studio/canvases/.canvas.json`
+
+The user can open and edit this file directly.
+
+### 2.2 Canvas record
+
+This is product metadata used for indexing, reopen behavior, status, and tab
+identity. It should not replace the source file.
+
+Recommended record shape:
+
+```ts
+interface CanvasRecord {
+ id: string;
+ workspaceId: string;
+ sessionId?: string;
+ sourcePath: string;
+ artifactType: "architecture_canvas" | "report_canvas";
+ title: string;
+ updatedAt: number;
+ renderStatus: "ready" | "error" | "rendering";
+ lastError?: CanvasRenderError | null;
+}
+```
+
+The source file owns content. The canvas record owns indexing and runtime
+state.
+
+## 3. Source Contract
+
+All canvas source files share one common outer envelope:
+
+```json
+{
+ "version": 1,
+ "kind": "architecture_canvas",
+ "title": "Workspace Runtime Architecture",
+ "document": {}
+}
+```
+
+or:
+
+```json
+{
+ "version": 1,
+ "kind": "report_canvas",
+ "title": "Workspace Audit Report",
+ "document": {}
+}
+```
+
+Required properties:
+
+- `version`
+- `kind`
+- `title`
+- `document`
+
+This shared envelope provides:
+
+- schema routing on the server
+- forward-compatible versioning
+- a stable editing target for users
+- a clean place to add more artifact types later
+
+## 4. Artifact Types
+
+### 4.1 `architecture_canvas`
+
+This artifact type is optimized for architecture diagrams and flow diagrams.
+
+It should use a JSON envelope plus a tightly scoped inner diagram DSL.
+
+Recommended shape:
+
+```json
+{
+ "version": 1,
+ "kind": "architecture_canvas",
+ "title": "Coder Studio Workspace Flow",
+ "document": {
+ "summary": "How agent actions move from web to server runtime.",
+ "diagram": {
+ "dsl": "graph_v1",
+ "source": "service WebUI -> command Server\ncommand Server -> runtime ProviderRuntime"
+ },
+ "annotations": [
+ {
+ "title": "Execution boundary",
+ "body": "Server owns command execution."
+ }
+ ]
+ }
+}
+```
+
+Recommended required or stable fields:
+
+- `summary`
+- `diagram.dsl`
+- `diagram.source`
+- `annotations[]`
+
+V1 should not attempt to support a broad third-party DSL surface such as the
+full Mermaid or PlantUML feature set. Instead, it should define a small,
+product-owned `graph_v1` dialect or AST subset that covers:
+
+- node
+- edge
+- direction
+- group / subgraph
+- label
+- simple style variants
+
+This keeps generation stable, validation strict, and rendering predictable.
+
+### 4.2 `report_canvas`
+
+This artifact type is optimized for reports, audits, stats, tables, and
+structured summaries.
+
+It should use pure structured JSON blocks instead of a nested DSL.
+
+Recommended shape:
+
+```json
+{
+ "version": 1,
+ "kind": "report_canvas",
+ "title": "Workspace Audit Report",
+ "document": {
+ "summary": "Audit of the current monorepo state.",
+ "stats": [
+ { "label": "Packages", "value": 6 },
+ { "label": "Failing Checks", "value": 2, "tone": "danger" }
+ ],
+ "sections": [
+ {
+ "title": "Key Findings",
+ "blocks": [
+ {
+ "type": "list",
+ "items": [
+ "Server commands are cohesive.",
+ "UI action path already exists."
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+V1 block types should be intentionally small:
+
+- `markdown`
+- `stats`
+- `list`
+- `table`
+- `callout`
+
+The design should not support arbitrary nested layout grammars in v1.
+
+## 5. Agent-Facing Command Interface
+
+V1 should expose three core commands:
+
+1. `canvas.create`
+2. `canvas.update`
+3. `canvas.render`
+
+Only the first two are primary agent entry points. `canvas.render` mainly
+exists for system-triggered re-render flows such as user-edited source files.
+
+### 5.1 `canvas.create`
+
+Recommended input:
+
+```json
+{
+ "workspaceId": "ws_123",
+ "sessionId": "sess_123",
+ "title": "Workspace Runtime Architecture",
+ "kind": "architecture_canvas",
+ "document": {
+ "summary": "How requests move through the runtime.",
+ "diagram": {
+ "dsl": "graph_v1",
+ "source": "service Web -> server Dispatch\nserver Dispatch -> runtime Commands"
+ },
+ "annotations": [
+ {
+ "title": "Execution boundary",
+ "body": "Server owns command execution."
+ }
+ ]
+ },
+ "openInEditor": true
+}
+```
+
+Behavior:
+
+- generate `canvasId`
+- persist source file
+- validate and compile
+- create or update canvas record
+- open a canvas editor tab when requested
+- return `canvasId`, `sourcePath`, `renderStatus`
+
+### 5.2 `canvas.update`
+
+Recommended input:
+
+```json
+{
+ "workspaceId": "ws_123",
+ "canvasId": "canvas_456",
+ "title": "Workspace Runtime Architecture v2",
+ "document": {
+ "summary": "Updated flow.",
+ "diagram": {
+ "dsl": "graph_v1",
+ "source": "service Web -> commandBus Dispatch\ncommandBus Dispatch -> runtime Commands"
+ },
+ "annotations": []
+ }
+}
+```
+
+V1 semantics should be full-document replacement:
+
+- overwrite the source file with the new document
+- re-run validation and compile
+- refresh any open canvas tab for that artifact
+
+V1 should not add JSON Patch, block-level patching, or multi-author merge
+semantics.
+
+### 5.3 `canvas.render`
+
+This command exists mainly for product-owned workflows:
+
+- source file changed on disk
+- source file saved by the user
+- server wants to refresh render state
+
+V1 does not require agents to call this directly.
+
+### 5.4 Update type constraints
+
+V1 should not allow cross-type mutation. For example:
+
+- `architecture_canvas` cannot be updated into `report_canvas`
+- `report_canvas` cannot be updated into `architecture_canvas`
+
+This keeps the state model and compiler selection simple.
+
+## 6. User Editing Flow
+
+The user should be able to open the backing source file and edit it directly.
+
+Expected flow:
+
+1. Canvas opens as a canvas tab.
+2. User chooses `Open Source`.
+3. The backing `.canvas.json` file opens in a normal file editor tab.
+4. User saves the file.
+5. Server detects the change and triggers re-render.
+6. The corresponding canvas tab refreshes.
+
+If rendering fails:
+
+- the source file remains as authored
+- the canvas tab enters an error state
+- the user can keep editing the source and retry by saving or re-rendering
+
+V1 should not silently roll back source edits.
+
+## 7. Runtime Pipeline
+
+The canvas runtime should have three explicit layers:
+
+1. `validate`
+2. `compile`
+3. `render`
+
+The internal data flow is:
+
+`source json -> typed document -> compiled canvas model -> frontend route render`
+
+V1 should not collapse this into a direct `json -> html` transformation.
+
+### 7.1 Validate
+
+Validation responsibilities:
+
+- parse JSON
+- validate outer envelope
+- route by `kind` and `version`
+- validate artifact-specific schema
+- reject illegal raw HTML source payloads
+
+Examples:
+
+- missing `diagram.source`
+- unknown report block type
+- malformed version
+
+### 7.2 Compile
+
+Compilation converts typed documents into a unified render model.
+
+Examples:
+
+- `architecture_canvas` compiles to diagram data plus annotation panels
+- `report_canvas` compiles to a block tree with stats, sections, tables, and
+ callouts
+
+The compiled model is the compatibility boundary between schema semantics and
+frontend presentation.
+
+### 7.3 Render
+
+Rendering is owned by the frontend canvas route and its renderer components.
+
+The backend should not maintain a per-canvas HTML output cache in v1. Instead:
+
+- the backend owns source validation
+- the backend owns source-to-model compilation
+- the frontend route owns turning the compiled model into visible UI
+
+The recommended transport shape is:
+
+- persisted source: `.coder-studio/canvases/.canvas.json`
+- canvas metadata: `CanvasRecord`
+- compiled response: `GET /api/canvas/:workspaceId/:canvasId/data`
+
+This keeps HTML as a product implementation detail rather than a persisted or
+server-cached artifact.
+
+## 8. Error Model
+
+V1 should distinguish at least three error categories:
+
+- `validation_error`
+- `compile_error`
+- `render_error`
+
+Examples:
+
+- `validation_error`: `document.diagram.source is required`
+- `compile_error`: diagram edge references a missing node
+- `render_error`: frontend renderer fails to mount a valid compiled model
+
+Recommended UX:
+
+- do not blank the source file or roll back
+- show a clear canvas-tab error state
+- include a field path and human-readable message
+- prefer catching issues in validation or compile rather than render
+
+V1 should keep error handling simple and not try to show a stale last-good
+render underneath the error state.
+
+## 9. Frontend Integration
+
+### 9.1 Canvas becomes a third editor-tab kind
+
+Today the persisted editor tab model only includes file and browser tabs.
+V1 should extend it with a third type:
+
+```ts
+interface WorkspaceCanvasEditorTab {
+ kind: "canvas";
+ id: string;
+ canvasId: string;
+ title: string;
+ artifactType: "architecture_canvas" | "report_canvas";
+ sourcePath: string;
+}
+```
+
+`WorkspaceEditorTab` then becomes:
+
+- `file`
+- `browser`
+- `canvas`
+
+This change must be reflected in both:
+
+- `packages/core/src/domain/types.ts`
+- `packages/web/src/features/workspace/atoms/files.ts`
+
+### 9.2 Open behavior
+
+Canvas should open as an editor tab in the existing main editor surface.
+
+The primary flow should be:
+
+`canvas.create/update -> server dispatches canvas.open -> frontend inserts canvas tab`
+
+The product should not depend on a chat card as the primary entry point.
+
+### 9.3 Editor surface routing
+
+The current editor surface already routes based on active editor state. V1
+should extend that routing:
+
+- `file` -> existing Monaco / preview / diff flow
+- `browser` -> existing dev-browser surface
+- `canvas` -> new `CanvasSurface`
+
+`CanvasSurface` should support at least:
+
+- `iframe`-hosted canvas display
+- `Open Source`
+- `Re-render`
+- clear error state display
+
+V1 should not embed a split source editor inside the canvas tab itself.
+
+### 9.4 Canvas rendering route
+
+Canvas rendering should still use `iframe` isolation, but the `iframe` should
+point at a frontend-owned route rather than a backend HTML artifact endpoint.
+
+Recommended shape:
+
+- editor tab renders `CanvasSurface`
+- `CanvasSurface` mounts an `iframe`
+- `iframe src` points to a web route such as
+ `/embedded/canvas/:workspaceId/:canvasId`
+- that route loads a dedicated React entry for canvas rendering
+- the route requests `GET /api/canvas/:workspaceId/:canvasId/data`
+- the route chooses a renderer by `kind`
+
+This gives v1:
+
+- style isolation from the main product shell
+- product-owned rendering without arbitrary agent HTML
+- a clear path for richer frontend interactions later
+
+### 9.5 Canvas renderer boundary
+
+The frontend route should render from compiled data, not directly from raw
+source JSON.
+
+Recommended boundary:
+
+- source contract is for agent output and user editing
+- compiled model is for renderer consumption
+
+This keeps renderer complexity lower and avoids coupling the web runtime to
+every source-schema detail or future DSL evolution.
+
+### 9.6 Tab UI
+
+The existing tab header can be extended rather than replaced.
+
+Canvas tabs should:
+
+- display a canvas-specific icon
+- display the canvas title
+- optionally display a compact artifact marker such as `ARCH` or `REPORT`
+- use the existing activate and close patterns
+
+### 9.7 Reopen and listing
+
+Because canvas is persisted, the workspace should provide a secondary reopen
+path.
+
+V1 should include at least one lightweight reopen mechanism, such as:
+
+- a `Canvases` section in the explorer/sidebar, or
+- command palette / quick open support for canvases
+
+V1 does not require a complex canvas management page.
+
+## 10. Protocol Surface
+
+The existing UI action protocol already handles opening files and browser tabs.
+Canvas should not be modeled as a browser URL in the workspace tab model, even
+though it uses an `iframe` internally for isolation.
+
+V1 should add a parallel canvas-specific action, for example:
+
+- `canvas.open`
+- optionally `canvas.close`
+
+This avoids overloading the meaning of `browser.openUrl` and keeps tab
+semantics correct.
+
+In addition to websocket commands, the runtime should expose a read-only data
+endpoint for the embedded renderer:
+
+- `GET /api/canvas/:workspaceId/:canvasId/data`
+
+Recommended response:
+
+```ts
+interface CanvasDataResponse {
+ canvasId: string;
+ workspaceId: string;
+ title: string;
+ kind: "architecture_canvas" | "report_canvas";
+ renderStatus: "ready" | "error";
+ lastError?: CanvasRenderError | null;
+ compiledDocument?: CompiledCanvas;
+}
+```
+
+`canvas.render` remains useful, but its v1 job is to force a fresh
+validate/compile pass and update status. It should not return or cache full
+HTML output.
+
+## 11. Persistence and Workspace State
+
+Canvas tabs should participate in persisted workspace editor state in the same
+way browser tabs do.
+
+That means:
+
+- open canvas tabs persist across refresh
+- active canvas tab persists across refresh
+- the canvas tab restores by `canvasId` and `sourcePath`
+- runtime render state is rebuilt from persisted source and metadata
+- iframe route state is disposable and derived on demand
+
+Short-lived render caches should not become the source of truth. In the
+recommended route-based design, v1 does not need a server-side HTML cache at
+all.
+
+## 12. Testing Strategy
+
+At minimum, add tests for:
+
+1. schema validation for both artifact types
+2. compiler outputs for valid architecture and report documents
+3. validation and compile failures with clear error payloads
+4. `canvas.create` persisting source and returning metadata
+5. `canvas.update` replacing the document and refreshing the tab
+6. `GET /api/canvas/:workspaceId/:canvasId/data` returning compiled data and
+ error states
+7. opening a canvas as a new editor tab
+8. restoring persisted canvas tabs after refresh
+9. opening and editing source files followed by automatic re-render
+10. iframe canvas route rendering the correct renderer by `kind`
+11. canvas tab error-state rendering after invalid source edits
+
+The most important v1 stability investment is in schema and compiler tests.
+
+## 13. Acceptance Criteria
+
+- an agent can explicitly create a canvas artifact through a command interface
+- the resulting artifact opens as a new canvas editor tab
+- canvas source is stored as a real workspace file
+- the user can open and edit the source file directly
+- saving the source file triggers re-render of the associated canvas tab
+- the agent never needs to emit raw HTML
+- the canvas tab renders through a frontend-owned route fed by compiled canvas
+ data
+- v1 supports both `architecture_canvas` and `report_canvas`
+- invalid source yields a clear canvas error state without destroying user
+ edits
+- canvas tabs participate in persisted workspace editor state
+
+## 14. Rollout Notes
+
+Implement this incrementally:
+
+1. core types and protocol
+2. server create/update/render commands and persistence
+3. server data endpoint plus validation/compile pipeline
+4. frontend iframe route and canvas renderer support
+5. source-edit re-render loop
+6. lightweight canvas reopen entry
+
+This keeps the work decomposed while preserving the core contract.
diff --git a/docs/superpowers/specs/2026-06-13-dev-browser-device-mode-design.md b/docs/superpowers/specs/2026-06-13-dev-browser-device-mode-design.md
new file mode 100644
index 000000000..ed898a6ae
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-13-dev-browser-device-mode-design.md
@@ -0,0 +1,235 @@
+# Dev Browser Device Mode Design
+
+## Goal
+
+Add a first-pass device mode to the dev browser so a browser tab can reopen the proxied page with a mobile or desktop user agent and render it inside a configurable device-sized viewport with portrait or landscape orientation.
+
+## Scope
+
+This design covers:
+
+- per-browser-tab device configuration
+- device viewport width and height controls
+- portrait and landscape switching
+- desktop or mobile user agent selection
+- server-side `User-Agent` forwarding through the dev-browser proxy
+- lightweight client-side navigator overrides for common device detection
+
+This design does not cover:
+
+- DPR simulation
+- touch event emulation
+- `navigator.userAgentData`
+- CPU or network throttling
+- browser chrome skinning or decorative phone frames
+- exact emulation of Chrome DevTools protocol behavior
+
+## Current Architecture
+
+The dev browser is represented as a `WorkspaceBrowserEditorTab` and rendered by `packages/web/src/features/dev-browser/dev-browser-surface.tsx`. Opening a URL creates a dev-browser session through `POST /api/dev-proxy/session`, then mounts an iframe pointed at `session.browserUrl`.
+
+The backend proxy is implemented in `packages/server/src/routes/dev-browser.ts`. The server session currently stores target URL metadata and routes proxied HTTP and WebSocket traffic to the target origin.
+
+Workspace UI state persistence already stores browser tabs in `openEditorTabs` and `activeEditorTab`, which makes browser-tab-local device settings the right persistence boundary.
+
+## Requirements
+
+### Functional requirements
+
+1. A browser tab can be configured independently from other browser tabs, even when multiple tabs point to the same URL.
+2. The dev browser toolbar exposes a device preset, viewport width, viewport height, orientation toggle, and apply action.
+3. Applying a device configuration reopens the dev browser session with the updated user agent.
+4. The preview iframe renders inside a centered scaled viewport that preserves the configured logical width and height.
+5. Portrait and landscape mode swap the logical viewport dimensions.
+6. HTTP proxy requests forward the configured `User-Agent`.
+7. WebSocket proxy handshakes also forward the configured `User-Agent`.
+8. The injected bootstrap script overrides a small set of navigator fields so client-side device checks align with the selected mode well enough for common responsive workflows.
+9. Device settings survive workspace refresh because they persist with the browser tab.
+
+### Non-functional requirements
+
+1. The first version must stay within the existing dev-browser architecture.
+2. Device configuration changes may refresh the page and recreate the session.
+3. The implementation must remain compatible with multiple concurrent dev-browser tabs.
+4. The UI must not trigger a session rebuild on every keystroke while editing width or height.
+
+## Proposed Design
+
+### 1. State model
+
+Extend `WorkspaceBrowserEditorTab` in both `packages/core/src/domain/types.ts` and `packages/web/src/features/workspace/atoms/files.ts` with a device configuration payload:
+
+- `devicePreset: "desktop" | "iphone-14" | "pixel-7" | "custom"`
+- `viewportWidth: number | null`
+- `viewportHeight: number | null`
+- `orientation: "portrait" | "landscape"`
+- `userAgentMode: "desktop" | "mobile"`
+
+These fields belong on the tab instead of global workspace UI state because:
+
+- multiple browser tabs already exist
+- duplicate URLs in separate tabs are supported
+- the user may want desktop and mobile previews open simultaneously
+
+The tab stores the selected mode and viewport dimensions, not an opaque raw user-agent string. The actual UA string is derived from the preset or mode when opening the session. This keeps persisted state stable if the default UA templates change later.
+
+### 2. Device presets
+
+The first version should ship with a minimal preset set:
+
+- `desktop`
+- `iphone-14`
+- `pixel-7`
+- `custom`
+
+Each preset maps to:
+
+- a default viewport width
+- a default viewport height
+- a user-agent mode
+
+`custom` preserves the current editable width and height values and does not auto-reset them after the user changes dimensions manually.
+
+### 3. Toolbar behavior
+
+The existing `dev-browser-toolbar` remains the control surface. It gains:
+
+- a preset selector
+- width input
+- height input
+- orientation toggle button
+- apply button
+
+Behavior rules:
+
+- changing presets fills width, height, and user-agent mode
+- changing orientation swaps width and height
+- width and height edits only update local form state until the user applies changes
+- opening a new URL or applying device changes uses the same session creation path
+
+This keeps the interaction predictable and avoids recreating the session while the user is still typing values.
+
+### 4. Viewport rendering
+
+The preview area changes from a full-bleed iframe to a three-layer structure:
+
+- shell: fills the editor area
+- stage: centers the preview and computes scaling
+- iframe viewport: fixed logical device width and height
+
+The iframe keeps the configured logical dimensions. When the editor area is smaller than the logical device viewport, the stage applies a uniform scale transform so the full page remains visible. When there is enough room, the scale is `1`.
+
+This mirrors the useful part of Chrome device mode: a fixed logical viewport shown inside a responsive outer canvas.
+
+### 5. Session creation contract
+
+Extend the dev-browser session creation payload in `packages/web/src/features/dev-browser/api.ts` and `packages/server/src/routes/dev-browser.ts`.
+
+Current payload:
+
+- `url`
+
+Proposed payload:
+
+- `url`
+- `userAgent`
+
+The backend stores `userAgent` in the dev-browser session. The first version does not require the backend to interpret viewport dimensions for proxy behavior; those stay frontend-driven. If a future iteration needs more complete navigator or screen emulation, the request contract can be extended without redesigning the route family.
+
+### 6. Proxy request behavior
+
+On HTTP proxy requests, the server already filters incoming headers before forwarding. After that filtering step, if the session carries a configured user agent, the proxy sets the outbound `user-agent` header to that configured value.
+
+On WebSocket proxy requests, the request option builder should accept an override user agent so the handshake mirrors the selected mode.
+
+This gives the upstream target a consistent identity across HTTP and WebSocket connections.
+
+### 7. Client-side navigator overrides
+
+The current HTML bootstrap script injected by the proxy already patches fetch, XHR, EventSource, history, and WebSocket behavior. Extend this bootstrap to expose a lightweight device-mode object derived from the session and override a small navigator surface:
+
+- `navigator.userAgent`
+- `navigator.platform`
+- `navigator.maxTouchPoints`
+
+Desktop mode should keep touch points at `0` and use desktop-oriented platform values. Mobile mode should expose a mobile UA string, a mobile platform value, and a non-zero touch-point count.
+
+This is intentionally limited. The goal is to align common client-side checks, not to build a full browser fingerprint emulation layer.
+
+### 8. Session lifecycle
+
+Changing device configuration recreates the dev-browser session.
+
+This is the preferred first implementation because:
+
+- it fits the current session model
+- it avoids adding a session mutation API
+- it keeps backend state management simple
+
+Trade-off:
+
+- page memory state is lost when the user applies a new device mode
+
+That is acceptable for the first version because the feature is primarily for preview and responsive validation rather than in-page workflow continuity.
+
+## Data Flow
+
+1. User edits device settings in the toolbar.
+2. Local component state tracks in-progress form values.
+3. User clicks apply.
+4. The browser tab object is updated with the new device configuration and persisted through existing workspace UI state persistence.
+5. The frontend derives the selected user-agent string from the chosen mode or preset.
+6. The frontend calls session creation with the URL and derived user agent.
+7. The backend stores the user agent in the session.
+8. The iframe reloads against the new session URL.
+9. Proxied HTTP and WebSocket traffic use the configured user agent.
+10. Injected bootstrap script overrides common navigator fields for the page context.
+
+## Error Handling
+
+- Invalid width or height values should block apply and keep the existing session intact.
+- Width and height must be positive integers inside a conservative supported range.
+- If session recreation fails, the previous persisted tab settings may remain updated, but the UI should surface the existing dev-browser error notice and avoid hiding the failure.
+- Navigator override failures inside the bootstrap script must fail open and leave the page usable.
+
+## Testing Strategy
+
+### Frontend
+
+- extend browser-tab normalization and persistence tests to cover the new tab fields
+- add dev-browser surface tests for preset selection, orientation swap, width and height edits, and apply behavior
+- add viewport rendering tests that verify fixed logical dimensions and scaling class or style behavior
+
+### Backend
+
+- extend dev-browser route tests to verify session creation accepts `userAgent`
+- verify proxied HTTP requests receive the overridden `User-Agent`
+- verify proxied WebSocket requests receive the overridden `User-Agent`
+- verify bootstrap injection includes navigator override logic
+
+## Risks
+
+1. Some targets may rely on `navigator.userAgentData` or more detailed browser APIs. The first version will not match those checks.
+2. Recreating sessions on apply causes page reloads, which may be noisy for pages with expensive boot sequences.
+3. Scaling math can produce poor visuals if the shell layout does not provide clean centering and overflow boundaries.
+
+## Open decisions resolved for this version
+
+- Device settings persist per browser tab, not globally.
+- Device changes apply through explicit user action, not live keystroke updates.
+- The backend stores and forwards a concrete user-agent string.
+- The first release supports desktop and mobile modes with a small preset list.
+
+## Implementation boundaries
+
+Primary files expected to change in implementation:
+
+- `packages/core/src/domain/types.ts`
+- `packages/web/src/features/workspace/atoms/files.ts`
+- `packages/web/src/features/dev-browser/dev-browser-surface.tsx`
+- `packages/web/src/features/dev-browser/api.ts`
+- `packages/server/src/dev-browser/session-store.ts`
+- `packages/server/src/routes/dev-browser.ts`
+- related tests in `packages/web/src/features/dev-browser/` and `packages/server/src/routes/`
+
+The implementation should stay within `packages/web`, `packages/server`, and `packages/core`. No provider or CLI changes are required.
diff --git a/docs/superpowers/specs/2026-06-13-dev-browser-websocket-proxy-design.md b/docs/superpowers/specs/2026-06-13-dev-browser-websocket-proxy-design.md
new file mode 100644
index 000000000..80609a217
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-13-dev-browser-websocket-proxy-design.md
@@ -0,0 +1,304 @@
+# Dev Browser WebSocket Proxy Design
+
+> Status: Draft for user review
+> Date: 2026-06-13
+> Scope: `packages/server` dev-browser proxy routes and websocket tunneling, `packages/web` dev-browser bootstrap rewrite logic, related tests
+
+## Goal
+
+Add transparent WebSocket proxying to the existing dev-browser loopback proxy so a
+proxied Coder Studio page can establish its own runtime WebSocket connections when
+opened through the built-in browser.
+
+The design stays intentionally narrow: one dev-browser session continues to bind to
+one validated loopback `targetOrigin`, and only WebSocket connections that resolve
+to that origin are rewritten and proxied.
+
+## Current Problem
+
+The HTTP fallback proxy path now allows proxied pages to load even when the
+service-worker path is unavailable. HTML, CSS, JavaScript, images, form posts, and
+same-origin fetch/XHR requests can already flow through:
+
+```text
+/dev-browser/session/:id/proxy/*
+```
+
+However, WebSocket upgrade requests to that same route are still rejected with:
+
+```json
+{ "error": "websocket_not_supported" }
+```
+
+That leaves proxied Coder Studio pages in a partially working state: the UI can
+render, but the app's `/ws` connection fails and live runtime behavior does not
+recover.
+
+## User-Approved Scope
+
+The user approved these constraints for this iteration:
+
+1. Proxy only WebSocket traffic for the current session's single `targetOrigin`.
+2. Do transparent frame forwarding only. No message rewriting or protocol-aware
+ handling.
+3. Keep each WebSocket independent:
+ - one browser WebSocket becomes one proxy WebSocket
+ - one proxy WebSocket becomes one upstream WebSocket
+4. Keep Coder Studio's existing application WebSocket at `/ws` separate from the
+ dev-browser proxy path.
+5. Keep the current warning behavior, but only show the warning when the proxied
+ WebSocket connection actually fails.
+
+## In Scope
+
+- front-end WebSocket URL rewriting for the current dev-browser session target
+- server-side WebSocket upgrade handling on the existing dev-browser proxy route
+- transparent text-frame and binary-frame tunneling between browser and upstream
+- close and error propagation between proxied peer connections
+- focused tests for successful proxying and failure behavior
+
+## Out Of Scope
+
+- multi-origin or multi-port allowlists inside one dev-browser session
+- WebSocket message inspection or protocol translation
+- sharing or multiplexing multiple browser sockets onto one upstream socket
+- replacing Coder Studio's native `/ws` application transport
+- generalized external-host WebSocket proxying
+
+## Existing Context
+
+The current dev-browser session model already stores:
+
+- a validated loopback `targetOrigin`
+- the target path and hash for browser navigation
+- a proxy base rooted at `/dev-browser/session/:id/proxy`
+
+The fallback HTML bootstrap in `packages/server/src/routes/dev-browser.ts` already
+rewrites resource access for:
+
+- `fetch`
+- `XMLHttpRequest`
+- `EventSource`
+- `window.open`
+- HTML attributes, CSS `url(...)`, and ESM specifiers in proxied responses
+
+The same bootstrap currently wraps `window.WebSocket`, but only to observe failed
+loopback connections and show a warning banner. It does not rewrite those WebSocket
+URLs to the dev-browser proxy path.
+
+Coder Studio's own frontend runtime resolves its application socket as:
+
+```text
+ws(s):///ws
+```
+
+or, in dev mode, reaches the same path through the Vite dev server's own `/ws`
+proxy. That means a proxied Coder Studio page fits the single-`targetOrigin`
+design: the page host and the page's WebSocket entry stay under the same browser
+origin contract.
+
+## Design
+
+## 1. Preserve the Existing Route Family
+
+The feature should reuse the current dev-browser proxy URL family instead of
+introducing a second WebSocket-specific namespace.
+
+HTTP and WebSocket traffic both remain conceptually rooted at:
+
+```text
+/dev-browser/session/:id/proxy/*
+```
+
+Examples:
+
+```text
+GET /dev-browser/session/dev_1/proxy/app/
+WS /dev-browser/session/dev_1/proxy/ws
+WS /dev-browser/session/dev_1/proxy/socket.io/?EIO=4&transport=websocket
+```
+
+This keeps session lookup, target resolution, and access boundaries aligned across
+protocols.
+
+## 2. Rewrite Only WebSockets That Match the Session Target
+
+The browser bootstrap should change from "observe loopback WebSocket failure" to
+"rewrite matching target WebSockets to the proxy route, then observe the actual
+connection result."
+
+Rewrite rules:
+
+- if the constructed WebSocket URL resolves to the current session `targetOrigin`,
+ rewrite it to the session proxy path on the Coder Studio host
+- preserve pathname, search, and hash components
+- preserve the browser page's current `ws:` vs `wss:` scheme based on the outer
+ page origin
+- leave non-matching WebSocket URLs unchanged
+
+Given:
+
+```text
+session.targetOrigin = http://127.0.0.1:5173
+```
+
+Examples:
+
+```text
+ws://127.0.0.1:5173/ws
+ -> ws://studio.example/dev-browser/session/dev_1/proxy/ws
+
+ws://127.0.0.1:5173/socket.io/?EIO=4&transport=websocket
+ -> ws://studio.example/dev-browser/session/dev_1/proxy/socket.io/?EIO=4&transport=websocket
+
+ws://127.0.0.1:4173/ws
+ -> unchanged
+```
+
+The warning banner remains, but it should attach to the rewritten proxy-backed
+socket result instead of warning preemptively for every loopback-looking socket.
+
+## 3. Add Transparent WebSocket Tunneling on the Server
+
+The server should accept WebSocket upgrades on the dev-browser proxy path and map
+them to an upstream WebSocket URL derived from the same session target resolution
+used by HTTP proxy requests.
+
+Processing flow:
+
+1. receive browser upgrade at `/dev-browser/session/:id/proxy/*`
+2. load the dev-browser session by `id`
+3. resolve the requested proxy path and query string against the session target
+4. convert target protocol:
+ - `http:` -> `ws:`
+ - `https:` -> `wss:`
+5. establish a new upstream WebSocket client connection to that resolved URL
+6. once both sides are open, transparently forward frames in both directions
+7. propagate close and error conditions and clean up both peers
+
+This is a pure tunnel. The proxy must not parse, mutate, or reinterpret frame
+payloads.
+
+## 4. Keep Connection Independence
+
+No connection reuse or multiplexing should be introduced.
+
+The connection model is:
+
+```text
+1 browser WebSocket
+ -> 1 dev-browser proxy connection
+ -> 1 upstream WebSocket
+```
+
+Implications:
+
+- multiple `new WebSocket(...)` calls inside one proxied page create multiple
+ independent tunnels
+- multiple browser tabs or multiple dev-browser sessions create separate WebSocket
+ sets
+- refresh, close, or failure in one page affects only that page's own sockets
+
+This keeps the proxy behavior close to normal browser semantics and avoids coupling
+unrelated runtime streams.
+
+## 5. Preserve Failure Transparency
+
+The dev-browser proxy should not fake successful WebSocket connections when the
+upstream target is unavailable.
+
+Expected behavior:
+
+- HTML and HTTP resources remain independently loadable
+- if the proxy can connect upstream, no warning is shown
+- if the proxy cannot connect upstream, the browser-side socket fails normally and
+ the existing warning banner is shown
+- if the upstream closes, the browser receives the corresponding close event
+- if the browser closes, the proxy closes the upstream connection
+
+The warning remains user-facing diagnostics only. It does not change protocol
+semantics or suppress close/error delivery.
+
+## 6. Keep Coder Studio Application Transport Separate
+
+Coder Studio's own application WebSocket at `/ws` remains untouched.
+
+The dev-browser proxy must not attempt to route proxied-page traffic through the
+main application socket, because:
+
+- protocol payloads are unrelated
+- the main socket carries authenticated command and event traffic
+- transparent proxying requires one upstream socket per browser socket
+
+The feature therefore adds a separate WebSocket tunnel path under the dev-browser
+session route family, even though both route families live on the same Fastify
+server instance.
+
+## 7. Error Boundaries and Rejection Cases
+
+The server should reject or fail fast for these cases:
+
+- missing or expired dev-browser session
+- proxy path that cannot be resolved against the current session target
+- upstream connection refusal or network failure
+
+For the single-origin scope, the browser-side rewrite logic should avoid generating
+proxy URLs for WebSockets outside the current session target. Those sockets stay on
+their original URL and are not promoted to proxy traffic.
+
+## Package Boundaries
+
+### `packages/server`
+
+- extend `registerDevBrowserRoutes` and supporting helpers to handle WebSocket
+ upgrades on the existing proxy route family
+- reuse current session lookup and target URL resolution rules
+- add websocket-tunnel cleanup and upstream error handling
+- update route tests to cover WebSocket success and failure paths
+
+### `packages/web`
+
+- update the dev-browser fallback bootstrap so `window.WebSocket` rewrites only
+ URLs matching the current session target
+- keep the warning banner behavior but tie it to actual proxy-backed socket failure
+- add focused tests for URL rewriting and warning behavior expectations
+
+## Testing Strategy
+
+Add test coverage in the smallest layers that prove the tunnel behavior.
+
+### Server
+
+Use real WebSocket integration tests for the dev-browser route instead of only
+`app.inject`, because upgrade handling and bidirectional frame flow need live socket
+behavior.
+
+Cover at least:
+
+1. a proxied WebSocket can upgrade through the dev-browser route
+2. text frames flow browser -> upstream and upstream -> browser
+3. binary frames flow browser -> upstream and upstream -> browser
+4. missing sessions are rejected
+5. upstream connection failure causes the proxied socket to fail rather than hang
+6. close propagation works in both directions
+
+### Web
+
+Keep a focused bootstrap-level test that proves:
+
+1. a WebSocket URL matching the current session target is rewritten to the
+ session's proxy path
+2. a non-matching WebSocket URL is left alone
+3. the warning hook still exists for failed proxy-backed connections
+
+## Acceptance Criteria
+
+- a proxied Coder Studio page can establish its `/ws` connection through the
+ dev-browser proxy
+- WebSocket traffic uses the dev-browser session proxy route, not the main
+ application `/ws` socket
+- each browser WebSocket is tunneled through an independent upstream WebSocket
+- text and binary frames are forwarded without payload rewriting
+- WebSocket failure no longer blocks page load, but failed connections still
+ surface the warning banner
+- non-target WebSocket URLs are not silently widened into arbitrary local proxying
diff --git a/docs/superpowers/specs/2026-06-13-memory-panel-remove-selected-detail-design.md b/docs/superpowers/specs/2026-06-13-memory-panel-remove-selected-detail-design.md
new file mode 100644
index 000000000..5ac283206
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-13-memory-panel-remove-selected-detail-design.md
@@ -0,0 +1,120 @@
+# Memory Panel Remove Selected Detail Design
+
+> Date: 2026-06-13
+> Status: Draft
+> Owner: Codex
+
+## Problem
+
+当前工作区的 `MemoryPanel` 在同一个 side panel 中同时承载两类职责:
+
+- 上半部分负责搜索、筛选、浏览和删除记忆条目
+- 下半部分负责展示“选中记忆”详情,并允许直接编辑和保存
+
+这和当前目标不一致。用户要求把截图中位于记忆 side panel 下方的“选中记忆”模块整个移除,只保留记忆列表和现有的新建入口。
+
+## Goal
+
+移除 `MemoryPanel` 中列表下方的“选中记忆 / 未选中记忆”详情模块,保证:
+
+- 记忆 side panel 不再渲染详情编辑区
+- 面板内不再显示保存按钮、详情表单和详情 footer
+- 现有的搜索、类型筛选、列表浏览、新建和删除能力继续可用
+- 改动保持在 `packages/web` 范围内,不扩散到 server 或 shared contract
+
+## Non-Goals
+
+- 不删除整个 `MemoryPanel`
+- 不修改 `memory.create`、`memory.delete` 或 `memory.list` 的协议
+- 不新增新的编辑入口
+- 不移除“新建记忆”弹窗里的表单
+- 不重构记忆数据模型、国际化结构或 side panel 外层布局
+
+## Decision
+
+采用最小删除方案,只移除 `MemoryPanel` 下半部分详情区和与其直接耦合的保存编辑流程。
+
+不采用以下替代方案:
+
+1. 同时移除列表选中态
+原因:这会额外改变列表交互,不属于这次请求的最小范围。
+
+2. 保留详情区结构但隐藏内容
+原因:会留下无用 DOM、样式和状态逻辑,后续维护成本更高。
+
+3. 顺手删除更新能力的所有底层代码
+原因:当前需求只针对 side panel 展示层,过度清理会扩大影响面,并可能误伤未来其它编辑入口。
+
+## Scope
+
+### In Scope
+
+- 删除 `packages/web/src/features/workspace/views/shared/memory-panel.tsx` 中详情区渲染
+- 删除该组件中仅用于详情编辑区的本地状态、派生值和保存事件
+- 更新 `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx`,移除详情区相关断言,保留列表、新建、删除和筛选覆盖
+
+### Out of Scope
+
+- `packages/web/src/features/workspace/actions/use-memory-panel.ts`
+- server `memory.*` commands
+- locale 文案清理
+- 记忆列表样式的大规模重排
+
+## Target UI Shape
+
+删除后,`MemoryPanel` 保留以下结构:
+
+- 顶部标题区
+- 错误提示
+- 搜索框
+- 类型筛选 chips
+- 记忆列表
+- 新建记忆弹窗
+- 删除记忆确认弹窗
+
+以下结构从 side panel 中移除:
+
+- “Selected Memory / No memory selected” 详情区标题
+- 详情区保存按钮
+- 类型、标题、内容、标签编辑表单
+- 来源和更新时间 footer
+- 未选中状态下的详情占位文案
+
+## Behavior After Removal
+
+- 点击列表项仍可保留当前选中高亮
+- 点击列表项不再展示下方详情编辑内容
+- 新建记忆继续通过现有 modal 表单完成
+- 删除记忆继续通过现有确认弹窗完成
+- `memory.update` 不再从这个 side panel 触发
+
+## Implementation Notes
+
+- 优先直接删除详情区 JSX,而不是保留条件分支
+- 删除 `selectedEntry` 详情渲染后,保留是否继续维护 `selectedId` 仅用于高亮,由实现阶段根据最小改动原则决定
+- 仅在测试中保留仍然对外可见的行为断言,避免继续断言已删除的保存或详情内容
+
+## Testing
+
+需要通过与这次改动直接相关的前端测试,至少覆盖:
+
+- `MemoryPanel` 仍能加载并显示记忆列表
+- 搜索和类型筛选仍为本地过滤
+- 新建记忆流程仍可用
+- 删除记忆流程仍可用
+- 不再渲染“Selected Memory”详情区和保存按钮
+
+## Risks
+
+- 如果只删 JSX,不删依赖详情区的测试,前端测试会失败
+- 如果误删过多状态逻辑,可能影响列表选中高亮或创建后选中行为
+- 如果顺手清理超出展示层范围,容易碰到已有未提交改动并引入不必要冲突
+
+## Validation
+
+完成实现后应满足:
+
+- 记忆 side panel 中不再出现详情编辑区
+- 新建和删除入口仍正常工作
+- 列表仍可正常加载、搜索和筛选
+- `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx` 通过
diff --git a/docs/superpowers/specs/2026-06-14-memory-title-removal-design.md b/docs/superpowers/specs/2026-06-14-memory-title-removal-design.md
new file mode 100644
index 000000000..d23c71952
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-14-memory-title-removal-design.md
@@ -0,0 +1,223 @@
+# Memory Title Removal Design
+
+> Date: 2026-06-14
+> Status: Draft for user review
+> Owner: Codex
+
+## Problem
+
+当前 workspace memory 系统把 `title` 当成一等字段,贯穿 shared contract、server command、storage、automation capability 和 web side panel。
+
+这带来两类问题:
+
+- UI 上同一条记忆同时显示标题和内容摘要,信息层次割裂,创建表单也多出一个低价值输入。
+- 数据层要求 title 存在,导致 agents、skills、CLI 和 UI 都必须人为生成一个名字,即使真正有价值的信息已经在 `content` 内。
+
+用户要求把 memory title 完整移除,不只是隐藏 UI,而是让 memory entry 只保留内容本身和现有结构化元数据。
+
+## Goal
+
+彻底移除 workspace memory 的 `title` 字段,并保证:
+
+- memory entry 数据模型不再包含 `title`
+- `memory.create`、`memory.update`、automation capability、builtin memory skill 不再要求或提及 `title`
+- web memory panel 和新建 modal 不再显示标题输入或标题展示
+- memory list、删除按钮和删除确认统一改用截断后的 `content` 预览作为可读标识
+- 已经持久化到磁盘的旧 memory 文件在读取后会被规范化为无 `title` 结构,并在后续写回时完成迁移
+
+## Non-Goals
+
+- 不改变 memory entry 的 `type`、`content`、`tags`、`source`、归档语义或 workspace 作用域
+- 不引入新的“摘要”“名称”或自动生成标题字段
+- 不扩大为 memory 体验的全面重设计
+- 不保留长期的双格式兼容 API
+
+## Existing Context
+
+当前相关实现分散在三层:
+
+- `packages/core/src/domain/memory.ts`
+ - `WorkspaceMemoryEntry`、`WorkspaceMemoryInput`、`validateWorkspaceMemoryInput()` 仍要求 `title`
+- `packages/server/src/commands/memory.ts`
+ - `memory.create` 必填 `title`
+ - `memory.update` 允许更新 `title`
+- `packages/server/src/storage/repositories/memory-repo.ts`
+ - 文件持久化包含 `title`
+ - 搜索范围包含 `title`
+- `packages/core/src/domain/automation.ts`
+ - capability 描述和输入 schema 仍暴露 `title`
+- `packages/server/src/skills/builtin/definitions/coder-studio-memory.ts`
+ - 内置 skill 文案仍要求“concise titles”
+- `packages/web/src/features/workspace/actions/use-memory-panel.ts`
+ - create/update 输入类型仍包含 `title`
+- `packages/web/src/features/workspace/views/shared/memory-panel.tsx`
+ - 新建 modal 仍渲染标题输入
+ - 列表、删除按钮和确认文案仍用 `entry.title`
+
+## Decision
+
+采用彻底删除方案:`title` 从 memory 公开契约、内部验证、存储格式、搜索逻辑、UI 表单和可访问文案中全部移除。
+
+不采用以下替代方案:
+
+1. 只在 UI 隐藏 title
+原因:这会保留低价值数据模型,并继续迫使 commands、skills、tests 维护一个不再有产品意义的字段。
+
+2. 保留 title 但改为可选
+原因:这会让同一份 memory 同时存在两种主标识规则,搜索、列表展示、删除确认和旧数据迁移都会持续分叉。
+
+3. 用自动摘要重新生成 title
+原因:这只是把旧字段换成另一个隐式字段,复杂度更高,也不符合“只保留内容”的目标。
+
+## Core Model Changes
+
+`WorkspaceMemoryEntry` 变更为:
+
+- 保留:`id`、`workspaceId`、`type`、`content`、`tags`、`source`、`createdAt`、`updatedAt`、`archivedAt`
+- 删除:`title`
+
+输入校验变更为:
+
+- `WorkspaceMemoryInput` 和 `WorkspaceMemoryValidatedInput` 删除 `title`
+- `validateWorkspaceMemoryInput()` 只校验 `type`、`content`、`tags`
+- `content` 继续要求 trim 后非空,长度上限保持 `20_000`
+- `tags` 继续沿用现有标准化规则
+
+这让 core contract 成为唯一真实来源,避免 server 和 web 继续携带已废弃字段。
+
+## Server API Changes
+
+memory commands 改为:
+
+- `memory.create`
+ - 输入:`workspaceId`、`type`、`content`、`tags`、可选 `sourceHint`
+ - 删除 `title`
+- `memory.update`
+ - 输入:`workspaceId`、`id`、可选 `type`、`content`、`tags`
+ - 删除可选 `title`
+- `memory.search`
+ - 行为保持不变,但查询范围改为 `content`、`tags`、`type`
+
+错误处理维持现状:
+
+- workspace 不存在时返回 `workspace_not_found`
+- entry 不存在时返回 `memory_not_found`
+- content 或 tags 非法时继续由现有 validation 抛出错误
+
+## Storage Migration
+
+旧磁盘文件可能仍包含:
+
+```json
+{
+ "version": 1,
+ "workspaceId": "ws-1",
+ "entries": {
+ "mem-1": {
+ "id": "mem-1",
+ "workspaceId": "ws-1",
+ "type": "decision",
+ "title": "Use pnpm",
+ "content": "This workspace uses pnpm.",
+ "tags": ["tooling"],
+ "source": { "kind": "user" },
+ "createdAt": 1,
+ "updatedAt": 1
+ }
+ }
+}
+```
+
+迁移策略采用“读取即规范化,写回即完成迁移”:
+
+- `MemoryRepo` 增加文件标准化逻辑,而不是直接把 JSON 强转为 `WorkspaceMemoryFile`
+- 读取时如果 entry 含有 `title`,直接忽略该字段
+- 读取结果在运行时只返回无 `title` 的 entry
+- 任意 create、update、delete 写回文件时,磁盘内容改写为不含 `title` 的新结构
+
+版本策略:
+
+- 继续使用 `version: 1`
+- 不单独引入 `version: 2`
+
+原因:
+
+- 这次 schema 收缩不需要并存两个运行时分支
+- 旧文件的歧义仅是多余字段,不是核心结构变更
+- 保持 `version: 1` 可以把迁移成本限制在 repo 内部标准化逻辑
+
+## Web UI Changes
+
+`MemoryPanel` 和新建 modal 调整为:
+
+- 删除标题输入框
+- 新建表单只保留:
+ - type
+ - content
+ - tags
+- 列表主标题改为 `content` 预览,而不是 title
+- 删除按钮 `aria-label`、tooltip 和确认弹窗描述都使用同一个内容预览
+
+内容预览规则:
+
+- 基于 `content.trim().replace(/\s+/g, " ")`
+- 空白压缩后截断
+- 继续使用现有短摘要风格,避免列表和确认文案过长
+
+交互约束:
+
+- 不新增新的可编辑标题或摘要字段
+- 列表中仍可显示 type badge、tags、更新时间、source 等现有辅助信息
+- 之前已移除的下方 detail panel 不回归
+
+## Automation And Skill Updates
+
+为了让 agent-facing 文档和实际 API 一致,需要同步更新:
+
+- `packages/core/src/domain/automation.ts`
+ - `memory.search` 描述去掉 “title”
+ - `memory.add` / `memory.update` 输入 schema 删除 `title`
+ - CLI 示例不再使用 `--title`
+- `packages/server/src/skills/builtin/definitions/coder-studio-memory.ts`
+ - 文案改为要求 clear content 和 searchable tags
+ - 示例命令删除 `--title`
+
+这样 agents、skills 和 CLI 帮助文本不会继续生成过时参数。
+
+## Testing
+
+本次改动需要覆盖三层测试:
+
+- Core
+ - `packages/core/src/domain/memory.test.ts`
+ - 如 capability schema 断言受影响,也更新 `packages/core/src/domain/automation.test.ts`
+- Server
+ - `packages/server/src/commands/memory.test.ts`
+ - `packages/server/src/storage/repositories/memory-repo.test.ts`
+ - `packages/server/src/__tests__/server-memory-wiring.test.ts`
+ - 增加旧磁盘 entry 含 `title` 的归一化迁移用例
+- Web
+ - `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx`
+ - 更新 create/delete 文案断言,改为 content preview
+
+测试策略采用 TDD:
+
+- 先改测试,验证它们因旧 title 行为而失败
+- 再逐层实现最小代码让测试转绿
+
+## Risks
+
+- 如果只删类型不做 repo 归一化,旧本地 memory 文件会在读取时与新 contract 不一致
+- 如果遗漏 automation 或 builtin skill 文案,agents 仍可能生成 `--title` 命令并触发校验失败
+- 如果 web 只删可见标题、不统一删除标签与确认文案中的 title 引用,界面和无障碍文本会继续泄露旧概念
+- 如果搜索逻辑没有同步去掉 `title`,测试和实际数据行为会不一致
+
+## Validation
+
+完成后应满足:
+
+- 新创建的 memory entry 从 type 到持久化文件都不再含 `title`
+- 旧含 `title` 的 memory 文件能被正常读取,并在下一次写回后变为无 `title` 结构
+- memory search 仅匹配 `content`、`tags`、`type`
+- 新建 modal 只显示 type、content、tags
+- memory list 和删除确认都使用内容预览作为条目标识
diff --git a/docs/superpowers/specs/2026-06-14-workspace-memory-remove-title-design.md b/docs/superpowers/specs/2026-06-14-workspace-memory-remove-title-design.md
new file mode 100644
index 000000000..e6fa29644
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-14-workspace-memory-remove-title-design.md
@@ -0,0 +1,257 @@
+# Workspace Memory Remove Title Design
+
+> Date: 2026-06-14
+> Status: Draft for user review
+> Owner: Codex
+
+## Problem
+
+当前 workspace memory 系统把 `title` 当成核心字段贯穿 `core`、`server`、`web`
+和持久化 JSON:
+
+- 新建 memory 必填 `title`
+- memory 列表把 `title` 作为主要可见标签
+- 删除确认和可访问名称依赖 `title`
+- 搜索会匹配 `title`
+- 旧存储文件会长期保留 `title`
+
+这和当前产品方向不一致。用户要求把 memory 简化成“仅保留内容本身”,不再让
+title 成为一个独立字段,也不保留兼容层让它继续活着。
+
+## Goal
+
+彻底移除 workspace memory 的 `title`,保证:
+
+- `WorkspaceMemoryEntry`、创建输入、更新输入都不再包含 `title`
+- `memory.create`、`memory.update`、automation capability schema 不再接受
+ `title`
+- memory 列表、删除入口、删除确认统一改用截断后的内容摘要标识一条记忆
+- 新建 modal 只保留 `type`、`content`、`tags`
+- 旧持久化数据中的 `title` 不再作为运行时字段存在,并会在后续写回时被清除
+
+## Non-Goals
+
+- 不新增单独的“显示名称”字段来替代 `title`
+- 不改变 memory `type`、`content`、`tags`、`source` 的含义
+- 不为旧存储数据提供长期双 schema 兼容 API
+- 不改变 memory 的软删除模型
+- 不扩展 memory 编辑 UI;本次重点是移除 title,而不是新增别的编辑能力
+
+## Existing Context
+
+当前相关实现集中在以下位置:
+
+- `packages/core/src/domain/memory.ts`
+ - `WorkspaceMemoryEntry`、输入类型和验证函数仍要求 `title`
+- `packages/server/src/commands/memory.ts`
+ - Zod schema 仍要求创建时传入 `title`,更新时允许修改 `title`
+- `packages/server/src/storage/repositories/memory-repo.ts`
+ - 存储结构、创建、更新、搜索都依赖 `title`
+- `packages/core/src/domain/automation.ts`
+ - capability 描述和输入 schema 仍公开 `title`
+- `packages/web/src/features/workspace/actions/use-memory-panel.ts`
+ - create/update 输入类型仍含 `title`
+- `packages/web/src/features/workspace/views/shared/memory-panel.tsx`
+ - 新建 modal 仍渲染 title 输入框,列表和删除文案仍显示 `title`
+
+## Decision
+
+采用“硬移除字段 + 存储归一化迁移”方案:
+
+1. 从 shared domain、server command schema、repo 输入类型和 web 输入类型中删掉
+ `title`
+2. 运行时所有 memory 文本标识统一改为内容摘要
+3. `MemoryRepo` 读取旧 JSON 时归一化旧条目:
+ - 忽略旧 `title`
+ - 只保留 title-less 的运行时结构
+4. 任何后续写入都会把该 workspace 文件写回成不含 `title` 的新结构
+
+不采用以下替代方案:
+
+1. 只删 UI,底层继续保留 `title`
+原因:会让 API、存储和真实模型继续背着无用字段,后续维护成本更高。
+
+2. 保留 `title` 但自动由 `content` 派生
+原因:这只是换一种方式继续保留同一个字段,不符合“都删掉”的要求。
+
+3. 直接删除旧文件或强制 bump 到新版本后拒绝旧数据
+原因:会让已有工作区 memory 丢失或不可读。这里需要无损迁移,而不是破坏式重置。
+
+## Data Model Changes
+
+### Core Domain
+
+`packages/core/src/domain/memory.ts` 改为:
+
+- `WorkspaceMemoryEntry` 删除 `title`
+- `WorkspaceMemoryInput` 删除 `title`
+- `WorkspaceMemoryValidatedInput` 删除 `title`
+- `validateWorkspaceMemoryInput()` 只校验:
+ - `type`
+ - `content`
+ - `tags`
+
+Validation 规则调整为:
+
+- `content`: trim 后 1-20,000 字符
+- `tags`: 维持现有标准化和去重规则
+- 不再有 title 长度校验和 title required 错误
+
+### Search Semantics
+
+memory 搜索与面板内本地过滤都只匹配:
+
+- `content`
+- `type`
+- `tags`
+
+`title` 不再是搜索范围的一部分。
+
+## Storage Migration
+
+### File Shape
+
+memory 文件仍保持:
+
+```json
+{
+ "version": 1,
+ "workspaceId": "ws_123",
+ "entries": {
+ "mem_abc": {
+ "id": "mem_abc",
+ "workspaceId": "ws_123",
+ "type": "decision",
+ "content": "Project memory is server-owned.",
+ "tags": ["architecture"],
+ "source": { "kind": "user" },
+ "createdAt": 1779120000000,
+ "updatedAt": 1779120000000
+ }
+ }
+}
+```
+
+不 bump `version`。原因:
+
+- 现有仓库已经广泛使用“保持 `version: 1` 并通过 normalize 兼容旧形态”的模式
+- 这次变化不需要多版本并存协议,只需要把旧记录整理成新结构
+
+### Normalization Strategy
+
+`MemoryRepo` 增加文件归一化逻辑:
+
+- 允许读取带 `title` 的旧 entry
+- 归一化结果返回不含 `title` 的 `WorkspaceMemoryEntry`
+- 如果某条旧记录 `content` 非法,则按现有 repo 的容错模式忽略该条记录
+- 归一化不会用 `title` 回填 `content`
+
+迁移行为定义为:
+
+- 旧文件首次被读取时,运行时已看不到 `title`
+- 旧文件在该 workspace 下发生任何 create/update/delete 写操作时,会被整体写回为
+ 不含 `title` 的新结构
+
+这是一种“读时兼容、写时完成迁移”的单向迁移,不保留长期 title 兼容层。
+
+## API And Server Behavior
+
+### Commands
+
+`packages/server/src/commands/memory.ts` 调整为:
+
+- `memory.create`
+ - 输入去掉 `title`
+ - 继续要求 `workspaceId`、`type`、`content`
+- `memory.update`
+ - 输入去掉 `title`
+ - 允许更新 `type`、`content`、`tags`
+
+### Repository
+
+`packages/server/src/storage/repositories/memory-repo.ts` 调整为:
+
+- create/update 不再向验证器传入 `title`
+- 搜索辅助函数不再匹配 `title`
+- 写入磁盘的 entry 不再包含 `title`
+
+### Automation Capability Metadata
+
+`packages/core/src/domain/automation.ts` 调整为:
+
+- `memory.search` 描述去掉 “by title”
+- `memory.add` input schema 去掉 `title`
+- `memory.update` input schema 去掉可选 `title`
+- CLI 示例同步改为只使用 `content`
+
+## UI Changes
+
+### Create Modal
+
+`packages/web/src/features/workspace/views/shared/memory-panel.tsx` 中的新建 modal
+只保留:
+
+- type select
+- content textarea
+- tags input
+
+移除:
+
+- title label
+- title input
+- 与 `title` 相关的 draft state
+
+### Memory List Presentation
+
+列表项主文案改为内容摘要:
+
+- 基于 `content.trim().replace(/\s+/g, " ")`
+- 长文本截断为单行摘要
+- 空内容不会出现,因为 content 校验仍要求非空
+
+删除入口和确认文案也使用同样的摘要文本,避免 UI 在没有 title 后失去识别能力。
+
+### Accessibility
+
+以下文本都改为基于内容摘要:
+
+- 列表项可访问名称
+- 删除按钮 aria-label
+- 删除确认弹窗描述中的记忆标识
+
+## Testing
+
+需要更新并通过以下直接相关测试:
+
+- `packages/core/src/domain/memory.test.ts`
+- `packages/core/src/domain/automation.test.ts`(如果 schema 断言覆盖到 memory)
+- `packages/server/src/commands/memory.test.ts`
+- `packages/server/src/storage/repositories/memory-repo.test.ts`
+- `packages/server/src/__tests__/server-memory-wiring.test.ts`
+- `packages/web/src/features/workspace/views/shared/memory-panel.test.tsx`
+
+测试重点:
+
+- memory 校验不再要求 `title`
+- command schema 拒绝旧的 title-dependent 假设
+- repo 能读取含 `title` 的旧文件并以无 `title` 结构返回
+- repo 写回后文件不再包含 `title`
+- web 新建 modal 不再渲染 title 输入框
+- 列表与删除文案改用内容摘要
+
+## Risks
+
+- 如果只删类型不补 repo normalize,旧 workspace memory 文件会在读取时与新类型不一致
+- 如果 UI 和 server 搜索语义没同时更新,会出现前后端过滤结果不一致
+- 如果测试仍依赖旧的 `title_label` 或 title 文案,前端测试会失败
+- 如果一次性清理过度,可能误触当前工作区内与 memory 无关的未提交改动
+
+## Validation
+
+完成后应满足:
+
+- 新建 memory 时没有 title 字段
+- `WorkspaceMemoryEntry` 运行时结构不含 `title`
+- 旧含 title 的磁盘文件仍可读取
+- 后续写回的磁盘文件不再含 `title`
+- 搜索、列表展示、删除确认都基于内容摘要
diff --git a/docs/wiki/Agent-Providers.md b/docs/wiki/Agent-Providers.md
index 401ee7eca..0182ae6a7 100644
--- a/docs/wiki/Agent-Providers.md
+++ b/docs/wiki/Agent-Providers.md
@@ -8,14 +8,119 @@ The important idea is that the workspace should not be defined by only one vendo
Current built-in support covers:
-- Claude Code
-- OpenAI Codex
+- Claude Code (`claude`) - full capability, stable
+- Codex (`codex`) - full capability, stable
+- Gemini CLI (`gemini`) - full capability, stable
+- Cursor Agent (`agent`) - full capability, stable
+- OpenCode (`opencode`) - limited capability, experimental
-These are the providers available in today's product. They run through their local CLIs, and Coder Studio gives them a browser workspace with terminals, files, Git, sessions, and review surfaces around the run.
+These providers run through their local CLIs, and Coder Studio gives them a browser workspace with terminals, files, Git, sessions, and review surfaces around the run.
+
+Full capability means Coder Studio can run interactive sessions, use idle detection, and use the provider for supervisor/session-analysis style workflows. Limited capability means interactive sessions are supported, but not every automated workflow is wired up yet.
+
+## Install Provider CLIs
+
+Provider CLIs need local installation. Coder Studio does not bundle these CLIs; it launches the provider commands available on your machine.
+
+### Install Claude Code
+
+```bash
+npm install -g @anthropic-ai/claude-code
+```
+
+Verify after installation:
+
+```bash
+claude --version
+```
+
+### Install Codex
+
+```bash
+npm install -g @openai/codex
+```
+
+Verify after installation:
+
+```bash
+codex --version
+```
+
+### Install Gemini CLI
+
+```bash
+npm install -g @google/gemini-cli
+```
+
+Verify after installation:
+
+```bash
+gemini --version
+```
+
+### Install Cursor Agent
+
+Cursor publishes its agent CLI through the Cursor install script on macOS and Linux:
+
+```bash
+curl https://cursor.com/install -fsS | bash
+```
+
+Verify after installation:
+
+```bash
+agent --version
+```
+
+### Install OpenCode
+
+```bash
+npm install -g opencode-ai
+```
+
+Verify after installation:
+
+```bash
+opencode --version
+```
+
+## Verify PATH
+
+Coder Studio can only launch provider commands that are visible to the server process PATH. Verify the command in a normal terminal first:
+
+```bash
+which claude
+which codex
+which gemini
+which agent
+which opencode
+claude --version
+codex --version
+gemini --version
+agent --version
+opencode --version
+```
+
+If `which` cannot find the command, your global npm command directory is probably not in PATH.
+
+Check the global npm prefix:
+
+```bash
+npm config get prefix
+npm prefix -g
+```
+
+On macOS/Linux, global command shims are usually under `/bin`. Add that directory to your shell profile, open a new terminal, and restart Coder Studio:
+
+```bash
+coder-studio serve --restart
+```
+
+On Windows, also check that the global npm command directory is in the user or system PATH. A common location is `%APPDATA%\npm`.
## Why Provider-Agnostic Positioning Matters
-Coder Studio is not trying to become a wrapper for exactly two agent CLIs forever.
+Coder Studio is not trying to become a wrapper for exactly one agent CLI or vendor.
The longer-term product promise is simpler:
@@ -23,27 +128,25 @@ The longer-term product promise is simpler:
- let users compare or supervise different agents in one place
- avoid rebuilding the whole product story around a single provider brand
-That positioning does not mean every provider already exists in the product. It means the workspace direction is broader than today's built-in list.
+That positioning does not mean every CLI agent has identical support. It means the workspace direction is broader than any single provider brand.
-## Future Presets
+## Presets And Custom Providers
-Future preset providers are roadmap items, not current built-in support.
+Preset metadata exists for CLI agents that can be launched through the custom-provider flow. Presets are separate from the built-in provider registry.
-The idea is to offer pre-filled provider metadata for common coding-agent tools so users do not have to start from scratch when support expands. Example preset candidates include:
+Preset/custom-provider workflows are useful when you want to launch a CLI through custom command configuration instead of the built-in provider path. Current preset examples include:
- Gemini CLI
- Aider
- OpenCode
-This page is not claiming that those providers are already enabled today.
+Gemini CLI and OpenCode also have built-in providers today; prefer the built-in entries unless you specifically need custom-provider behavior. Aider is not a built-in provider today. Treat Aider as a preset/custom-provider workflow: install the `aider` command yourself, create or apply the provider metadata, and verify the command is visible in PATH before starting a session.
## Custom Command Providers
-Custom command providers are also a roadmap item.
-
-The goal is to let users connect their own local coding-agent commands to the same workspace model. That could cover internal tools, local scripts, or company-specific agents.
+Custom command providers let users connect their own local coding-agent commands to the same workspace model. That can cover Aider, internal tools, local scripts, or company-specific agents.
-Custom command providers are not part of the current built-in release.
+Custom providers are interactive command integrations. They may not support the same supervisor, idle-detection, agent-instructions, or install flows as built-in providers.
## Non-Goals
@@ -59,6 +162,6 @@ The provider roadmap does not imply:
If you are using Coder Studio today, the accurate summary is:
-- built-in providers today are Claude Code and OpenAI Codex
-- the workspace direction is broader than those two providers
+- built-in providers today are Claude Code, Codex, Gemini CLI, Cursor Agent, and OpenCode
+- Aider-style workflows use preset/custom-provider configuration rather than the built-in registry
- your code and runtime still stay on your own machine
diff --git a/docs/wiki/Coder-Studio-vs-Warp.md b/docs/wiki/Coder-Studio-vs-Warp.md
index 4c39ae705..e9ab5662c 100644
--- a/docs/wiki/Coder-Studio-vs-Warp.md
+++ b/docs/wiki/Coder-Studio-vs-Warp.md
@@ -40,7 +40,7 @@ Coder Studio is strongest when you want:
## Built-In Provider Reality
-Today, Coder Studio's built-in providers are Claude Code and OpenAI Codex. The agentic workspace direction is broader than that, but the current product should still be described accurately.
+Today, Coder Studio's built-in providers are Claude Code, Codex, Gemini CLI, Cursor Agent, and OpenCode. Aider-style workflows use preset/custom-provider configuration. The agentic workspace direction is broader than a single provider brand, but the current product should still be described accurately.
## They Can Be Complementary
diff --git a/docs/wiki/Common-Workflows.md b/docs/wiki/Common-Workflows.md
index debb5017e..460c0055a 100644
--- a/docs/wiki/Common-Workflows.md
+++ b/docs/wiki/Common-Workflows.md
@@ -6,7 +6,7 @@ This page describes practical ways to use Coder Studio after the first launch.
1. Start Coder Studio on your computer.
2. Open a workspace.
-3. Start a Claude or Codex session.
+3. Start an agent session. For a first trial, Claude or Codex is the recommended path.
4. Give the agent a task.
5. Open the same Coder Studio URL from your phone.
6. Check session output, files, Git changes, and terminal state.
@@ -15,7 +15,7 @@ For remote access setup, see [Mobile and Remote Access](Mobile-and-Remote-Access
## Vibe Code With A Visible Verification Loop
-1. Start a Claude or Codex session.
+1. Start an agent session.
2. Describe the desired change in natural language.
3. Watch the agent's terminal output.
4. Inspect changed files and Git diffs.
@@ -24,11 +24,11 @@ For remote access setup, see [Mobile and Remote Access](Mobile-and-Remote-Access
This keeps vibe-coding speed inside a reviewable engineering harness.
-## Use Claude And Codex Side By Side
+## Use Multiple Providers Side By Side
1. Open one workspace.
-2. Start a Claude session for one task.
-3. Start a Codex session for another task.
+2. Start one provider session for a task.
+3. Start another provider session for a separate task.
4. Switch between sessions in the Agent panel.
5. Use Git diff and shell terminals to verify output.
diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md
index debbaaa71..f4a4876f9 100644
--- a/docs/wiki/FAQ.md
+++ b/docs/wiki/FAQ.md
@@ -6,15 +6,15 @@ No. Coder Studio runs on your own machine and serves a browser UI from that loca
## Does My Code Leave My Machine?
-Coder Studio itself is local-first. However, provider CLIs such as Claude Code or Codex may send task context according to their own behavior and settings. Review the provider's documentation if this matters for your project.
+Coder Studio itself is local-first. However, provider CLIs may send task context according to their own behavior and settings. Review the provider's documentation if this matters for your project.
## Is Coder Studio A Replacement For VS Code Or Cursor?
Not directly. Coder Studio is strongest as a browser workspace around vibe coding sessions, terminals, files, Git, and cross-device visibility. Use a desktop editor when you need deep manual editing.
-## Do I Need Claude Code Or Codex Installed?
+## Do I Need A Provider CLI Installed?
-Only for AI sessions. You can still use Coder Studio for file browsing and terminals without provider CLIs installed.
+Only for AI sessions. You can still use Coder Studio for file browsing and terminals without provider CLIs installed. Install the CLI for each provider you want to run.
## Can I Use It From My Phone?
diff --git a/docs/wiki/First-Agent-Run.md b/docs/wiki/First-Agent-Run.md
new file mode 100644
index 000000000..7a3aa3193
--- /dev/null
+++ b/docs/wiki/First-Agent-Run.md
@@ -0,0 +1,108 @@
+# First Agent Run
+
+This guide walks through the first successful Coder Studio agent workflow: open a project, start a recommended first-run provider, watch the session output, and review the resulting Git diff.
+
+## What This Guide Solves
+
+Use this to verify that Coder Studio can support a real AI coding loop, not just launch the UI.
+
+## Prerequisites
+
+- Coder Studio installed: `npm install -g @spencer-kit/coder-studio`
+- Node.js 24 or newer: `node --version`
+- At least one Provider CLI installed. For the first trial, Claude Code or Codex is recommended:
+ - Claude Code: `npm install -g @anthropic-ai/claude-code`
+ - Codex: `npm install -g @openai/codex`
+- The Provider CLI works in a normal terminal:
+ - `claude --version`
+ - `codex --version`
+
+## 1. Launch Coder Studio
+
+```bash
+coder-studio open
+```
+
+Your browser should open automatically. If it does not, run:
+
+```bash
+coder-studio status
+```
+
+Then manually open the local URL printed by the command.
+
+## 2. Open A Local Project
+
+On the welcome screen, choose **Open Workspace** and select a small repository you can safely test with.
+
+For the first run, prefer:
+
+- a personal repository with a README
+- a Git repository with a clean working tree
+- a project that does not contain production secrets
+
+## 3. Start A Recommended First-Run Provider
+
+Inside the workspace, create a new session and choose a detected provider. For the first trial, Claude or Codex is the recommended path because the setup and troubleshooting docs are the most detailed.
+
+If the provider is missing:
+
+1. Install the CLI in your normal terminal.
+2. Verify it with `claude --version` or `codex --version`.
+3. Refresh Coder Studio or reopen the workspace.
+
+## 4. Use A Safe First Task
+
+Do not start with a large refactor. Use a small documentation task first:
+
+```text
+Please read the README and add a short "Who this is for" section. Keep the change to 1-2 paragraphs and do not modify source code.
+```
+
+This is a good first test because:
+
+- the change is small
+- the Git diff is easy to inspect
+- the result is easy to revert
+
+## 5. Review Output And Changes
+
+While the session runs, watch the agent output. When it finishes:
+
+1. Open the Git view.
+2. Inspect changed files.
+3. Open the diff.
+4. Decide whether the result matches your request.
+
+You can ask the same session for a follow-up change, edit the files yourself, or discard the change with Git.
+
+## 6. Check Progress From Mobile
+
+For phone or tablet access, read [Mobile and Remote Access](Mobile-and-Remote-Access.md).
+
+Mobile is best for:
+
+- checking whether an agent is still running
+- reading terminal output
+- browsing files and diffs
+- monitoring long tasks
+
+Use desktop for the first full trial. Mobile is a continuation and review surface, not the main heavy editing environment.
+
+## FAQ
+
+**Can I try Coder Studio without Claude or Codex installed?**
+Yes. You can open workspaces, browse files, and use terminals, but you cannot start a provider's agent session until its CLI is installed.
+
+**I installed a provider but Coder Studio cannot find it. What now?**
+Run `which ` and ` --version` in a normal terminal. If the command is missing, the install directory is probably not in PATH. See [Agent Providers](Agent-Providers.md).
+
+**What if the agent makes a bad change?**
+Review the Git diff first. You can edit the file, ask for a follow-up, or discard the change with Git.
+
+## Next Steps
+
+- [Agent Providers](Agent-Providers.md)
+- [Mobile and Remote Access](Mobile-and-Remote-Access.md)
+- [Troubleshooting](Troubleshooting.md)
+- [Common Workflows](Common-Workflows.md)
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 6119074c8..469bf843b 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -2,18 +2,28 @@
Coder Studio is an agentic workspace for real development. It lets you run, inspect, and supervise coding agents with terminals, files, Git, sessions, and review in one browser workspace.
-Built-in support today covers Claude Code and OpenAI Codex. The broader direction is a workspace that can bring more coding agents together over time, while your code and runtime stay on your machine.
+Built-in support today covers Claude Code, Codex, Gemini CLI, Cursor Agent, and OpenCode. Aider-style workflows use preset/custom-provider configuration, while your code and runtime stay on your machine.
In current vibe coding language, Coder Studio sits between raw vibe coding and a full engineering harness: you can express intent in natural language, but the workspace keeps terminals, files, diffs, sessions, and review visible.
## Start Here
+- [Quick Start](Quick-Start.md)
+- [First Agent Run](First-Agent-Run.md)
+- [Agent Providers](Agent-Providers.md)
+- [Mobile and Remote Access](Mobile-and-Remote-Access.md)
+- [Security and Privacy](Security-and-Privacy.md)
+- [Known Limitations](Known-Limitations.md)
+- [Troubleshooting](Troubleshooting.md)
+
+## First Launch
+
```bash
npm install -g @spencer-kit/coder-studio
coder-studio open
```
-Then select a project folder and start a Claude or Codex session today.
+Then select a project folder and start an agent session. For a first trial, Claude or Codex is the recommended path because those docs are the most detailed.
## What It Is For
@@ -27,7 +37,7 @@ Then select a project folder and start a Claude or Codex session today.
| If you use... | It is great for... | Coder Studio adds... |
|---------------|--------------------|----------------------|
| Vibe coding tools | Fast intent-to-code iteration | A local workspace harness for review, terminal output, Git diffs, and verification |
-| Claude Code / Codex CLI | Running today's built-in agents in a terminal | A browser workspace with files, Git, terminals, sessions, review, and mobile access |
+| Coding agent CLIs | Running agents in a terminal | A browser workspace with files, Git, terminals, sessions, review, and mobile access |
| Cursor / VS Code | Interactive editing | Long-running AI sessions that remain visible across devices |
| Cloud IDEs | Hosted remote development | A local-first runtime on your own machine |
| SSH / remote desktop | Remote machine access | A responsive workspace UI instead of mirroring a desktop |
diff --git a/docs/wiki/Known-Limitations.md b/docs/wiki/Known-Limitations.md
new file mode 100644
index 000000000..2b4a8f1c9
--- /dev/null
+++ b/docs/wiki/Known-Limitations.md
@@ -0,0 +1,71 @@
+# Known Limitations
+
+This page sets expectations for the current Coder Studio release.
+
+## What This Page Solves
+
+Coder Studio is easiest to understand when its boundaries are explicit. It is not a cloud IDE, not a VS Code replacement, and not an AI model provider.
+
+## System Requirements
+
+- Node.js 24 or newer is required.
+- Coder Studio is installed as a global npm package.
+- Agent sessions depend on local Provider CLIs installed on your machine.
+
+## Provider Boundary
+
+Coder Studio does not bundle Claude, Codex, or other models. It launches and manages local Provider CLIs inside the workspace.
+
+Without a Provider CLI installed, you can:
+
+- open a workspace
+- browse files
+- use terminals
+
+You cannot create that provider's agent session until the CLI is installed and visible in PATH.
+
+## Mobile Boundary
+
+Mobile is best for monitoring and review, not heavy editing.
+
+Good mobile use cases:
+
+- check long-running task progress
+- read agent output
+- browse files
+- inspect Git diffs
+- decide whether desktop intervention is needed
+
+## Remote Access Boundary
+
+Coder Studio is safest on localhost or trusted networks. Cross-device and public access require your own network and authentication setup.
+
+Before remote access:
+
+```bash
+coder-studio config --password
+coder-studio config --host 0.0.0.0
+coder-studio serve --restart
+```
+
+Do not expose Coder Studio to the public internet without authentication.
+
+## Not The Focus Of This Launch
+
+The current launch does not focus on:
+
+- hosted cloud workspaces
+- team permission systems
+- phone-first full coding
+- plugin marketplace
+- session replay
+- cloud preference sync
+
+These may evolve later, but they are not required to understand the first Coder Studio workflow.
+
+## Next Steps
+
+- [Quick Start](Quick-Start.md)
+- [First Agent Run](First-Agent-Run.md)
+- [Mobile and Remote Access](Mobile-and-Remote-Access.md)
+- [Troubleshooting](Troubleshooting.md)
diff --git a/docs/wiki/Mobile-and-Remote-Access.md b/docs/wiki/Mobile-and-Remote-Access.md
index df0586d22..01f6f2d5e 100644
--- a/docs/wiki/Mobile-and-Remote-Access.md
+++ b/docs/wiki/Mobile-and-Remote-Access.md
@@ -2,6 +2,18 @@
Coder Studio runs on your machine and is accessed through a browser. Desktop, tablet, and phone use the same service URL, with layouts adapting to screen size.
+## What Mobile Is Best For
+
+Mobile is a continuation surface for work that usually starts on desktop:
+
+- check whether an agent is still running
+- read terminal output and status changes
+- browse files and Git diffs
+- inspect Supervisor progress
+- decide whether you need to return to desktop and intervene
+
+Mobile should not be positioned as a full replacement for desktop coding. Use desktop for first installation, Provider setup, large edits, and complex Git operations.
+
## Choose An Access Method
| Scenario | Recommended method |
diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md
index bfa06a92e..6862f8e6a 100644
--- a/docs/wiki/Quick-Start.md
+++ b/docs/wiki/Quick-Start.md
@@ -5,8 +5,7 @@ This guide gets Coder Studio installed, opened, and connected to a first workspa
## Requirements
- Node.js 24 or newer
-- Optional: Claude Code CLI for Claude sessions
-- Optional: OpenAI Codex CLI for Codex sessions
+- Optional: at least one supported Provider CLI for agent sessions
Coder Studio can still open files and terminals without an AI CLI installed.
@@ -30,6 +29,8 @@ coder-studio status
Open the displayed local URL in your browser.
+After Coder Studio opens, continue with [First Agent Run](First-Agent-Run.md) to start a recommended first-run provider, inspect output, and review your first Git diff.
+
## Open A Project
1. Click the workspace picker.
@@ -39,7 +40,7 @@ Open the displayed local URL in your browser.
## Start An AI Session
1. Open the Agent panel.
-2. Choose Claude or Codex.
+2. Choose a detected provider. For a first trial, Claude or Codex is the recommended path.
3. Create a session.
4. Type a task into the session terminal.
diff --git a/docs/wiki/README.md b/docs/wiki/README.md
index 415e03034..008d10509 100644
--- a/docs/wiki/README.md
+++ b/docs/wiki/README.md
@@ -25,6 +25,7 @@ GitHub only creates `.wiki.git` after the repository Wiki is initialized o
- [Home](Home.md)
- [Quick Start](Quick-Start.md)
+- [First Agent Run](First-Agent-Run.md)
- [Why Coder Studio](Why-Coder-Studio.md)
- [What Is an Agentic Workspace](What-is-an-Agentic-Workspace.md)
- [Agent Providers](Agent-Providers.md)
@@ -32,6 +33,7 @@ GitHub only creates `.wiki.git` after the repository Wiki is initialized o
- [AI Coding Terms](AI-Coding-Terms.md)
- [Mobile and Remote Access](Mobile-and-Remote-Access.md)
- [Security and Privacy](Security-and-Privacy.md)
+- [Known Limitations](Known-Limitations.md)
- [Supervisor](Supervisor.md)
- [Common Workflows](Common-Workflows.md)
- [Troubleshooting](Troubleshooting.md)
diff --git a/docs/wiki/Security-and-Privacy.md b/docs/wiki/Security-and-Privacy.md
index 1ffa8e420..47863dc0b 100644
--- a/docs/wiki/Security-and-Privacy.md
+++ b/docs/wiki/Security-and-Privacy.md
@@ -7,12 +7,12 @@ Coder Studio is local-first: the server runs on your machine and opens your loca
- Coder Studio server runs on your machine.
- The web UI is served from that local server.
- Project files are read from local directories that you open as workspaces.
-- Claude Code and Codex run through their local CLIs when you start those sessions.
+- Agent sessions run through the matching local Provider CLI when you start them.
- SQLite stores Coder Studio local state.
## What Leaves Your Machine
-Coder Studio itself is not a hosted cloud service. However, provider CLIs such as Claude Code or Codex may send prompts, code context, terminal output, or other task data according to their own behavior and configuration.
+Coder Studio does not operate a hosted code service for your workspace, but any Provider CLI you run may send prompts, code context, terminal output, file snippets, or other task data according to that provider's own behavior and configuration.
Review the provider CLI's documentation and account settings if you need strict data-handling guarantees.
@@ -37,6 +37,8 @@ Authentication is especially important when using:
By default, local access is the safest mode. Remote access increases risk because anyone who reaches the service may be able to interact with files, terminals, sessions, and AI tools.
+Treat remote Coder Studio access like remote shell access: anyone who can authenticate may be able to read files, run terminal commands, and trigger provider tools with the permissions of your local user.
+
Recommended order:
1. Local browser access on the same machine
diff --git a/docs/wiki/Supervisor.md b/docs/wiki/Supervisor.md
index 760689194..a8082e051 100644
--- a/docs/wiki/Supervisor.md
+++ b/docs/wiki/Supervisor.md
@@ -46,7 +46,7 @@ Review the current work after each turn. Summarize risks, missing tests, and whe
## How To Use It
-1. Start a Claude or Codex session.
+1. Start a provider session that supports Supervisor workflows.
2. Open the Supervisor control for that session.
3. Write a concrete objective.
4. Choose an evaluator provider if the UI asks for one.
diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md
index 2be30b88d..8b0514c4a 100644
--- a/docs/wiki/Troubleshooting.md
+++ b/docs/wiki/Troubleshooting.md
@@ -2,6 +2,21 @@
Use this page when Coder Studio does not start, cannot reach a provider, or cannot be opened from another device.
+## First-Run Checklist
+
+If the first trial does not work, check in this order:
+
+1. `node --version` confirms Node.js >= 24.0.0.
+2. `coder-studio version` confirms the CLI is installed.
+3. `coder-studio status` confirms the service is running.
+4. `coder-studio logs` shows recent errors.
+5. `which ` confirms the Provider CLI is in PATH.
+6. ` --version` confirms the Provider CLI can run.
+7. If the browser does not open, manually visit the URL from `coder-studio status`.
+8. If mobile cannot connect, confirm the service listens on `0.0.0.0` and your firewall allows the port.
+
+Use desktop for the first full trial. Do not start by debugging public tunnels and phone access at the same time.
+
## Coder Studio Does Not Start
Check status and logs:
@@ -38,7 +53,7 @@ coder-studio serve --restart
## Provider Is Missing
-Coder Studio can open files and terminals without an AI CLI, but Claude and Codex sessions require their matching provider CLIs.
+Coder Studio can open files and terminals without an AI CLI, but agent sessions require their matching provider CLIs.
Install the provider CLI, confirm it works in a normal terminal, then start a new session in Coder Studio.
diff --git a/docs/wiki/What-is-an-Agentic-Workspace.md b/docs/wiki/What-is-an-Agentic-Workspace.md
index 4859f05ca..ee594ab75 100644
--- a/docs/wiki/What-is-an-Agentic-Workspace.md
+++ b/docs/wiki/What-is-an-Agentic-Workspace.md
@@ -62,6 +62,6 @@ That is the core promise behind inspectable vibe coding. Speed still matters, bu
Coder Studio is designed as an agentic workspace for real development.
-Today, built-in support covers Claude Code and OpenAI Codex. The larger direction is a workspace that can bring more coding agents together over time while keeping the same engineering surfaces visible: terminals, files, Git, sessions, review, and cross-device supervision.
+Today, built-in support covers Claude Code, Codex, Gemini CLI, Cursor Agent, and OpenCode, with Aider-style workflows handled through preset/custom-provider configuration. The larger direction is a workspace that can bring more coding agents together over time while keeping the same engineering surfaces visible: terminals, files, Git, sessions, review, and cross-device supervision.
Your code and runtime stay on your machine.
diff --git a/e2e/fixtures/app-entry.ts b/e2e/fixtures/app-entry.ts
index 7b4f7d805..cdd4058fd 100644
--- a/e2e/fixtures/app-entry.ts
+++ b/e2e/fixtures/app-entry.ts
@@ -6,13 +6,39 @@ import {
expectWelcomeCopy,
} from "./phase1-i18n.js";
-export const APP_ENTRY_SELECTOR =
+const INTERACTIVE_APP_ENTRY_SELECTOR =
".welcome-container, .workspace-page, .agent-draft-launcher, .session-card.agent-pane[data-session-id]";
+const APP_LOADING_SELECTOR = '.app-loading-shell, [data-testid="workspace-resolving-shell"]';
+
+export const APP_ENTRY_SELECTOR = `${INTERACTIVE_APP_ENTRY_SELECTOR}, ${APP_LOADING_SELECTOR}`;
export async function expectAppEntry(page: Page): Promise {
await expect(page.locator(APP_ENTRY_SELECTOR).first()).toBeVisible();
}
+async function waitForInteractiveAppEntry(page: Page): Promise {
+ await page.waitForFunction(
+ ({
+ interactiveSelector,
+ loadingSelector,
+ }: {
+ interactiveSelector: string;
+ loadingSelector: string;
+ }) => {
+ if (document.querySelector(loadingSelector)) {
+ return false;
+ }
+
+ return Boolean(document.querySelector(interactiveSelector));
+ },
+ {
+ interactiveSelector: INTERACTIVE_APP_ENTRY_SELECTOR,
+ loadingSelector: APP_LOADING_SELECTOR,
+ },
+ { timeout: 20000 }
+ );
+}
+
export async function isWelcomeVisible(page: Page): Promise {
return await page
.locator(".welcome-container")
@@ -35,7 +61,7 @@ export async function expectWelcomeCopyIfVisible(page: Page): Promise {
}
export async function expectPrimaryWorkspaceAction(page: Page): Promise {
- await expectAppEntry(page);
+ await waitForInteractiveAppEntry(page);
const welcomeButton = page.locator(".welcome-btn").first();
if (await welcomeButton.isVisible().catch(() => false)) {
@@ -53,7 +79,7 @@ export async function expectPrimaryWorkspaceAction(page: Page): Promise
}
export async function expectSettingsEntryPoint(page: Page): Promise {
- await expectAppEntry(page);
+ await waitForInteractiveAppEntry(page);
const welcomeSettings = page.locator(".welcome-link").first();
if (await welcomeSettings.isVisible().catch(() => false)) {
@@ -61,11 +87,17 @@ export async function expectSettingsEntryPoint(page: Page): Promise {
return welcomeSettings;
}
- const settingsButton = page
+ const moreFeaturesButton = page
.getByRole("button", {
- name: translatePatternForE2E("action.settings"),
+ name: translatePatternForE2E("more.title"),
})
.first();
- await expect(settingsButton).toBeVisible();
- return settingsButton;
+
+ if (await moreFeaturesButton.isVisible().catch(() => false)) {
+ return moreFeaturesButton;
+ }
+
+ const welcomeButton = page.locator(".welcome-btn").first();
+ await expectOpenWorkspaceButton(welcomeButton);
+ return welcomeButton;
}
diff --git a/e2e/fixtures/phase1-i18n.ts b/e2e/fixtures/phase1-i18n.ts
index 17c291396..299f27dfe 100644
--- a/e2e/fixtures/phase1-i18n.ts
+++ b/e2e/fixtures/phase1-i18n.ts
@@ -9,10 +9,10 @@ export async function expectWelcomeCopy(page: Page): Promise {
export async function expectOpenWorkspaceButton(locator: Locator): Promise {
await expect(locator).toBeVisible();
- await expect(locator.locator("span")).toContainText(translateForE2E("action.open_workspace"));
+ await expect(locator).toContainText(translateForE2E("action.open_workspace"));
}
export async function expectSettingsButton(locator: Locator): Promise {
await expect(locator).toBeVisible();
- await expect(locator.locator("span")).toContainText(translateForE2E("action.settings"));
+ await expect(locator).toContainText(translateForE2E("action.settings"));
}
diff --git a/e2e/fixtures/phase2-i18n.ts b/e2e/fixtures/phase2-i18n.ts
index f6dbd2d87..81658ef54 100644
--- a/e2e/fixtures/phase2-i18n.ts
+++ b/e2e/fixtures/phase2-i18n.ts
@@ -1,7 +1,7 @@
-import { type Page } from "@playwright/test";
+import { expect, type Locator, type Page } from "@playwright/test";
import { type E2ELocaleCode, translateForE2E } from "./i18n.js";
-type SettingsSection = "general" | "appearance" | "providers" | "shortcuts" | "analysis";
+type SettingsSection = "general" | "appearance" | "providers" | "shortcuts";
type ProviderSettingLabel =
| "base"
| "config_file"
@@ -15,7 +15,6 @@ const SETTINGS_SECTION_KEYS: Record {
- await page
- .getByRole("button", {
- name: locale ? settingsSectionLabel(section, locale) : settingsSectionPattern(section),
- })
- .click();
+ await openSettingsPage(page, section);
+}
+
+export async function openSettingsPage(page: Page, section?: SettingsSection): Promise {
+ const reenterButton = page.getByRole("button", {
+ name: localizedPattern("auth.session_gate_reenter"),
+ });
+ const targetUrl = section ? `/more/settings/${section}` : "/more/settings/general";
+ const settingsRoot = page.locator(
+ ".more-features-page .settings-content, .settings-page, .settings-container"
+ );
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
+ const visibleState = await Promise.race<"settings" | "gate">([
+ settingsRoot.waitFor({ state: "visible", timeout: 10000 }).then(() => "settings" as const),
+ reenterButton.waitFor({ state: "visible", timeout: 10000 }).then(() => "gate" as const),
+ ]).catch(() => null);
+
+ if (visibleState === "gate" || page.url().includes("/session-gate")) {
+ await clickVisibleElement(reenterButton);
+ await page.waitForURL(/\/$/, { timeout: 10000 }).catch(() => {});
+ continue;
+ }
+
+ await expect(settingsRoot).toBeVisible();
+ await page.waitForTimeout(1000);
+
+ if (
+ (await reenterButton.isVisible().catch(() => false)) ||
+ page.url().includes("/session-gate")
+ ) {
+ await clickVisibleElement(reenterButton);
+ await page.waitForURL(/\/$/, { timeout: 10000 }).catch(() => {});
+ continue;
+ }
+
+ return;
+ }
+
+ await expect(settingsRoot).toBeVisible();
}
export function providerSettingLabel(
@@ -107,3 +142,14 @@ export function configFileLabel(label: ConfigFileLabel, locale: E2ELocaleCode =
export function configFilePattern(label: ConfigFileLabel): RegExp {
return localizedPattern(CONFIG_FILE_KEYS[label]);
}
+
+export async function clickVisibleElement(locator: Locator): Promise {
+ await expect(locator).toBeVisible();
+ await locator.evaluate((element) => {
+ if (!(element instanceof HTMLElement)) {
+ throw new Error("Expected clickable HTMLElement");
+ }
+
+ element.click();
+ });
+}
diff --git a/e2e/fixtures/seed-work-analysis-settings-db.ts b/e2e/fixtures/seed-work-analysis-settings-db.ts
index 9f2efe96e..eac365ad3 100644
--- a/e2e/fixtures/seed-work-analysis-settings-db.ts
+++ b/e2e/fixtures/seed-work-analysis-settings-db.ts
@@ -19,6 +19,9 @@ mkdirSync(stateDir, { recursive: true });
rmSync(join(stateDir, "state"), { recursive: true, force: true });
const now = Date.now();
+const HOUR_MS = 60 * 60 * 1000;
+const DAY_MS = 24 * HOUR_MS;
+const latestCompleteHourStart = now - (now % HOUR_MS) - HOUR_MS;
const siblingWorkspacePath = join(join(workspacePath, ".."), "workspace-b");
const externalWorkspacePath = join(join(workspacePath, ".."), "workspace-c");
const availableWorkspacePaths = [workspacePath, siblingWorkspacePath, externalWorkspacePath].sort(
@@ -32,7 +35,51 @@ const settingsRepo = new SettingsRepo({
filePath: join(stateDir, "state", "settings.json"),
});
const workAnalysisRepo = new WorkAnalysisRepo({
- filePath: join(stateDir, "state", "work-analysis.json"),
+ filePath: join(stateDir, "state", "work-analysis.sqlite"),
+});
+
+const buildIndexedSession = ({
+ sessionId,
+ workspacePath: targetWorkspacePath,
+ hourStart,
+ inputTokens,
+ outputTokens,
+}: {
+ sessionId: string;
+ workspacePath: string;
+ hourStart: number;
+ inputTokens: number;
+ outputTokens: number;
+}) => ({
+ providerId: "codex" as const,
+ sessionId,
+ workspacePath: targetWorkspacePath,
+ startedAt: hourStart,
+ lastActiveAt: hourStart + 45 * 60 * 1000,
+ sourceRef: `codex:${sessionId}`,
+ title: `${sessionId} summary`,
+ modelId: "gpt-5-codex",
+ gitBranch: "develop",
+ userTurnCount: 3,
+ assistantTurnCount: 4,
+ toolUseCount: 2,
+ usage: {
+ inputTokens,
+ outputTokens,
+ cachedInputTokens: Math.round(inputTokens * 0.1),
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: Math.round(inputTokens * 0.05),
+ reasoningOutputTokens: 0,
+ totalTokens: inputTokens + outputTokens,
+ },
+ usageCoverage: {
+ hasUsage: true,
+ callCount: 1,
+ callsWithTotalTokens: 1,
+ estimatedCallCount: 0,
+ },
+ parseErrorCount: 0,
+ timestampQuality: "explicit" as const,
});
workspaceRepo.create({
@@ -431,6 +478,73 @@ workAnalysisRepo.upsert({
},
});
+workAnalysisRepo.upsertHourlyIndex({
+ version: 1,
+ bucketMode: "hourly_session_slices",
+ indexedAt: now,
+ indexedThroughHourStart: latestCompleteHourStart,
+ sourceDigest: "analysis-e2e-source",
+ providerStatuses: [
+ {
+ providerId: "codex",
+ status: "supported",
+ sessionCount: 4,
+ parseErrorCount: 0,
+ warningCount: 0,
+ },
+ ],
+ buckets: [
+ {
+ hourStart: latestCompleteHourStart - 4 * DAY_MS,
+ sessions: [
+ buildIndexedSession({
+ sessionId: "sess-1",
+ workspacePath,
+ hourStart: latestCompleteHourStart - 4 * DAY_MS,
+ inputTokens: 360,
+ outputTokens: 260,
+ }),
+ ],
+ },
+ {
+ hourStart: latestCompleteHourStart - 3 * DAY_MS,
+ sessions: [
+ buildIndexedSession({
+ sessionId: "sess-2",
+ workspacePath,
+ hourStart: latestCompleteHourStart - 3 * DAY_MS,
+ inputTokens: 220,
+ outputTokens: 200,
+ }),
+ ],
+ },
+ {
+ hourStart: latestCompleteHourStart - 2 * DAY_MS,
+ sessions: [
+ buildIndexedSession({
+ sessionId: "sess-3",
+ workspacePath: siblingWorkspacePath,
+ hourStart: latestCompleteHourStart - 2 * DAY_MS,
+ inputTokens: 210,
+ outputTokens: 100,
+ }),
+ ],
+ },
+ {
+ hourStart: latestCompleteHourStart - DAY_MS,
+ sessions: [
+ buildIndexedSession({
+ sessionId: "sess-4",
+ workspacePath: externalWorkspacePath,
+ hourStart: latestCompleteHourStart - DAY_MS,
+ inputTokens: 530,
+ outputTokens: 320,
+ }),
+ ],
+ },
+ ],
+});
+
settingsRepo.set("workspace.lastViewedTarget", {
workspaceId: WORKSPACE_ID,
updatedAt: now,
diff --git a/e2e/fixtures/start-analysis-server.ts b/e2e/fixtures/start-analysis-server.ts
new file mode 100644
index 000000000..0f299065e
--- /dev/null
+++ b/e2e/fixtures/start-analysis-server.ts
@@ -0,0 +1,18 @@
+import { createServer } from "../../packages/server/src/server.ts";
+import { WorkAnalysisService } from "../../packages/server/src/work-analysis/service.ts";
+
+WorkAnalysisService.prototype.startAutoScan = function startAutoScanForE2E() {
+ // Keep the seeded hourly index stable for analysis acceptance.
+};
+
+const server = await createServer();
+
+process.on("SIGINT", async () => {
+ await server.stop();
+ process.exit(0);
+});
+
+process.on("SIGTERM", async () => {
+ await server.stop();
+ process.exit(0);
+});
diff --git a/e2e/specs/app-shell/i18n.spec.ts b/e2e/specs/app-shell/i18n.spec.ts
index 0677b4395..9fa41864b 100644
--- a/e2e/specs/app-shell/i18n.spec.ts
+++ b/e2e/specs/app-shell/i18n.spec.ts
@@ -9,19 +9,17 @@ import {
test.describe("@phase2 i18n acceptance", () => {
test("P2I-01 language switch to English", async ({ page }) => {
- await page.goto("/settings");
- await openSettingsSection(page, "appearance");
+ await openSettingsSection(page, "general");
await page.getByRole("button", { name: translateForE2E("settings.language.en") }).click();
await expect(page.locator(".settings-group-title").first()).toHaveText(
- settingsGroupLabel("theme", "en")
+ settingsGroupLabel("notifications", "en")
);
});
test("P2I-02 language persists after reload", async ({ page }) => {
- await page.goto("/settings");
- await openSettingsSection(page, "appearance");
+ await openSettingsSection(page, "general");
await page.getByRole("button", { name: translateForE2E("settings.language.en") }).click();
await page.reload();
@@ -41,8 +39,8 @@ test.describe("@phase2 i18n acceptance", () => {
await expectAppEntry(page);
// Navigate to settings
- await page.goto("/settings");
- await expect(page.locator(".settings-page")).toBeVisible();
+ await page.goto("/more/settings/general");
+ await expect(page.getByTestId("more-features-page")).toBeVisible();
});
test("P2I-04 fallback to default language", async ({ page }) => {
diff --git a/e2e/specs/settings/analysis.spec.ts b/e2e/specs/settings/analysis.spec.ts
index c68472377..b83ce52f2 100644
--- a/e2e/specs/settings/analysis.spec.ts
+++ b/e2e/specs/settings/analysis.spec.ts
@@ -1,30 +1,107 @@
-import { spawnSync } from "node:child_process";
+import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { createServer } from "node:net";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
-import { expectSettingsEntryPoint } from "../../fixtures/app-entry";
import { translatePatternForE2E } from "../../fixtures/i18n";
+const HOST = "127.0.0.1";
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
+const WEB_ROOT = join(REPO_ROOT, "packages", "web");
const SPEC_DIR = fileURLToPath(new URL(".", import.meta.url));
-const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
-test.describe("@phase2 settings analysis acceptance", () => {
- let sandboxDir: string;
- let workspaceDir: string;
+let sandboxDir: string;
+let stateDir: string;
+let runtimeDir: string;
+let workspaceDir: string;
+let backendHttpUrl = "";
+let baseUrl = "";
+let backendProcess: ChildProcess | undefined;
+let webProcess: ChildProcess | undefined;
+
+function startProcess(
+ command: string,
+ args: string[],
+ options: {
+ cwd: string;
+ env?: NodeJS.ProcessEnv;
+ }
+): ChildProcess {
+ const child = spawn(command, args, {
+ cwd: options.cwd,
+ env: {
+ ...process.env,
+ ...options.env,
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ child.stdout?.on("data", () => {});
+ child.stderr?.on("data", () => {});
+ child.on("error", (error) => {
+ throw error;
+ });
+
+ return child;
+}
+
+async function reservePort(host: string): Promise {
+ return await new Promise((resolve, reject) => {
+ const server = createServer();
+
+ server.once("error", reject);
+ server.listen(0, host, () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Failed to reserve an ephemeral port for analysis acceptance"));
+ return;
+ }
+
+ const { port } = address;
+ server.close((closeError) => {
+ if (closeError) {
+ reject(closeError);
+ return;
+ }
+ resolve(port);
+ });
+ });
+ });
+}
+
+async function waitForHttp(url: string, timeoutMs = 30000): Promise {
+ const start = Date.now();
+
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) {
+ return;
+ }
+ } catch {
+ // Keep polling until the server is reachable.
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
- test.beforeEach(() => {
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+test.describe("@phase2 settings analysis acceptance", () => {
+ test.beforeAll(async () => {
sandboxDir = mkdtempSync(join(tmpdir(), "coder-studio-analysis-settings-e2e-"));
+ stateDir = join(sandboxDir, "state");
+ runtimeDir = join(sandboxDir, "runtime");
workspaceDir = join(sandboxDir, "workspace");
- mkdirSync(workspaceDir, { recursive: true });
- const stateDir = process.env.CODER_STUDIO_PHASE1_STATE_DIR;
- if (!stateDir) {
- throw new Error("CODER_STUDIO_PHASE1_STATE_DIR must be set for settings analysis e2e");
- }
+ mkdirSync(runtimeDir, { recursive: true });
+ mkdirSync(workspaceDir, { recursive: true });
- const result = spawnSync(
+ const seed = spawnSync(
"pnpm",
["exec", "tsx", "e2e/fixtures/seed-work-analysis-settings-db.ts", stateDir, workspaceDir],
{
@@ -33,87 +110,144 @@ test.describe("@phase2 settings analysis acceptance", () => {
}
);
- if (result.status !== 0) {
- throw new Error(`Failed to seed work analysis settings state: ${result.status ?? "unknown"}`);
+ if (seed.status !== 0) {
+ throw new Error(`Failed to seed work analysis settings state: ${seed.status ?? "unknown"}`);
}
+
+ const serverPort = await reservePort(HOST);
+ const webPort = await reservePort(HOST);
+ backendHttpUrl = `http://${HOST}:${serverPort}`;
+ baseUrl = `http://${HOST}:${webPort}`;
+
+ backendProcess = startProcess(
+ "pnpm",
+ ["exec", "tsx", "e2e/fixtures/start-analysis-server.ts"],
+ {
+ cwd: REPO_ROOT,
+ env: {
+ HOST,
+ PORT: String(serverPort),
+ STATE_DIR: stateDir,
+ RUNTIME_DIR: runtimeDir,
+ NO_AUTH: "true",
+ },
+ }
+ );
+
+ await waitForHttp(`${backendHttpUrl}/healthz`);
+
+ webProcess = startProcess("pnpm", ["exec", "vite", "--host", HOST, "--port", String(webPort)], {
+ cwd: WEB_ROOT,
+ env: {
+ NODE_ENV: "development",
+ VITE_BACKEND_HTTP_URL: backendHttpUrl,
+ VITE_BACKEND_WS_URL: `ws://${HOST}:${serverPort}/ws`,
+ },
+ });
+
+ await waitForHttp(`${baseUrl}/`);
});
- test.afterEach(() => {
+ test.afterAll(async () => {
+ const kill = async (child: ChildProcess | undefined) => {
+ if (!child || child.killed) return;
+ child.kill("SIGTERM");
+ await new Promise((resolve) => child.once("exit", resolve));
+ };
+
+ await kill(webProcess);
+ await kill(backendProcess);
rmSync(sandboxDir, { recursive: true, force: true });
});
- test("P2S-09 analysis settings renders all discovered workspace paths", async ({ page }) => {
- await page.goto("/workspace");
+ test("P2S-09 more-features page routes to analytics and preserves discovered workspace paths", async ({
+ page,
+ }) => {
+ await page.goto(`${baseUrl}/workspace`);
await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 });
- const settingsEntry = await expectSettingsEntryPoint(page);
- await settingsEntry.click();
- await expect(page).toHaveURL(/\/settings$/);
+ await page.getByTestId("more-open").click();
+ await expect(page).toHaveURL(/\/more\/settings\/general$/, { timeout: 20000 });
+ await expect(page.getByTestId("more-features-page")).toBeVisible();
- await page
- .getByRole("button", { name: translatePatternForE2E("settings.analysis.title") })
- .click();
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.settings") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.analysis") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.about") })
+ ).toBeVisible();
+
+ await page.screenshot({
+ path: join(SPEC_DIR, "../../test-results/more-features-root.png"),
+ fullPage: true,
+ });
- await expect(page.locator('[data-testid="session-analysis-settings"]')).toBeVisible();
- await expect(page.locator("body")).not.toContainText(
- "An error occurred in the component."
- );
await expect(
- page.getByText(translatePatternForE2E("settings.analysis.provider_sources"))
+ page.getByRole("button", { name: translatePatternForE2E("settings.general") })
).toBeVisible();
await expect(
- page.getByText(
- translatePatternForE2E("settings.analysis.log_coverage_summary", {
- workspaceCount: "3",
- sessionCount: "4",
- providerCount: "1",
- })
- )
+ page.getByRole("button", { name: translatePatternForE2E("settings.providers") })
).toBeVisible();
- await expect(page.getByText(/workspace$/)).toBeVisible();
- await expect(page.getByText(/workspace-b$/)).toBeVisible();
- await expect(page.getByText(/workspace-c$/)).toBeVisible();
await expect(
- page.getByText(translatePatternForE2E("settings.analysis.empty_workspace"))
- ).toHaveCount(0);
- await page
- .getByRole("button", { name: translatePatternForE2E("settings.analysis.open_analytics") })
- .click();
- await expect(page).toHaveURL(/\/analytics/);
- await expect(page.getByTestId("work-analytics-page")).toBeVisible();
+ page.getByRole("button", { name: translatePatternForE2E("settings.appearance") })
+ ).toBeVisible();
await expect(
- page.getByRole("tablist", {
- name: translatePatternForE2E("settings.analysis.analytics_sections"),
- })
+ page.getByRole("button", { name: translatePatternForE2E("settings.shortcuts.title") })
).toBeVisible();
await expect(
- page.getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_overview") })
- ).toHaveAttribute("aria-selected", "true");
+ page.getByRole("button", { name: translatePatternForE2E("settings.analysis.title") })
+ ).toHaveCount(0);
await page.screenshot({
- path: join(SPEC_DIR, "../../test-results/settings-analysis-overview.png"),
+ path: join(SPEC_DIR, "../../test-results/more-features-settings.png"),
fullPage: true,
});
- await page
- .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_compare") })
- .click();
- await expect(page).toHaveURL(/tab=compare/);
+ await page.getByRole("tab", { name: translatePatternForE2E("more.category.analysis") }).click();
+
+ await expect(page).toHaveURL(/\/more\/analysis\/analytics$/, { timeout: 20000 });
+ await expect(
+ page
+ .getByTestId("work-analysis-root")
+ .getByRole("heading", { name: translatePatternForE2E("settings.analysis.title") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("monitoring.title") })
+ ).toBeVisible();
await expect(
- page.getByText(translatePatternForE2E("settings.analysis.workspace_breakdown"))
+ page.getByRole("button", { name: translatePatternForE2E("settings.diagnostics.title") })
).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.general") })
+ ).toHaveCount(0);
+ await expect(page.getByTestId("work-analysis-data-source")).toContainText("Codex 4");
+
+ await page.screenshot({
+ path: join(SPEC_DIR, "../../test-results/settings-analysis-overview.png"),
+ fullPage: true,
+ });
+
+ await expect(page.getByTestId("token-trend-chart")).toBeVisible();
+ await expect(page.getByTestId("token-contribution-row")).toBeVisible();
+
+ await page.getByRole("button", { name: /目录筛选/ }).click();
+ await expect(page.getByRole("checkbox", { name: /workspace$/ })).toBeVisible();
+ await expect(page.getByRole("checkbox", { name: /workspace-b$/ })).toBeVisible();
+ await expect(page.getByRole("checkbox", { name: /workspace-c$/ })).toBeVisible();
+
await page.screenshot({
path: join(SPEC_DIR, "../../test-results/settings-analysis-compare.png"),
fullPage: true,
});
- await page
- .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_yield") })
- .click();
- await expect(page).toHaveURL(/tab=yield/);
+ await page.getByRole("button", { name: "全部目录", exact: true }).click();
await expect(
- page.getByText(translatePatternForE2E("settings.analysis.top_sessions"))
+ page.getByTestId("work-analysis-root").getByRole("heading", { name: "工作分析" })
).toBeVisible();
+
await page.screenshot({
path: join(SPEC_DIR, "../../test-results/settings-analysis-yield.png"),
fullPage: true,
diff --git a/e2e/specs/settings/general.spec.ts b/e2e/specs/settings/general.spec.ts
index b9553856f..605b79779 100644
--- a/e2e/specs/settings/general.spec.ts
+++ b/e2e/specs/settings/general.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
-import { translateForE2E } from "../../fixtures/i18n";
import {
+ clickVisibleElement,
configFilePattern,
openSettingsSection,
providerSettingPattern,
@@ -9,62 +9,76 @@ import {
test.describe("@phase2 settings acceptance", () => {
test("P2S-01 settings page opens and renders provider configuration", async ({ page }) => {
- await page.goto("/settings");
- await expect(page.locator(".settings-page")).toBeVisible();
await openSettingsSection(page, "providers");
await expect(page.locator(".settings-provider-content")).toBeVisible();
await expect(page.locator(".settings-command-preview")).toBeVisible();
});
- test("P2S-02 provider model change triggers preview update", async ({ page }) => {
- await page.goto("/settings");
+ test("P2S-02 provider startup args accept multiline edits", async ({ page }) => {
await openSettingsSection(page, "providers");
const argsInput = page.getByLabel(providerSettingPattern("startup_args"));
+
await argsInput.fill("--verbose\n--print");
- await expect(page.locator(".settings-command-preview")).toContainText("--print");
+ await expect(argsInput).toHaveValue("--verbose\n--print");
});
test("P2S-03 inject hooks updates provider status UI", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
- await page.getByRole("tab", { name: providerSettingPattern("config_file") }).click();
+ await clickVisibleElement(
+ page.getByRole("tab", { name: providerSettingPattern("config_file") })
+ );
await expect(page.getByText(configFilePattern("claude"))).toBeVisible();
});
- test("P2S-04 codex provider shows cwd override field", async ({ page }) => {
- await page.goto("/settings");
+ test("P2S-04 codex provider shows startup args editor", async ({ page }) => {
await openSettingsSection(page, "providers");
- await page.getByRole("tab", { name: "Codex" }).click();
+ await clickVisibleElement(page.getByRole("tab", { name: "Codex" }));
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
- await expect(page.locator(".settings-provider-content textarea.input")).toBeVisible();
+ await expect(page.locator(".settings-provider-args-input")).toBeVisible();
});
test("P2S-05 appearance settings show theme options", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "appearance");
await expect(
page.locator(".settings-group-title").filter({ hasText: settingsGroupPattern("theme") })
).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:Mint|薄荷)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:Graphite)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:Nord)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:High Contrast|高对比)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:深色|Dark)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:浅色|Light)$/ })).toBeVisible();
+ const themePicker = page.getByRole("button", { name: /^(?:主题|Theme)\s+.+$/ });
+
+ await expect(themePicker).toBeVisible();
+ await clickVisibleElement(themePicker);
+
+ const themeListbox = page.getByRole("listbox", { name: /^(?:主题|Theme)$/ });
+
+ await expect(themeListbox).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:基础主题|Core Themes)$/ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:Mint 深色|Mint Dark)$/ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:Graphite 浅色|Graphite Light)$/ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:Nord 深色|Nord Dark)$/ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:高对比浅色|High Contrast Light)$/ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", { name: /^(?:四季主题|Seasonal Themes)$/ })
+ ).toBeVisible();
});
test("P2S-06 settings persist after page reload", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
await page.getByLabel(providerSettingPattern("startup_args")).fill("--persisted-e2e");
await page.reload();
await openSettingsSection(page, "providers");
- await expect(page.locator(".settings-page")).toBeVisible();
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
});
test("P2S-07 hook status shows registration state", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
// Check for hook status indicator
const statusIndicator = page.locator(".settings-provider-status, .hook-status");
@@ -74,7 +88,6 @@ test.describe("@phase2 settings acceptance", () => {
});
test("P2S-08 keyboard shortcuts settings accessible", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "shortcuts");
await expect(page.locator(".shortcuts-category-tabs")).toBeVisible();
await expect(page.locator(".shortcuts-list")).toBeVisible();
diff --git a/e2e/specs/settings/mobile-copy-on-select.spec.ts b/e2e/specs/settings/mobile-copy-on-select.spec.ts
index d6e952e88..0342cfef9 100644
--- a/e2e/specs/settings/mobile-copy-on-select.spec.ts
+++ b/e2e/specs/settings/mobile-copy-on-select.spec.ts
@@ -281,8 +281,6 @@ async function longPressTerminalRowBlankRightSide(page: Page, rowIndex: number):
}
async function setMobileCopyOnSelect(page: Page, enabled: boolean): Promise {
- await page.goto("/settings");
- await expect(page.locator(".settings-page")).toBeVisible({ timeout: 15000 });
await openSettingsSection(page, "general");
const toggle = page.getByRole("switch", {
diff --git a/e2e/specs/settings/provider.spec.ts b/e2e/specs/settings/provider.spec.ts
index 894970fa8..621f1014d 100644
--- a/e2e/specs/settings/provider.spec.ts
+++ b/e2e/specs/settings/provider.spec.ts
@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";
+import { translatePatternForE2E } from "../../fixtures/i18n";
import {
+ clickVisibleElement,
configFilePattern,
openSettingsSection,
providerSettingPattern,
@@ -9,10 +11,11 @@ test.describe("@phase2 provider acceptance", () => {
test("desktop uses provider sub-navigation and preserves config view across providers", async ({
page,
}) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
- await expect(page.getByRole("tablist", { name: "Providers" })).toBeVisible();
+ await expect(
+ page.getByRole("tablist", { name: translatePatternForE2E("settings.providers") })
+ ).toBeVisible();
await expect(page.getByRole("tab", { name: "Claude" })).toHaveAttribute(
"aria-selected",
"true"
@@ -24,41 +27,39 @@ test.describe("@phase2 provider acceptance", () => {
);
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
- await page.getByRole("tab", { name: providerSettingPattern("config_file") }).click();
+ await clickVisibleElement(
+ page.getByRole("tab", { name: providerSettingPattern("config_file") })
+ );
await expect(
page.getByRole("tab", { name: providerSettingPattern("config_file") })
).toHaveAttribute("aria-selected", "true");
await expect(page.getByText(configFilePattern("claude"))).toBeVisible();
await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible();
- await page.getByRole("tab", { name: "Codex" }).click();
+ await clickVisibleElement(page.getByRole("tab", { name: "Codex" }));
await expect(
page.getByRole("tab", { name: providerSettingPattern("config_file") })
).toHaveAttribute("aria-selected", "true");
await expect(page.getByText(configFilePattern("codex"))).toBeVisible();
await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible();
- await page.getByRole("tab", { name: providerSettingPattern("base") }).click();
+ await clickVisibleElement(page.getByRole("tab", { name: providerSettingPattern("base") }));
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
});
- test("desktop updates startup args per provider and keeps command preview scoped", async ({
- page,
- }) => {
- await page.goto("/settings");
+ test("desktop scopes startup args per provider", async ({ page }) => {
await openSettingsSection(page, "providers");
const argsInput = page.getByLabel(providerSettingPattern("startup_args"));
await expect(argsInput).toBeVisible();
await argsInput.fill("--verbose\n--print");
- await expect(page.locator(".settings-command-preview")).toContainText("--print");
+ await expect(argsInput).toHaveValue("--verbose\n--print");
- await page.getByRole("tab", { name: "Codex" }).click();
+ await clickVisibleElement(page.getByRole("tab", { name: "Codex" }));
await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toHaveValue(
"--verbose\n--print"
);
- await expect(page.locator(".settings-command-preview")).not.toContainText("--print");
});
test("mobile enters config editor through secondary action and returns to base settings", async ({
@@ -70,21 +71,25 @@ test.describe("@phase2 provider acceptance", () => {
const page = await context.newPage();
try {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
await expect(page.locator(".settings-provider-subnav")).toHaveCount(0);
- await page
- .getByRole("button", { name: providerSettingPattern("open_config_file_editor") })
- .click();
+ await clickVisibleElement(
+ page.getByRole("button", { name: providerSettingPattern("open_config_file_editor") })
+ );
await expect(
page.getByRole("button", { name: providerSettingPattern("back_to_base") })
).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: translatePatternForE2E("settings.config_files.title"),
+ })
+ ).toBeVisible();
await expect(page.getByText(configFilePattern("claude"))).toBeVisible();
- await page.getByRole("tab", { name: "Codex" }).click();
+ await clickVisibleElement(page.getByRole("tab", { name: "Codex" }));
await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible();
await expect(
page.getByRole("button", { name: providerSettingPattern("back_to_base") })
diff --git a/e2e/specs/settings/visual.spec.ts b/e2e/specs/settings/visual.spec.ts
index 99654aeac..0537b0277 100644
--- a/e2e/specs/settings/visual.spec.ts
+++ b/e2e/specs/settings/visual.spec.ts
@@ -1,43 +1,90 @@
import { expect, test } from "@playwright/test";
+import { translatePatternForE2E } from "../../fixtures/i18n";
import {
+ openSettingsPage,
openSettingsSection,
providerSettingPattern,
settingsGroupPattern,
} from "../../fixtures/phase2-i18n";
test.describe("@phase2 settings visual acceptance", () => {
- test("P2V-01 settings page layout baseline", async ({ page }) => {
- await page.goto("/settings");
- // Check settings page structure
- await expect(page.locator(".settings-page, .settings-container")).toBeVisible();
- // Check navigation buttons exist
- const navButtons = page.locator(".settings-nav button, .settings-sidebar button");
- expect(await navButtons.count()).toBeGreaterThan(0);
+ test("P2V-01 more-features settings shell layout baseline", async ({ page }) => {
+ await openSettingsPage(page);
+ const categoryTabs = page.locator('.more-features-tabs [role="tab"]');
+ const navButtons = page.locator(".more-features-nav button");
+
+ await expect(page.getByTestId("more-features-page")).toBeVisible();
+ await expect(page.locator(".more-features-shell")).toBeVisible();
+ await expect(categoryTabs).toHaveCount(3);
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.settings") })
+ ).toHaveAttribute("aria-selected", "true");
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.analysis") })
+ ).toHaveAttribute("aria-selected", "false");
+ await expect(
+ page.getByRole("tab", { name: translatePatternForE2E("more.category.about") })
+ ).toHaveAttribute("aria-selected", "false");
+ await expect(navButtons).toHaveCount(4);
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.general") })
+ ).toHaveAttribute("aria-current", "page");
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.providers") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.appearance") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.shortcuts.title") })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.analysis.title") })
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("monitoring.title") })
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.diagnostics.title") })
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: translatePatternForE2E("settings.about.title") })
+ ).toHaveCount(0);
+ await expect(page.getByTestId("more-features-page")).toBeVisible();
+ await expect(page.locator(".more-features-page__frame--compact-top")).toHaveCount(1);
+ await expect(page.getByTestId("more-current-route")).toHaveCount(0);
});
- test("P2V-02 settings page color tokens", async ({ page }) => {
- await page.goto("/settings");
- // Verify CSS tokens are applied
- const bgColor = await page
- .locator(".settings-page, .settings-container")
- .evaluate((el) => getComputedStyle(el).backgroundColor);
- // Should use token-based color (not hardcoded white/black)
- expect(bgColor).toBeTruthy();
+ test("P2V-02 more-features shell color tokens", async ({ page }) => {
+ await openSettingsPage(page);
+ const pageBackground = await page
+ .locator(".more-features-page--desktop")
+ .evaluate((el) => getComputedStyle(el).backgroundImage);
+ const shellBorderColor = await page
+ .locator(".more-features-shell")
+ .evaluate((el) => getComputedStyle(el).borderColor);
+
+ expect(pageBackground).toBeTruthy();
+ expect(pageBackground).not.toBe("none");
+ expect(shellBorderColor).toBeTruthy();
});
test("P2V-03 provider card styling", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
- // Provider cards should have consistent styling
- const providerCard = page.locator(".settings-provider-content");
- await expect(providerCard).toBeVisible();
- // Check for proper spacing
- const padding = await providerCard.evaluate((el) => getComputedStyle(el).padding);
- expect(padding).toBeTruthy();
+ const providerLayout = page.locator(".settings-provider-base-layout");
+ const providerTab = page.locator(".settings-provider-tab").first();
+
+ await expect(providerLayout).toBeVisible();
+ await expect(providerTab).toBeVisible();
+
+ const gap = await providerLayout.evaluate((el) => getComputedStyle(el).gap);
+ const borderRadius = await providerTab.evaluate((el) => getComputedStyle(el).borderRadius);
+
+ expect(gap).toBeTruthy();
+ expect(borderRadius).toBeTruthy();
});
test("P2V-04 appearance section layout", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "appearance");
await expect(
page.locator(".settings-group-title").filter({ hasText: settingsGroupPattern("theme") })
@@ -45,7 +92,6 @@ test.describe("@phase2 settings visual acceptance", () => {
});
test("P2V-05 input field focus states", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
// Find an input and focus it
const input = page.getByLabel(providerSettingPattern("startup_args"));
@@ -60,7 +106,6 @@ test.describe("@phase2 settings visual acceptance", () => {
});
test("P2V-06 button hover states", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "providers");
// Find a button and hover
const button = page.locator(".settings-provider-tab").first();
@@ -70,7 +115,7 @@ test.describe("@phase2 settings visual acceptance", () => {
});
test("P2V-07 i18n layout RTL support", async ({ page }) => {
- await page.goto("/settings");
+ await openSettingsPage(page);
// Check if RTL is supported (dir attribute)
const dir = await page.locator("html").getAttribute("dir");
// RTL might be 'rtl' or null/'ltr'
@@ -78,9 +123,31 @@ test.describe("@phase2 settings visual acceptance", () => {
});
test("P2V-08 theme toggle visual feedback", async ({ page }) => {
- await page.goto("/settings");
await openSettingsSection(page, "appearance");
- await expect(page.getByRole("button", { name: /^(?:深色|Dark)$/ })).toBeVisible();
- await expect(page.getByRole("button", { name: /^(?:浅色|Light)$/ })).toBeVisible();
+ const themePicker = page.getByRole("button", { name: /^(?:主题|Theme)\s+.+$/ });
+
+ await expect(themePicker).toBeVisible();
+ await themePicker.click();
+
+ const themeListbox = page.getByRole("listbox", {
+ name: translatePatternForE2E("settings.theme.title"),
+ });
+
+ await expect(themeListbox).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", {
+ name: translatePatternForE2E("settings.theme.group_core"),
+ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", {
+ name: translatePatternForE2E("settings.theme.mint_dark"),
+ })
+ ).toBeVisible();
+ await expect(
+ themeListbox.getByRole("option", {
+ name: translatePatternForE2E("settings.theme.group_seasonal"),
+ })
+ ).toBeVisible();
});
});
diff --git a/package.json b/package.json
index 16b845be5..8aa716c22 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"publish:wiki": "tsx scripts/publish-wiki.ts",
"ci:lint": "pnpm exec biome check --diagnostic-level=error --max-diagnostics=none .",
"ci:test:scripts": "pnpm exec vitest run --config scripts/vitest.config.ts scripts/publish-cli.test.ts scripts/build-cli.test.ts scripts/validate-changesets.test.ts scripts/husky-pre-commit.test.ts --environment node",
- "ci:test:workspace": "pnpm -r --filter './packages/**' --if-present run test",
+ "ci:test:workspace": "pnpm -r --filter './packages/**' --filter '!@coder-studio/web' --if-present run test && pnpm --filter @coder-studio/web test",
"ci:test": "pnpm ci:test:scripts && pnpm ci:test:workspace",
"ci:typecheck": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit",
"ci:build": "pnpm --filter @coder-studio/server build && pnpm build",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 89efda4bc..069ecb8c4 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1,14 +1,8 @@
# @spencer-kit/coder-studio
-> Deploy once, code everywhere.
+Self-hosted browser workspace for AI coding agents, review, supervision, and cross-device continuation.
-Coder Studio is a browser-based AI coding workspace that lets you keep using the same local project, Agent sessions, files, Git, and terminal across devices.
-
-## What It Solves
-
-Typical AI coding workflows stay tied to the machine where the CLI is running. If you leave that device, it becomes harder to keep watching progress, reviewing diffs, or continuing the same workspace from somewhere else.
-
-Coder Studio turns that into a persistent workspace you can reopen from another computer, tablet, or phone without handing off the environment again.
+Coder Studio runs on your machine and opens your local projects in a browser workspace. It brings popular coding-agent CLIs, terminals, files, Git diff review, Supervisor loops, Work Analysis, and Skills into one place.
## Install
@@ -16,19 +10,60 @@ Coder Studio turns that into a persistent workspace you can reopen from another
npm install -g @spencer-kit/coder-studio
```
+Coder Studio requires Node.js 24 or newer.
+
## Quick Start
```bash
coder-studio open
```
-Then open a workspace in the browser and start a Claude or Codex session.
+Then:
+
+1. Open a local repository.
+2. Start an agent session. For a first trial, Claude or Codex is the recommended path.
+3. Ask the agent for a small change.
+4. Review the changed files and Git diff beside the session.
+5. Reopen the same workspace from another device when you want to monitor progress.
+
+## Provider CLIs
+
+Coder Studio does not bundle AI models. Install the local CLI for the agent you want to run:
+
+```bash
+npm install -g @anthropic-ai/claude-code
+npm install -g @openai/codex
+npm install -g @google/gemini-cli
+npm install -g opencode-ai
+```
+
+After installation, verify:
+
+```bash
+claude --version
+codex --version
+gemini --version
+opencode --version
+```
+
+Cursor Agent uses the Cursor CLI install flow and exposes the `agent` command. See the Provider docs for the full built-in provider list and custom-provider notes.
## More Information
-- Full project README: https://github.com/spencerkit/coder-studio/blob/main/README.md
-- Chinese README: https://github.com/spencerkit/coder-studio/blob/main/README.zh-CN.md
-- Documentation: https://github.com/spencerkit/coder-studio/tree/main/docs/help
+- GitHub README: https://github.com/spencerkit/coder-studio#readme
+- English Quick Start: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/Quick-Start.md
+- First Agent Run: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/First-Agent-Run.md
+- Security and Privacy: https://github.com/spencerkit/coder-studio/blob/main/docs/wiki/Security-and-Privacy.md
+- 中文帮助中心: https://github.com/spencerkit/coder-studio/blob/main/docs/help/README.md
+
+## What It Is Not
+
+- Not a cloud IDE.
+- Not a VS Code replacement.
+- Not an AI model provider.
+- Not a promise that phone screens replace desktop coding.
+
+Coder Studio is a workbench around local repositories, local shells, and the AI coding agent CLIs you choose to install.
## License
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 501e7dbb0..2b912c2d8 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -2,7 +2,7 @@
"name": "@spencer-kit/coder-studio",
"version": "0.5.1",
"type": "module",
- "description": "Deploy once, code everywhere. Browser-based AI coding workspace for Claude Code and Codex.",
+ "description": "Self-hosted browser workspace for AI coding agents, review, supervision, and cross-device continuation.",
"main": "./src/index.ts",
"bin": {
"coder-studio": "./src/bin.ts"
@@ -67,7 +67,18 @@
"engines": {
"node": ">=24.0.0"
},
- "keywords": ["cli", "coder-studio", "agent", "development", "tools"],
+ "keywords": [
+ "ai-coding",
+ "ai-agent",
+ "agent-workspace",
+ "browser-ide",
+ "claude-code",
+ "codex",
+ "self-hosted",
+ "cross-device",
+ "terminal",
+ "git"
+ ],
"author": "Coder Studio",
"license": "MIT",
"repository": {
diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts
index 41ece54bf..d93ad5dac 100644
--- a/packages/cli/src/bin.test.ts
+++ b/packages/cli/src/bin.test.ts
@@ -99,6 +99,7 @@ beforeEach(() => {
});
afterEach(() => {
+ vi.unstubAllEnvs();
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -568,6 +569,7 @@ describe("main", () => {
});
it("prints identify output", async () => {
+ vi.stubEnv("CODER_STUDIO", "");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await main(["identify", "--json"]);
@@ -671,6 +673,269 @@ describe("main", () => {
diff: "diff --git a/a b/a\n",
});
});
+
+ it("prints UI open-file dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-1",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "ui",
+ "open-file",
+ "--workspace",
+ "ws-1",
+ "--path",
+ "src/index.ts",
+ "--line",
+ "12",
+ "--column",
+ "3",
+ "--json",
+ ]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "editor.openFile",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ line: 12,
+ column: 3,
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-1",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+
+ it("prints UI open-url dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-2",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "ui",
+ "open-url",
+ "--workspace",
+ "ws-1",
+ "--url",
+ "http://127.0.0.1:5173",
+ "--json",
+ ]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "browser.openUrl",
+ workspaceId: "ws-1",
+ url: "http://127.0.0.1:5173",
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-2",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+
+ it("prints UI close-file dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-close-file",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main(["ui", "close-file", "--workspace", "ws-1", "--path", "src/index.ts", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "editor.closeFile",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-close-file",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+
+ it("prints UI close-url dispatch output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({
+ accepted: true,
+ requestId: "req-close-url",
+ topic: "workspace.ws-1.ui.action",
+ });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "ui",
+ "close-url",
+ "--workspace",
+ "ws-1",
+ "--url",
+ "http://127.0.0.1:5173",
+ "--json",
+ ]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: {
+ type: "browser.closeUrl",
+ workspaceId: "ws-1",
+ url: "http://127.0.0.1:5173",
+ },
+ source: { kind: "agent" },
+ },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual({
+ accepted: true,
+ requestId: "req-close-url",
+ topic: "workspace.ws-1.ui.action",
+ });
+ });
+
+ it("prints memory list output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce([{ id: "mem-1", workspaceId: "ws-1" }]);
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main(["memory", "list", "--workspace", "ws-1", "--type", "decision", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "memory.list",
+ args: { workspaceId: "ws-1", type: "decision" },
+ });
+ expect(JSON.parse(logSpy.mock.calls[0]?.[0] as string)).toEqual([
+ { id: "mem-1", workspaceId: "ws-1" },
+ ]);
+ });
+
+ it("uses CODER_STUDIO_WORKSPACE_ID for memory commands when workspace is omitted", async () => {
+ vi.stubEnv("CODER_STUDIO_WORKSPACE_ID", "ws-env");
+ callCoderStudioCommand.mockResolvedValueOnce([{ id: "mem-1", workspaceId: "ws-env" }]);
+ vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main(["memory", "search", "testing", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "memory.search",
+ args: { workspaceId: "ws-env", query: "testing" },
+ });
+ });
+
+ it("prints memory get output through the Coder Studio command API", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({ id: "mem-1", workspaceId: "ws-1" });
+ vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main(["memory", "get", "mem-1", "--workspace", "ws-1", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "memory.get",
+ args: { workspaceId: "ws-1", id: "mem-1" },
+ });
+ });
+
+ it("maps memory add options to memory.create", async () => {
+ callCoderStudioCommand.mockResolvedValueOnce({ id: "mem-1" });
+ vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "memory",
+ "add",
+ "--workspace",
+ "ws-1",
+ "--type",
+ "decision",
+ "--content",
+ "Persist decisions outside the repo.",
+ "--tag",
+ "Architecture",
+ "--tag",
+ "Testing",
+ "--skill",
+ "coder-studio-memory",
+ "--json",
+ ]);
+
+ expect(callCoderStudioCommand).toHaveBeenCalledWith({
+ apiUrl: undefined,
+ op: "memory.create",
+ args: {
+ workspaceId: "ws-1",
+ type: "decision",
+ content: "Persist decisions outside the repo.",
+ tags: ["Architecture", "Testing"],
+ sourceHint: { skillSlug: "coder-studio-memory" },
+ },
+ });
+ });
+
+ it("maps memory update and delete commands", async () => {
+ callCoderStudioCommand.mockResolvedValue({ id: "mem-1", workspaceId: "ws-1" });
+ vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await main([
+ "memory",
+ "update",
+ "mem-1",
+ "--workspace",
+ "ws-1",
+ "--content",
+ "Updated content",
+ "--tag",
+ "storage",
+ "--json",
+ ]);
+ await main(["memory", "delete", "mem-1", "--workspace", "ws-1", "--json"]);
+
+ expect(callCoderStudioCommand).toHaveBeenNthCalledWith(1, {
+ apiUrl: undefined,
+ op: "memory.update",
+ args: {
+ workspaceId: "ws-1",
+ id: "mem-1",
+ content: "Updated content",
+ tags: ["storage"],
+ },
+ });
+ expect(callCoderStudioCommand).toHaveBeenNthCalledWith(2, {
+ apiUrl: undefined,
+ op: "memory.delete",
+ args: { workspaceId: "ws-1", id: "mem-1" },
+ });
+ });
});
describe("parseArgs", () => {
@@ -825,6 +1090,179 @@ describe("parseArgs", () => {
});
});
+ it("parses UI open-file command", () => {
+ expect(
+ parseArgs([
+ "ui",
+ "open-file",
+ "--workspace",
+ "ws-1",
+ "--path",
+ "src/index.ts",
+ "--line",
+ "12",
+ "--column",
+ "3",
+ "--json",
+ ])
+ ).toEqual({
+ command: "ui",
+ uiCommand: "open-file",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ line: 12,
+ column: 3,
+ json: true,
+ });
+ });
+
+ it("parses memory list command with workspace, type, tag, and json output", () => {
+ expect(
+ parseArgs([
+ "memory",
+ "list",
+ "--workspace",
+ "ws-1",
+ "--type",
+ "decision",
+ "--tag",
+ "Architecture",
+ "--json",
+ ])
+ ).toEqual({
+ command: "memory",
+ memoryCommand: "list",
+ workspaceId: "ws-1",
+ memoryType: "decision",
+ tags: ["Architecture"],
+ json: true,
+ });
+ });
+
+ it("parses UI close-file command", () => {
+ expect(
+ parseArgs(["ui", "close-file", "--workspace", "ws-1", "--path", "src/index.ts", "--json"])
+ ).toEqual({
+ command: "ui",
+ uiCommand: "close-file",
+ workspaceId: "ws-1",
+ path: "src/index.ts",
+ json: true,
+ });
+ });
+
+ it("parses UI close-url command", () => {
+ expect(
+ parseArgs([
+ "ui",
+ "close-url",
+ "--workspace",
+ "ws-1",
+ "--url",
+ "http://127.0.0.1:5173",
+ "--json",
+ ])
+ ).toEqual({
+ command: "ui",
+ uiCommand: "close-url",
+ workspaceId: "ws-1",
+ url: "http://127.0.0.1:5173",
+ json: true,
+ });
+ });
+
+ it("parses memory search command with query text", () => {
+ expect(parseArgs(["memory", "search", "testing", "--workspace", "ws-1"])).toEqual({
+ command: "memory",
+ memoryCommand: "search",
+ workspaceId: "ws-1",
+ query: "testing",
+ });
+ });
+
+ it("parses memory get command with id", () => {
+ expect(parseArgs(["memory", "get", "mem-1", "--workspace", "ws-1"])).toEqual({
+ command: "memory",
+ memoryCommand: "get",
+ memoryId: "mem-1",
+ workspaceId: "ws-1",
+ });
+ });
+
+ it("parses memory add command with content fields, tags, and skill source", () => {
+ expect(
+ parseArgs([
+ "memory",
+ "add",
+ "--workspace",
+ "ws-1",
+ "--type",
+ "decision",
+ "--content",
+ "Persist decisions.",
+ "--tag",
+ "Architecture",
+ "--tag",
+ "Testing",
+ "--skill",
+ "coder-studio-memory",
+ "--json",
+ ])
+ ).toEqual({
+ command: "memory",
+ memoryCommand: "add",
+ workspaceId: "ws-1",
+ memoryType: "decision",
+ content: "Persist decisions.",
+ tags: ["Architecture", "Testing"],
+ skillSlug: "coder-studio-memory",
+ json: true,
+ });
+ });
+
+ it("parses UI show-panel and run-command commands", () => {
+ expect(parseArgs(["ui", "show-panel", "--panel", "terminal"])).toEqual({
+ command: "ui",
+ uiCommand: "show-panel",
+ panel: "terminal",
+ });
+
+ expect(parseArgs(["ui", "run-command", "--command", "quickOpen.open"])).toEqual({
+ command: "ui",
+ uiCommand: "run-command",
+ uiCommandId: "quickOpen.open",
+ });
+ });
+
+ it("parses memory update and delete commands", () => {
+ expect(
+ parseArgs([
+ "memory",
+ "update",
+ "mem-1",
+ "--workspace",
+ "ws-1",
+ "--content",
+ "Updated content",
+ "--tag",
+ "storage",
+ ])
+ ).toEqual({
+ command: "memory",
+ memoryCommand: "update",
+ memoryId: "mem-1",
+ workspaceId: "ws-1",
+ content: "Updated content",
+ tags: ["storage"],
+ });
+ expect(parseArgs(["memory", "delete", "mem-1", "--workspace", "ws-1"])).toEqual({
+ command: "memory",
+ memoryCommand: "delete",
+ memoryId: "mem-1",
+ workspaceId: "ws-1",
+ });
+ });
+
it("parses server alias as serve", () => {
expect(parseArgs(["server"])).toEqual({
command: "serve",
@@ -1031,6 +1469,29 @@ describe("parseArgs", () => {
expect(() => parseArgs(["git", "diff", "--workspace", "ws-1"])).toThrow("Missing path value");
});
+ it("requires memory command arguments", () => {
+ expect(() => parseArgs(["memory"])).toThrow("Missing memory subcommand");
+ expect(() => parseArgs(["memory", "get", "--workspace", "ws-1"])).toThrow(
+ "Missing memory id value"
+ );
+ expect(() => parseArgs(["memory", "add", "--workspace", "ws-1"])).toThrow("Missing type value");
+ expect(() => parseArgs(["memory", "add", "--type", "decision"])).toThrow(
+ "Missing content value"
+ );
+ expect(() => parseArgs(["memory", "search", "--workspace", "ws-1"])).toThrow(
+ "Missing query value"
+ );
+ });
+
+ it("rejects legacy title flags on memory commands", () => {
+ expect(() =>
+ parseArgs(["memory", "add", "--workspace", "ws-1", "--type", "decision", "--title", "t"])
+ ).toThrow("Unknown option: --title");
+ expect(() =>
+ parseArgs(["memory", "update", "mem-1", "--workspace", "ws-1", "--title", "t"])
+ ).toThrow("Unknown option: --title");
+ });
+
it("allows config-time host-only updates", () => {
expect(parseArgs(["config", "--host", "127.0.0.1"])).toEqual({
command: "config",
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index b6d9f409f..98a3d5854 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -9,7 +9,7 @@ import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js
import { readLogExcerpt } from "./log-excerpt.js";
import { assertSupportedNodeVersion } from "./node-version.js";
import { getCliVersion } from "./package-manifest.js";
-import { parseArgs } from "./parse-args.js";
+import { type CliArgs, parseArgs } from "./parse-args.js";
import { startManagedServer } from "./pm2-control.js";
import { confirmYesNo, isInteractiveSession } from "./prompts.js";
import { getServerStatus, type ServerStatus, stopRunningServer } from "./server-control.js";
@@ -79,11 +79,13 @@ COMMANDS:
logs Show the managed server logs
help Show this help message
identify Print Coder Studio agent runtime context
- capabilities Print agent-facing automation capabilities
+ capabilities Print read-only validation commands for agents
workspace Read workspace automation data
session Read session automation data
terminal Read terminal automation data
git Read git automation data
+ ui Dispatch UI actions to the active Coder Studio workspace
+ memory Read and write workspace memory
version Show version
OPTIONS:
@@ -114,6 +116,14 @@ EXAMPLES:
coder-studio terminal read --terminal term_123 --bytes 4096 --json
coder-studio git status --workspace ws_123 --json
coder-studio git diff --workspace ws_123 --path src/a.ts --json
+ coder-studio ui open-file --workspace ws_123 --path src/a.ts --line 12 --json
+ coder-studio ui close-file --workspace ws_123 --path src/a.ts --json
+ coder-studio ui open-url --workspace ws_123 --url http://127.0.0.1:5173 --json
+ coder-studio ui close-url --workspace ws_123 --url http://127.0.0.1:5173 --json
+ coder-studio ui show-panel --workspace ws_123 --panel terminal --json
+ coder-studio memory list --workspace ws_123 --json
+ coder-studio memory search architecture --workspace ws_123 --json
+ coder-studio memory add --workspace ws_123 --type decision --content "This repo uses pnpm." --tag tooling --json
coder-studio stop
coder-studio config --host 0.0.0.0 --port 8080
`);
@@ -181,6 +191,66 @@ function printCommandResult(result: unknown, options: { json?: boolean } = {}):
console.log(JSON.stringify(result, null, 2));
}
+function buildUiActionIntent(args: CliArgs): Record {
+ const workspace = args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {};
+
+ switch (args.uiCommand) {
+ case "open-file":
+ return {
+ type: "editor.openFile",
+ ...workspace,
+ path: args.path!,
+ ...(args.line !== undefined ? { line: args.line } : {}),
+ ...(args.column !== undefined ? { column: args.column } : {}),
+ };
+ case "close-file":
+ return {
+ type: "editor.closeFile",
+ ...workspace,
+ path: args.path!,
+ };
+ case "open-url":
+ return {
+ type: "browser.openUrl",
+ ...workspace,
+ url: args.url!,
+ };
+ case "close-url":
+ return {
+ type: "browser.closeUrl",
+ ...workspace,
+ url: args.url!,
+ };
+ case "show-panel":
+ return {
+ type: "panel.show",
+ ...workspace,
+ panel: args.panel!,
+ };
+ case "focus-workspace":
+ return {
+ type: "workspace.focus",
+ workspaceId: args.workspaceId!,
+ };
+ case "run-command":
+ return {
+ type: "command.run",
+ commandId: args.uiCommandId!,
+ };
+ default:
+ throw new Error("Missing ui subcommand");
+ }
+}
+
+function resolveMemoryWorkspaceId(args: CliArgs): string {
+ const workspaceId = args.workspaceId ?? process.env.CODER_STUDIO_WORKSPACE_ID;
+ if (!workspaceId) {
+ throw new Error("Missing workspace value");
+ }
+
+ return workspaceId;
+}
+
function formatAuthBlocks(blocks: Awaited>): string {
if (blocks.length === 0) {
return "No blocked IPs.";
@@ -418,6 +488,120 @@ export async function main(argv = process.argv.slice(2)): Promise {
return;
}
+ if (args.command === "ui") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "uiAction.dispatch",
+ args: {
+ ...(args.workspaceId !== undefined ? { workspaceId: args.workspaceId } : {}),
+ intent: buildUiActionIntent(args),
+ source: { kind: "agent" },
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.command === "memory") {
+ const workspaceId = resolveMemoryWorkspaceId(args);
+
+ if (args.memoryCommand === "list") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.list",
+ args: {
+ workspaceId,
+ ...(args.query !== undefined ? { query: args.query } : {}),
+ ...(args.memoryType !== undefined ? { type: args.memoryType } : {}),
+ ...(args.tags?.[0] !== undefined ? { tag: args.tags[0] } : {}),
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.memoryCommand === "search") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.search",
+ args: {
+ workspaceId,
+ query: args.query!,
+ ...(args.memoryType !== undefined ? { type: args.memoryType } : {}),
+ ...(args.tags?.[0] !== undefined ? { tag: args.tags[0] } : {}),
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.memoryCommand === "get") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.get",
+ args: { workspaceId, id: args.memoryId! },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.memoryCommand === "add") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.create",
+ args: {
+ workspaceId,
+ type: args.memoryType!,
+ content: args.content!,
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
+ ...(args.skillSlug !== undefined ? { sourceHint: { skillSlug: args.skillSlug } } : {}),
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.memoryCommand === "update") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.update",
+ args: {
+ workspaceId,
+ id: args.memoryId!,
+ ...(args.memoryType !== undefined ? { type: args.memoryType } : {}),
+ ...(args.content !== undefined ? { content: args.content } : {}),
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
+ },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+
+ if (args.memoryCommand === "delete") {
+ printCommandResult(
+ await callCoderStudioCommand({
+ apiUrl: args.apiUrl,
+ op: "memory.delete",
+ args: { workspaceId, id: args.memoryId! },
+ }),
+ { json: args.json }
+ );
+ return;
+ }
+ }
+
if (args.command === "auth") {
if (args.authCommand === "ban-list") {
console.log(formatAuthBlocks(await listAuthBlocks()));
diff --git a/packages/cli/src/parse-args.ts b/packages/cli/src/parse-args.ts
index de3655f54..52df7e2ba 100644
--- a/packages/cli/src/parse-args.ts
+++ b/packages/cli/src/parse-args.ts
@@ -13,12 +13,23 @@ type CliCommand =
| "workspace"
| "session"
| "terminal"
- | "git";
+ | "git"
+ | "ui"
+ | "memory";
type AuthCommand = "ban-list" | "unblock";
type WorkspaceCommand = "list";
type SessionCommand = "list";
type TerminalCommand = "read";
type GitCommand = "status" | "diff";
+type UiCommand =
+ | "open-file"
+ | "close-file"
+ | "open-url"
+ | "close-url"
+ | "show-panel"
+ | "focus-workspace"
+ | "run-command";
+type MemoryCommand = "list" | "get" | "search" | "add" | "update" | "delete";
export const RUNTIME_CONFIG_ERROR =
"Host, port, state-dir, password, and auth settings must be configured via the config command";
@@ -34,6 +45,8 @@ export interface CliArgs {
sessionCommand?: SessionCommand;
terminalCommand?: TerminalCommand;
gitCommand?: GitCommand;
+ uiCommand?: UiCommand;
+ memoryCommand?: MemoryCommand;
configHelp?: boolean;
port?: number;
host?: string;
@@ -46,8 +59,19 @@ export interface CliArgs {
terminalId?: string;
bytes?: number;
path?: string;
+ url?: string;
+ panel?: string;
+ uiCommandId?: string;
+ line?: number;
+ column?: number;
staged?: boolean;
apiUrl?: string;
+ memoryId?: string;
+ memoryType?: string;
+ query?: string;
+ content?: string;
+ tags?: string[];
+ skillSlug?: string;
}
function getActiveCommand(args: CliArgs): CliCommand {
@@ -73,12 +97,25 @@ function clearAutomationArgs(args: CliArgs): void {
delete args.sessionCommand;
delete args.terminalCommand;
delete args.gitCommand;
+ delete args.uiCommand;
+ delete args.memoryCommand;
delete args.workspaceId;
delete args.terminalId;
delete args.bytes;
delete args.path;
+ delete args.url;
+ delete args.panel;
+ delete args.uiCommandId;
+ delete args.line;
+ delete args.column;
delete args.staged;
delete args.apiUrl;
+ delete args.memoryId;
+ delete args.memoryType;
+ delete args.query;
+ delete args.content;
+ delete args.tags;
+ delete args.skillSlug;
}
function clearLogsArgs(args: CliArgs): void {
@@ -99,14 +136,14 @@ function setCommand(args: CliArgs, command: CliCommand): void {
clearLogsArgs(args);
}
- if (!["workspace", "session", "terminal", "git"].includes(command)) {
+ if (!["workspace", "session", "terminal", "git", "ui", "memory"].includes(command)) {
clearAutomationArgs(args);
}
if (
command !== "identify" &&
command !== "capabilities" &&
- !["workspace", "session", "terminal", "git"].includes(command)
+ !["workspace", "session", "terminal", "git", "ui", "memory"].includes(command)
) {
delete args.json;
}
@@ -154,6 +191,20 @@ function readOptionValue(argv: string[], index: number, label: string): string {
return value;
}
+function readPositiveIntegerOption(argv: string[], index: number, label: string): number {
+ const value = readOptionValue(argv, index, label);
+ if (!/^[1-9]\d*$/u.test(value)) {
+ throw new Error(`Invalid ${label} number`);
+ }
+
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed)) {
+ throw new Error(`Invalid ${label} number`);
+ }
+
+ return parsed;
+}
+
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {};
@@ -177,6 +228,8 @@ export function parseArgs(argv: string[]): CliArgs {
case "session":
case "terminal":
case "git":
+ case "ui":
+ case "memory":
setCommand(args, arg);
break;
@@ -262,7 +315,7 @@ export function parseArgs(argv: string[]): CliArgs {
if (
command !== "identify" &&
command !== "capabilities" &&
- !["workspace", "session", "terminal", "git"].includes(command)
+ !["workspace", "session", "terminal", "git", "ui", "memory"].includes(command)
) {
throwUnknownOption(arg);
}
@@ -274,7 +327,12 @@ export function parseArgs(argv: string[]): CliArgs {
case "--workspace":
case "--workspace-id": {
const command = getActiveCommand(args);
- if (command !== "session" && command !== "git") {
+ if (
+ command !== "session" &&
+ command !== "git" &&
+ command !== "ui" &&
+ command !== "memory"
+ ) {
throwUnknownOption(arg);
}
@@ -299,27 +357,74 @@ export function parseArgs(argv: string[]): CliArgs {
throwUnknownOption(arg);
}
- const bytesValue = readOptionValue(argv, i + 1, "bytes");
- if (!/^[1-9]\d*$/u.test(bytesValue)) {
- throw new Error("Invalid bytes number");
+ args.bytes = readPositiveIntegerOption(argv, i + 1, "bytes");
+ i += 1;
+ break;
+ }
+
+ case "--path": {
+ const command = getActiveCommand(args);
+ if (
+ (command !== "git" || args.gitCommand !== "diff") &&
+ (command !== "ui" || (args.uiCommand !== "open-file" && args.uiCommand !== "close-file"))
+ ) {
+ throwUnknownOption(arg);
}
- const bytes = Number(bytesValue);
- if (!Number.isSafeInteger(bytes)) {
- throw new Error("Invalid bytes number");
+ args.path = readOptionValue(argv, i + 1, "path");
+ i += 1;
+ break;
+ }
+
+ case "--url": {
+ if (
+ getActiveCommand(args) !== "ui" ||
+ (args.uiCommand !== "open-url" && args.uiCommand !== "close-url")
+ ) {
+ throwUnknownOption(arg);
}
- args.bytes = bytes;
+ args.url = readOptionValue(argv, i + 1, "url");
i += 1;
break;
}
- case "--path": {
- if (getActiveCommand(args) !== "git" || args.gitCommand !== "diff") {
+ case "--panel": {
+ if (getActiveCommand(args) !== "ui" || args.uiCommand !== "show-panel") {
throwUnknownOption(arg);
}
- args.path = readOptionValue(argv, i + 1, "path");
+ args.panel = readOptionValue(argv, i + 1, "panel");
+ i += 1;
+ break;
+ }
+
+ case "--command": {
+ if (getActiveCommand(args) !== "ui" || args.uiCommand !== "run-command") {
+ throwUnknownOption(arg);
+ }
+
+ args.uiCommandId = readOptionValue(argv, i + 1, "command");
+ i += 1;
+ break;
+ }
+
+ case "--line": {
+ if (getActiveCommand(args) !== "ui" || args.uiCommand !== "open-file") {
+ throwUnknownOption(arg);
+ }
+
+ args.line = readPositiveIntegerOption(argv, i + 1, "line");
+ i += 1;
+ break;
+ }
+
+ case "--column": {
+ if (getActiveCommand(args) !== "ui" || args.uiCommand !== "open-file") {
+ throwUnknownOption(arg);
+ }
+
+ args.column = readPositiveIntegerOption(argv, i + 1, "column");
i += 1;
break;
}
@@ -335,7 +440,7 @@ export function parseArgs(argv: string[]): CliArgs {
case "--api-url": {
const command = getActiveCommand(args);
- if (!["workspace", "session", "terminal", "git"].includes(command)) {
+ if (!["workspace", "session", "terminal", "git", "ui", "memory"].includes(command)) {
throwUnknownOption(arg);
}
@@ -344,6 +449,42 @@ export function parseArgs(argv: string[]): CliArgs {
break;
}
+ case "--type":
+ if (getActiveCommand(args) !== "memory") {
+ throwUnknownOption(arg);
+ }
+
+ args.memoryType = readOptionValue(argv, i + 1, "type");
+ i += 1;
+ break;
+
+ case "--content":
+ if (getActiveCommand(args) !== "memory") {
+ throwUnknownOption(arg);
+ }
+
+ args.content = readOptionValue(argv, i + 1, "content");
+ i += 1;
+ break;
+
+ case "--tag":
+ if (getActiveCommand(args) !== "memory") {
+ throwUnknownOption(arg);
+ }
+
+ args.tags = [...(args.tags ?? []), readOptionValue(argv, i + 1, "tag")];
+ i += 1;
+ break;
+
+ case "--skill":
+ if (getActiveCommand(args) !== "memory") {
+ throwUnknownOption(arg);
+ }
+
+ args.skillSlug = readOptionValue(argv, i + 1, "skill");
+ i += 1;
+ break;
+
case "--port":
case "-p": {
ensureConfigContext(args, arg);
@@ -405,10 +546,26 @@ export function parseArgs(argv: string[]): CliArgs {
args.sessionCommand = arg;
break;
}
+ if (command === "memory") {
+ args.memoryCommand = arg;
+ break;
+ }
throwUnknownArgument(arg);
}
+ case "get":
+ case "search":
+ case "add":
+ case "update":
+ case "delete":
+ if (getActiveCommand(args) !== "memory") {
+ throwUnknownArgument(arg);
+ }
+
+ args.memoryCommand = arg;
+ break;
+
case "read":
if (getActiveCommand(args) !== "terminal") {
throwUnknownArgument(arg);
@@ -426,6 +583,20 @@ export function parseArgs(argv: string[]): CliArgs {
args.gitCommand = arg;
break;
+ case "open-file":
+ case "close-file":
+ case "open-url":
+ case "close-url":
+ case "show-panel":
+ case "focus-workspace":
+ case "run-command":
+ if (getActiveCommand(args) !== "ui") {
+ throwUnknownArgument(arg);
+ }
+
+ args.uiCommand = arg;
+ break;
+
case "--ip":
if (getActiveCommand(args) !== "auth" || args.authCommand !== "unblock") {
throwUnknownOption(arg);
@@ -440,6 +611,23 @@ export function parseArgs(argv: string[]): CliArgs {
throwUnknownOption(arg);
}
+ if (getActiveCommand(args) === "memory") {
+ if (args.memoryCommand === "search" && args.query === undefined) {
+ args.query = arg;
+ break;
+ }
+
+ if (
+ (args.memoryCommand === "get" ||
+ args.memoryCommand === "update" ||
+ args.memoryCommand === "delete") &&
+ args.memoryId === undefined
+ ) {
+ args.memoryId = arg;
+ break;
+ }
+ }
+
throwUnknownArgument(arg);
}
}
@@ -498,5 +686,66 @@ export function parseArgs(argv: string[]): CliArgs {
}
}
+ if (args.command === "ui") {
+ if (args.uiCommand === undefined) {
+ throw new Error("Missing ui subcommand");
+ }
+
+ if (
+ (args.uiCommand === "open-file" || args.uiCommand === "close-file") &&
+ args.path === undefined
+ ) {
+ throw new Error("Missing path value");
+ }
+
+ if (
+ (args.uiCommand === "open-url" || args.uiCommand === "close-url") &&
+ args.url === undefined
+ ) {
+ throw new Error("Missing url value");
+ }
+
+ if (args.uiCommand === "show-panel" && args.panel === undefined) {
+ throw new Error("Missing panel value");
+ }
+
+ if (args.uiCommand === "focus-workspace" && args.workspaceId === undefined) {
+ throw new Error("Missing workspace value");
+ }
+
+ if (args.uiCommand === "run-command" && args.uiCommandId === undefined) {
+ throw new Error("Missing command value");
+ }
+ }
+
+ if (args.command === "memory") {
+ if (args.memoryCommand === undefined) {
+ throw new Error("Missing memory subcommand");
+ }
+
+ if (
+ (args.memoryCommand === "get" ||
+ args.memoryCommand === "update" ||
+ args.memoryCommand === "delete") &&
+ args.memoryId === undefined
+ ) {
+ throw new Error("Missing memory id value");
+ }
+
+ if (args.memoryCommand === "search" && args.query === undefined) {
+ throw new Error("Missing query value");
+ }
+
+ if (args.memoryCommand === "add") {
+ if (args.memoryType === undefined) {
+ throw new Error("Missing type value");
+ }
+
+ if (args.content === undefined) {
+ throw new Error("Missing content value");
+ }
+ }
+ }
+
return args;
}
diff --git a/packages/core/src/domain/automation.test.ts b/packages/core/src/domain/automation.test.ts
index ad1bf48bc..781cdfeca 100644
--- a/packages/core/src/domain/automation.test.ts
+++ b/packages/core/src/domain/automation.test.ts
@@ -39,8 +39,16 @@ describe("automation domain", () => {
const capabilities = listAutomationCapabilities({
permissions: DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
});
+ const memoryAdd = capabilities.find((capability) => capability.name === "memory.add");
+ const memoryUpdate = capabilities.find((capability) => capability.name === "memory.update");
+ const memoryCapabilities = capabilities.filter((capability) =>
+ capability.name.startsWith("memory.")
+ );
+ const memoryExamples = memoryCapabilities.flatMap((capability) => capability.examples);
expect(capabilities.map((capability) => capability.name)).toContain("git.status");
+ expect(DEFAULT_AGENT_AUTOMATION_PERMISSIONS).toContain("memory:read");
+ expect(DEFAULT_AGENT_AUTOMATION_PERMISSIONS).toContain("memory:write");
expect(capabilities.find((capability) => capability.name === "git.status")).toMatchObject({
cli: "coder-studio git status",
permissions: ["git:read"],
@@ -55,6 +63,93 @@ describe("automation domain", () => {
inputSchema: { workspaceId: "string", path: "string", staged: "boolean optional" },
examples: ["coder-studio git diff --workspace ws_123 --path src/a.ts --json"],
});
+ expect(capabilities.find((capability) => capability.name === "memory.list")).toMatchObject({
+ cli: "coder-studio memory list",
+ permissions: ["memory:read"],
+ riskLevel: "read",
+ examples: ["coder-studio memory list --workspace ws_123 --json"],
+ });
+ expect(capabilities.find((capability) => capability.name === "memory.search")).toMatchObject({
+ description: "Search workspace memory entries by content or type.",
+ examples: ['coder-studio memory search "testing" --workspace ws_123 --json'],
+ });
+ expect(
+ capabilities.find((capability) => capability.name === "memory.search")?.inputSchema
+ ).toEqual({ workspaceId: "string", query: "string" });
+ expect(memoryAdd).toMatchObject({
+ permissions: ["memory:write"],
+ riskLevel: "write",
+ examples: [
+ 'coder-studio memory add --workspace ws_123 --type project --content "..." --json',
+ ],
+ });
+ expect(memoryAdd?.examples).toEqual([
+ 'coder-studio memory add --workspace ws_123 --type project --content "..." --json',
+ ]);
+ expect(memoryAdd?.inputSchema).toEqual({
+ workspaceId: "string",
+ type: "feature | todo | bugfix | project | note",
+ content: "string",
+ });
+ expect(memoryAdd?.inputSchema).not.toHaveProperty("tags");
+ expect(memoryUpdate?.inputSchema).toEqual({
+ workspaceId: "string",
+ id: "string",
+ type: "feature | todo | bugfix | project | note optional",
+ content: "string optional",
+ });
+ expect(memoryUpdate?.inputSchema).not.toHaveProperty("tags");
+ expect(memoryUpdate).toMatchObject({
+ examples: ['coder-studio memory update mem_abc --workspace ws_123 --content "..." --json'],
+ });
+ expect(memoryUpdate?.examples).toEqual([
+ 'coder-studio memory update mem_abc --workspace ws_123 --content "..." --json',
+ ]);
+ expect(memoryExamples).toSatisfy((examples) =>
+ examples.every((example) => !example.includes("--tag"))
+ );
+ expect(memoryAdd?.inputSchema.type).not.toContain("project_fact");
+ expect(memoryUpdate?.inputSchema.type).not.toContain("project_fact");
+ expect(memoryExamples).toSatisfy((examples) =>
+ examples.every((example) => !example.includes("project_fact"))
+ );
+ });
+
+ it("includes low-risk UI action permissions in the default agent permissions", () => {
+ expect(DEFAULT_AGENT_AUTOMATION_PERMISSIONS).toEqual(
+ expect.arrayContaining(["ui:read", "ui:navigate", "ui:command"])
+ );
+ });
+
+ it("lists UI action capabilities through automation capabilities", () => {
+ const capabilities = listAutomationCapabilities({
+ permissions: DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ });
+
+ expect(capabilities).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "ui.editor.openFile",
+ cli: "coder-studio ui open-file --path ",
+ permissions: ["ui:navigate"],
+ }),
+ expect.objectContaining({
+ name: "ui.editor.closeFile",
+ cli: "coder-studio ui close-file --path ",
+ permissions: ["ui:navigate"],
+ }),
+ expect.objectContaining({
+ name: "ui.browser.closeUrl",
+ cli: "coder-studio ui close-url --url ",
+ permissions: ["ui:navigate"],
+ }),
+ expect.objectContaining({
+ name: "ui.command.run",
+ cli: "coder-studio ui run-command --command ",
+ permissions: ["ui:command"],
+ }),
+ ])
+ );
});
it("filters capabilities by permissions", () => {
@@ -64,5 +159,16 @@ describe("automation domain", () => {
expect(capabilities.map((capability) => capability.name)).toContain("workspace.list");
expect(capabilities.map((capability) => capability.name)).not.toContain("git.status");
+ expect(capabilities.map((capability) => capability.name)).not.toContain("memory.list");
+ expect(
+ listAutomationCapabilities({ permissions: ["memory:read"] }).map(
+ (capability) => capability.name
+ )
+ ).toEqual(["memory.list", "memory.search", "memory.get"]);
+ expect(
+ listAutomationCapabilities({ permissions: ["memory:write"] }).map(
+ (capability) => capability.name
+ )
+ ).toEqual(["memory.add", "memory.update", "memory.delete"]);
});
});
diff --git a/packages/core/src/domain/automation.ts b/packages/core/src/domain/automation.ts
index a265cba1c..6125c7bb7 100644
--- a/packages/core/src/domain/automation.ts
+++ b/packages/core/src/domain/automation.ts
@@ -1,8 +1,15 @@
+import { listUiActionCapabilities } from "./ui-actions.js";
+
export const DEFAULT_AGENT_AUTOMATION_PERMISSIONS = [
"workspace:read",
"session:read",
"terminal:read",
"git:read",
+ "ui:read",
+ "ui:navigate",
+ "ui:command",
+ "memory:read",
+ "memory:write",
] as const;
export type AutomationPermission = (typeof DEFAULT_AGENT_AUTOMATION_PERMISSIONS)[number];
@@ -92,6 +99,81 @@ const MVP_CAPABILITIES: AutomationCapability[] = [
examples: ["coder-studio git diff --workspace ws_123 --path src/a.ts --json"],
available: true,
},
+ {
+ name: "memory.list",
+ cli: "coder-studio memory list",
+ description: "List workspace memory entries.",
+ inputSchema: { workspaceId: "string" },
+ output: "Workspace memory entries as JSON.",
+ permissions: ["memory:read"],
+ riskLevel: "read",
+ examples: ["coder-studio memory list --workspace ws_123 --json"],
+ available: true,
+ },
+ {
+ name: "memory.search",
+ cli: "coder-studio memory search",
+ description: "Search workspace memory entries by content or type.",
+ inputSchema: { workspaceId: "string", query: "string" },
+ output: "Matching workspace memory entries as JSON.",
+ permissions: ["memory:read"],
+ riskLevel: "read",
+ examples: ['coder-studio memory search "testing" --workspace ws_123 --json'],
+ available: true,
+ },
+ {
+ name: "memory.get",
+ cli: "coder-studio memory get",
+ description: "Read one workspace memory entry.",
+ inputSchema: { workspaceId: "string", id: "string" },
+ output: "Workspace memory entry as JSON.",
+ permissions: ["memory:read"],
+ riskLevel: "read",
+ examples: ["coder-studio memory get mem_abc --workspace ws_123 --json"],
+ available: true,
+ },
+ {
+ name: "memory.add",
+ cli: "coder-studio memory add",
+ description: "Create a workspace memory entry.",
+ inputSchema: {
+ workspaceId: "string",
+ type: "feature | todo | bugfix | project | note",
+ content: "string",
+ },
+ output: "Created workspace memory entry as JSON.",
+ permissions: ["memory:write"],
+ riskLevel: "write",
+ examples: ['coder-studio memory add --workspace ws_123 --type project --content "..." --json'],
+ available: true,
+ },
+ {
+ name: "memory.update",
+ cli: "coder-studio memory update",
+ description: "Update a workspace memory entry.",
+ inputSchema: {
+ workspaceId: "string",
+ id: "string",
+ type: "feature | todo | bugfix | project | note optional",
+ content: "string optional",
+ },
+ output: "Updated workspace memory entry as JSON.",
+ permissions: ["memory:write"],
+ riskLevel: "write",
+ examples: ['coder-studio memory update mem_abc --workspace ws_123 --content "..." --json'],
+ available: true,
+ },
+ {
+ name: "memory.delete",
+ cli: "coder-studio memory delete",
+ description: "Archive a workspace memory entry.",
+ inputSchema: { workspaceId: "string", id: "string" },
+ output: "Archived workspace memory entry as JSON.",
+ permissions: ["memory:write"],
+ riskLevel: "write",
+ examples: ["coder-studio memory delete mem_abc --workspace ws_123 --json"],
+ available: true,
+ },
];
export function buildIdentifyResult(input: IdentifyInput = {}): IdentifyResult {
@@ -116,7 +198,21 @@ export function listAutomationCapabilities(input: {
permissions: readonly string[];
}): AutomationCapability[] {
const allowed = new Set(input.permissions);
- return MVP_CAPABILITIES.filter((capability) =>
+ const uiCapabilities: AutomationCapability[] = listUiActionCapabilities({
+ permissions: input.permissions,
+ }).map((capability) => ({
+ name: `ui.${capability.type}`,
+ cli: capability.cli,
+ description: capability.description,
+ inputSchema: capability.inputSchema,
+ output: "Accepted dispatch metadata as JSON. The frontend executes UI actions asynchronously.",
+ permissions: capability.permissions,
+ riskLevel: capability.riskLevel,
+ examples: capability.examples,
+ available: capability.available,
+ }));
+
+ return [...MVP_CAPABILITIES, ...uiCapabilities].filter((capability) =>
capability.permissions.every((permission) => allowed.has(permission))
);
}
diff --git a/packages/core/src/domain/events.ts b/packages/core/src/domain/events.ts
index 5e5718f49..874ce3055 100644
--- a/packages/core/src/domain/events.ts
+++ b/packages/core/src/domain/events.ts
@@ -1,6 +1,5 @@
// DomainEvent type union for EventBus (spec §4.0)
-import type { WorkspaceExtensionStateView } from "./extension-state";
import type { LspDiagnosticsEvent } from "./lsp";
import type { SessionState, TaskDefinition, TaskRun, TerminalKind, Workspace } from "./types";
@@ -20,11 +19,6 @@ export type DomainEvent =
event: "started" | "turn_completed" | "stopped" | "removed";
}
| { type: "workspace.meta.changed"; workspaceId: string; patch: Partial }
- | {
- type: "workspace.extension_state.changed";
- workspaceId: string;
- state: WorkspaceExtensionStateView;
- }
| {
type: "git.state.changed";
workspaceId: string;
diff --git a/packages/core/src/domain/extension-state.test.ts b/packages/core/src/domain/extension-state.test.ts
deleted file mode 100644
index f08587ebc..000000000
--- a/packages/core/src/domain/extension-state.test.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- createEmptyWorkspaceExtensionStateView,
- WORKSPACE_LOG_LEVELS,
- WORKSPACE_STATUS_PILL_STATES,
-} from "./extension-state.js";
-
-describe("workspace extension state", () => {
- it("declares the supported status pill states and log levels", () => {
- expect(WORKSPACE_STATUS_PILL_STATES).toEqual([
- "idle",
- "running",
- "success",
- "warning",
- "error",
- ]);
- expect(WORKSPACE_LOG_LEVELS).toEqual(["info", "warning", "error"]);
- });
-
- it("creates an empty workspace extension state view", () => {
- expect(
- createEmptyWorkspaceExtensionStateView("ws-1", {
- now: () => 1234,
- })
- ).toEqual({
- workspaceId: "ws-1",
- statusPills: [],
- progress: [],
- logs: [],
- quickActions: [],
- updatedAt: 1234,
- });
- });
-});
diff --git a/packages/core/src/domain/extension-state.ts b/packages/core/src/domain/extension-state.ts
deleted file mode 100644
index fd867390b..000000000
--- a/packages/core/src/domain/extension-state.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-export const WORKSPACE_STATUS_PILL_STATES = [
- "idle",
- "running",
- "success",
- "warning",
- "error",
-] as const;
-
-export const WORKSPACE_LOG_LEVELS = ["info", "warning", "error"] as const;
-
-export type WorkspaceStatusPillState = (typeof WORKSPACE_STATUS_PILL_STATES)[number];
-export type WorkspaceLogLevel = (typeof WORKSPACE_LOG_LEVELS)[number];
-
-export interface WorkspaceStatusPillView {
- key: string;
- label: string;
- state: WorkspaceStatusPillState;
- detail?: string;
- updatedAt: number;
-}
-
-export interface WorkspaceProgressView {
- key: string;
- label: string;
- value?: number;
- max?: number;
- detail?: string;
- updatedAt: number;
-}
-
-export interface WorkspaceLogEntryView {
- key: string;
- level: WorkspaceLogLevel;
- message: string;
- timestamp: number;
-}
-
-export interface WorkspaceQuickActionView {
- id: string;
- label: string;
- command: string;
- description?: string;
-}
-
-export interface WorkspaceExtensionStateView {
- workspaceId: string;
- statusPills: WorkspaceStatusPillView[];
- progress: WorkspaceProgressView[];
- logs: WorkspaceLogEntryView[];
- quickActions: WorkspaceQuickActionView[];
- updatedAt: number;
-}
-
-export interface CreateWorkspaceExtensionStateViewOptions {
- now?: () => number;
-}
-
-export function createEmptyWorkspaceExtensionStateView(
- workspaceId: string,
- options: CreateWorkspaceExtensionStateViewOptions = {}
-): WorkspaceExtensionStateView {
- return {
- workspaceId,
- statusPills: [],
- progress: [],
- logs: [],
- quickActions: [],
- updatedAt: options.now?.() ?? Date.now(),
- };
-}
diff --git a/packages/core/src/domain/memory.test.ts b/packages/core/src/domain/memory.test.ts
new file mode 100644
index 000000000..371b37dfa
--- /dev/null
+++ b/packages/core/src/domain/memory.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+ resolveWorkspaceMemorySource,
+ validateWorkspaceMemoryInput,
+ WORKSPACE_MEMORY_SOURCE_KINDS,
+ WORKSPACE_MEMORY_TYPES,
+} from "./memory.js";
+
+describe("workspace memory domain", () => {
+ it("exports stable memory types and source kinds", () => {
+ expect(WORKSPACE_MEMORY_TYPES).toEqual(["feature", "todo", "bugfix", "project", "note"]);
+ expect(WORKSPACE_MEMORY_SOURCE_KINDS).toEqual(["user", "agent", "skill"]);
+ });
+
+ it("normalizes trimmed content", () => {
+ expect(
+ validateWorkspaceMemoryInput({
+ type: "project",
+ content: " Use pnpm for package scripts. ",
+ })
+ ).toEqual({
+ type: "project",
+ content: "Use pnpm for package scripts.",
+ });
+ });
+
+ it("ignores stale tags payloads and returns a tag-free validated result", () => {
+ const validated = validateWorkspaceMemoryInput({
+ type: "project",
+ content: " Keep pnpm scripts centralized. ",
+ tags: ["legacy", "callers"],
+ } as Parameters[0] & { tags: string[] });
+
+ expect(validated).toEqual({
+ type: "project",
+ content: "Keep pnpm scripts centralized.",
+ });
+ expect(validated).not.toHaveProperty("tags");
+ });
+
+ it("rejects invalid memory inputs", () => {
+ expect(() =>
+ validateWorkspaceMemoryInput({
+ type: "unknown",
+ content: "Content",
+ })
+ ).toThrow("Invalid memory type");
+ expect(() =>
+ validateWorkspaceMemoryInput({
+ type: "note",
+ content: "",
+ })
+ ).toThrow("Memory content is required");
+ expect(() =>
+ validateWorkspaceMemoryInput({
+ type: "note",
+ content: " \n\t ",
+ })
+ ).toThrow("Memory content is required");
+ expect(() =>
+ validateWorkspaceMemoryInput({
+ type: "note",
+ content: 123,
+ })
+ ).toThrow("Memory content is required");
+ });
+
+ it("accepts long content after trimming", () => {
+ const longContent = ` ${"x".repeat(20_001)} `;
+
+ expect(
+ validateWorkspaceMemoryInput({
+ type: "note",
+ content: longContent,
+ })
+ ).toEqual({
+ type: "note",
+ content: "x".repeat(20_001),
+ });
+ });
+
+ it("resolves source metadata with safe defaults", () => {
+ expect(resolveWorkspaceMemorySource({})).toEqual({ kind: "user" });
+ expect(resolveWorkspaceMemorySource({ defaultKind: "agent", providerId: "codex" })).toEqual({
+ kind: "agent",
+ providerId: "codex",
+ });
+ expect(
+ resolveWorkspaceMemorySource({
+ defaultKind: "agent",
+ kind: "skill",
+ sessionId: "sess-1",
+ skillSlug: "coder-studio-memory",
+ })
+ ).toEqual({
+ kind: "skill",
+ sessionId: "sess-1",
+ skillSlug: "coder-studio-memory",
+ });
+ });
+});
diff --git a/packages/core/src/domain/memory.ts b/packages/core/src/domain/memory.ts
new file mode 100644
index 000000000..56267a3fb
--- /dev/null
+++ b/packages/core/src/domain/memory.ts
@@ -0,0 +1,111 @@
+export const WORKSPACE_MEMORY_TYPES = ["feature", "todo", "bugfix", "project", "note"] as const;
+
+export type WorkspaceMemoryType = (typeof WORKSPACE_MEMORY_TYPES)[number];
+
+export const WORKSPACE_MEMORY_SOURCE_KINDS = ["user", "agent", "skill"] as const;
+
+export type WorkspaceMemorySourceKind = (typeof WORKSPACE_MEMORY_SOURCE_KINDS)[number];
+
+export interface WorkspaceMemorySource {
+ kind: WorkspaceMemorySourceKind;
+ providerId?: string;
+ sessionId?: string;
+ skillSlug?: string;
+}
+
+export interface WorkspaceMemoryEntry {
+ id: string;
+ workspaceId: string;
+ type: WorkspaceMemoryType;
+ content: string;
+ source: WorkspaceMemorySource;
+ createdAt: number;
+ updatedAt: number;
+ archivedAt?: number;
+}
+
+export interface WorkspaceMemoryListFilter {
+ workspaceId: string;
+ query?: string;
+ type?: WorkspaceMemoryType;
+ includeArchived?: boolean;
+}
+
+export interface WorkspaceMemoryInput {
+ type: unknown;
+ content: unknown;
+}
+
+export interface WorkspaceMemoryValidatedInput {
+ type: WorkspaceMemoryType;
+ content: string;
+}
+
+export interface WorkspaceMemorySourceInput {
+ kind?: unknown;
+ defaultKind?: WorkspaceMemorySourceKind;
+ providerId?: unknown;
+ sessionId?: unknown;
+ skillSlug?: unknown;
+}
+
+const WORKSPACE_MEMORY_TYPE_SET = new Set(WORKSPACE_MEMORY_TYPES);
+const WORKSPACE_MEMORY_SOURCE_KIND_SET = new Set(WORKSPACE_MEMORY_SOURCE_KINDS);
+function isWorkspaceMemoryType(value: unknown): value is WorkspaceMemoryType {
+ return typeof value === "string" && WORKSPACE_MEMORY_TYPE_SET.has(value);
+}
+
+function isWorkspaceMemorySourceKind(value: unknown): value is WorkspaceMemorySourceKind {
+ return typeof value === "string" && WORKSPACE_MEMORY_SOURCE_KIND_SET.has(value);
+}
+
+function normalizeSourceText(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+export function validateWorkspaceMemoryInput(
+ input: WorkspaceMemoryInput
+): WorkspaceMemoryValidatedInput {
+ if (!isWorkspaceMemoryType(input.type)) {
+ throw new Error("Invalid memory type");
+ }
+
+ if (typeof input.content !== "string") {
+ throw new Error("Memory content is required");
+ }
+
+ const content = input.content.trim();
+ if (!content) {
+ throw new Error("Memory content is required");
+ }
+
+ return {
+ type: input.type,
+ content,
+ };
+}
+
+export function resolveWorkspaceMemorySource(
+ input: WorkspaceMemorySourceInput
+): WorkspaceMemorySource {
+ const kind = input.kind ?? input.defaultKind ?? "user";
+ if (!isWorkspaceMemorySourceKind(kind)) {
+ throw new Error("Invalid memory source kind");
+ }
+
+ const providerId = normalizeSourceText(input.providerId);
+ const sessionId = normalizeSourceText(input.sessionId);
+ const skillSlug = normalizeSourceText(input.skillSlug);
+
+ return {
+ kind,
+ ...(providerId ? { providerId } : {}),
+ ...(sessionId ? { sessionId } : {}),
+ ...(skillSlug ? { skillSlug } : {}),
+ };
+}
diff --git a/packages/core/src/domain/skill-management.test.ts b/packages/core/src/domain/skill-management.test.ts
index 0e4077ae9..9b149cddf 100644
--- a/packages/core/src/domain/skill-management.test.ts
+++ b/packages/core/src/domain/skill-management.test.ts
@@ -1,3 +1,4 @@
+import type { SkillRecommendationEntry } from "./skill-management.js";
import {
isSkillMountStatus,
SKILL_INSTALL_STATES,
@@ -5,6 +6,7 @@ import {
SKILL_LIBRARY_SOURCES,
SKILL_MOUNT_STATUSES,
SKILL_TARGET_HEALTH_STATES,
+ SKILL_VERSION_CHECK_STATUSES,
} from "./skill-management.js";
describe("skill management domain", () => {
@@ -35,6 +37,15 @@ describe("skill management domain", () => {
]);
});
+ it("exports stable skill version check statuses", () => {
+ expect(SKILL_VERSION_CHECK_STATUSES).toEqual([
+ "up_to_date",
+ "update_available",
+ "unknown",
+ "error",
+ ]);
+ });
+
it("exports stable target health states", () => {
expect(SKILL_TARGET_HEALTH_STATES).toEqual(["healthy", "warning", "error", "unconfigured"]);
});
@@ -44,4 +55,18 @@ describe("skill management domain", () => {
expect(isSkillMountStatus("failed")).toBe(true);
expect(isSkillMountStatus("unknown")).toBe(false);
});
+
+ it("exports a stable skill recommendation entry shape", () => {
+ const entry: SkillRecommendationEntry = {
+ slug: "code-review",
+ displayName: "Code Review",
+ description: "Reviews code changes",
+ reason: "Matches the workspace test workflow",
+ sourceQuery: "test workflow",
+ score: 42,
+ installed: false,
+ };
+
+ expect(entry.slug).toBe("code-review");
+ });
});
diff --git a/packages/core/src/domain/skill-management.ts b/packages/core/src/domain/skill-management.ts
index 440126689..55335ea1a 100644
--- a/packages/core/src/domain/skill-management.ts
+++ b/packages/core/src/domain/skill-management.ts
@@ -13,11 +13,18 @@ export const SKILL_MOUNT_STATUSES = [
"missing_source",
"failed",
] as const;
+export const SKILL_VERSION_CHECK_STATUSES = [
+ "up_to_date",
+ "update_available",
+ "unknown",
+ "error",
+] as const;
export const SKILL_TARGET_HEALTH_STATES = ["healthy", "warning", "error", "unconfigured"] as const;
type SkillInstallState = (typeof SKILL_INSTALL_STATES)[number];
export type SkillLibrarySource = (typeof SKILL_LIBRARY_SOURCES)[number];
type SkillMountStatus = (typeof SKILL_MOUNT_STATUSES)[number];
+export type SkillVersionCheckStatus = (typeof SKILL_VERSION_CHECK_STATUSES)[number];
type SkillTargetHealthState = (typeof SKILL_TARGET_HEALTH_STATES)[number];
export function isSkillMountStatus(value: string): value is SkillMountStatus {
@@ -41,6 +48,24 @@ export interface SkillLibraryEntry {
};
}
+export interface SkillRecommendationEntry {
+ slug: string;
+ displayName: string;
+ description?: string;
+ reason: string;
+ sourceQuery: string;
+ score: number;
+ installed: boolean;
+}
+
+export interface SkillVersionCheckEntry {
+ slug: string;
+ currentVersion: string;
+ latestVersion?: string;
+ status: SkillVersionCheckStatus;
+ error?: string;
+}
+
export interface AgentSkillTargetEntry {
providerId: string;
displayName: string;
diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts
index 6241180a6..f40d749da 100644
--- a/packages/core/src/domain/supervisor.ts
+++ b/packages/core/src/domain/supervisor.ts
@@ -24,35 +24,57 @@ export type SupervisorStopReason =
| "max_supervision_count_reached"
| "supervisor_uncertain";
-export type SupervisorWorkItemStatus = "pending" | "in_progress" | "done";
-export type SupervisorDecompositionMode = "stage" | "subtarget";
-export type SupervisorWorkItemKind = SupervisorDecompositionMode;
+export type SupervisorPlanNodeStatus = "pending" | "in_progress" | "done" | "blocked";
+export type SupervisorTaskType = "coding" | "writing" | "research" | "design" | "generic";
+export type SupervisorGranularity = "too_large" | "ready" | "too_small";
+
+export interface SupervisorPlanNodeReadyCheck {
+ granularity: SupervisorGranularity;
+ reason: string;
+ recommendedUnit?: string;
+ qualityRisk?: string;
+ missingInputs?: string[];
+ confidence?: "low" | "medium" | "high";
+ checkedAt: number;
+}
-export interface SupervisorWorkItem {
+export interface SupervisorPlanNodeExecution {
+ executable: boolean;
+ guidance?: string;
+ lastInjectedAt?: number;
+}
+
+export interface SupervisorPlanNode {
id: string;
- kind: SupervisorWorkItemKind;
title: string;
objective: string;
deliverable: string;
acceptanceCriteria: string[];
- status: SupervisorWorkItemStatus;
+ status: SupervisorPlanNodeStatus;
+ taskType: SupervisorTaskType;
+ children: SupervisorPlanNode[];
+ readyCheck?: SupervisorPlanNodeReadyCheck;
+ execution?: SupervisorPlanNodeExecution;
}
+export const DEFAULT_SUPERVISOR_PLAN_MAX_DEPTH = 6;
+
export interface SupervisorTargetMemory {
+ schemaVersion: 2;
targetId: string;
- decompositionGenerated: boolean;
- decompositionMode?: SupervisorDecompositionMode;
- items: SupervisorWorkItem[];
- activeItemId?: string;
+ planTree: SupervisorPlanNode;
+ activeNodeId?: string;
+ maxDepth: number;
+ planRevision: number;
progressSummary?: string;
lastGuidance?: string;
stalledCount: number;
updatedAt: number;
}
-export interface SupervisorCycleItemUpdate {
+export interface SupervisorCycleNodeUpdate {
id: string;
- status: SupervisorWorkItemStatus;
+ status: SupervisorPlanNodeStatus;
}
export interface SupervisorCycleTargetRecord {
@@ -65,9 +87,8 @@ export interface SupervisorCycleTargetRecord {
reason?: string;
guidance?: string;
progressSummary?: string;
- decompositionMode?: SupervisorDecompositionMode;
- activeItemId?: string;
- itemUpdates?: SupervisorCycleItemUpdate[];
+ activeNodeId?: string;
+ nodeUpdates?: SupervisorCycleNodeUpdate[];
injected?: boolean;
attemptCount?: number;
errorReason?: string;
diff --git a/packages/core/src/domain/types.test.ts b/packages/core/src/domain/types.test.ts
index 0f33b840a..da36c027e 100644
--- a/packages/core/src/domain/types.test.ts
+++ b/packages/core/src/domain/types.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, expectTypeOf, it } from "vitest";
+import type { SupervisorTargetMemory } from "./supervisor";
import type {
AgentContextKind,
CustomProviderSessionMode,
@@ -182,3 +183,50 @@ describe("Git hunk contracts", () => {
}>();
});
});
+
+describe("Supervisor target memory", () => {
+ it("allows supervisor target memory to hold a recursive plan tree", () => {
+ const memory: SupervisorTargetMemory = {
+ schemaVersion: 2,
+ targetId: "tgt-1",
+ planTree: {
+ id: "root",
+ title: "Write a 1M word novel",
+ objective: "Produce the full novel through small executable writing tasks",
+ deliverable: "Completed novel",
+ acceptanceCriteria: ["The novel is complete"],
+ status: "in_progress",
+ taskType: "writing",
+ children: [
+ {
+ id: "volume-1",
+ title: "Volume 1",
+ objective: "Draft the first volume",
+ deliverable: "Volume 1 draft",
+ acceptanceCriteria: ["Volume 1 has a complete arc"],
+ status: "in_progress",
+ taskType: "writing",
+ children: [],
+ readyCheck: {
+ granularity: "too_large",
+ reason: "A full volume is too broad for one execution step",
+ recommendedUnit: "scene_card",
+ qualityRisk: "large_scope_quality_loss",
+ missingInputs: ["scene conflict"],
+ confidence: "high",
+ checkedAt: 10,
+ },
+ },
+ ],
+ },
+ activeNodeId: "volume-1",
+ maxDepth: 6,
+ planRevision: 1,
+ stalledCount: 0,
+ updatedAt: 10,
+ };
+
+ expect(memory.planTree.children[0]?.readyCheck?.granularity).toBe("too_large");
+ expect(memory.activeNodeId).toBe("volume-1");
+ });
+});
diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts
index b4bf88130..d79b7bc9c 100644
--- a/packages/core/src/domain/types.ts
+++ b/packages/core/src/domain/types.ts
@@ -65,15 +65,41 @@ export interface WorkspacePaneSplit {
export type WorkspacePaneNode = WorkspacePaneLeaf | WorkspacePaneSplit;
+export interface WorkspaceFileEditorTab {
+ kind: "file";
+ path: string;
+}
+
+export type DevBrowserDevicePreset = "desktop" | "iphone-14" | "pixel-7" | "custom";
+export type DevBrowserOrientation = "portrait" | "landscape";
+export type DevBrowserUserAgentMode = "desktop" | "mobile";
+
+export interface WorkspaceBrowserEditorTab {
+ kind: "browser";
+ id: string;
+ url: string | null;
+ devicePreset: DevBrowserDevicePreset;
+ viewportWidth: number | null;
+ viewportHeight: number | null;
+ orientation: DevBrowserOrientation;
+ userAgentMode: DevBrowserUserAgentMode;
+}
+
+export type WorkspaceEditorTab = WorkspaceFileEditorTab | WorkspaceBrowserEditorTab;
+
export interface UiState {
leftPanelWidth: number;
bottomPanelHeight: number;
focusMode: boolean;
+ editorViewVisible?: boolean;
activeSessionId?: string;
paneLayout?: WorkspacePaneNode;
fileTreeExpandedDirs?: string[];
openEditorPaths?: string[];
activeEditorPath?: string | null;
+ openEditorTabs?: WorkspaceEditorTab[];
+ activeEditorTab?: WorkspaceEditorTab | null;
+ devBrowserTargetUrl?: string | null;
agentInstructionsExpanded?: boolean;
}
diff --git a/packages/core/src/domain/ui-actions.test.ts b/packages/core/src/domain/ui-actions.test.ts
new file mode 100644
index 000000000..cee1e940e
--- /dev/null
+++ b/packages/core/src/domain/ui-actions.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from "vitest";
+import {
+ createUiActionEvent,
+ listUiActionCapabilities,
+ normalizeUiActionDispatchRequest,
+ type UiActionDispatchRequest,
+ validateUiActionIntent,
+} from "./ui-actions.js";
+
+describe("ui action domain", () => {
+ it("lists MVP UI action capabilities with CLI examples", () => {
+ const capabilities = listUiActionCapabilities({ permissions: ["ui:navigate", "ui:command"] });
+
+ expect(capabilities.map((capability) => capability.type)).toEqual([
+ "editor.openFile",
+ "editor.closeFile",
+ "browser.openUrl",
+ "browser.closeUrl",
+ "workspace.focus",
+ "panel.show",
+ "command.run",
+ ]);
+ expect(capabilities.find((capability) => capability.type === "editor.openFile")).toMatchObject({
+ cli: "coder-studio ui open-file --path ",
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ });
+ expect(capabilities.find((capability) => capability.type === "editor.closeFile")).toMatchObject(
+ {
+ cli: "coder-studio ui close-file --path ",
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ }
+ );
+ expect(capabilities.find((capability) => capability.type === "browser.closeUrl")).toMatchObject(
+ {
+ cli: "coder-studio ui close-url --url ",
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ }
+ );
+ });
+
+ it("filters UI action capabilities by permissions", () => {
+ expect(
+ listUiActionCapabilities({ permissions: ["ui:navigate"] }).map(
+ (capability) => capability.type
+ )
+ ).toEqual([
+ "editor.openFile",
+ "editor.closeFile",
+ "browser.openUrl",
+ "browser.closeUrl",
+ "workspace.focus",
+ "panel.show",
+ ]);
+
+ expect(
+ listUiActionCapabilities({ permissions: ["ui:command"] }).map((capability) => capability.type)
+ ).toEqual(["command.run"]);
+ });
+
+ it("rejects unsafe workspace paths", () => {
+ expect(() => validateUiActionIntent({ type: "editor.openFile", path: "/etc/passwd" })).toThrow(
+ "workspace-relative"
+ );
+ expect(() =>
+ validateUiActionIntent({ type: "editor.openFile", path: "../secret.txt" })
+ ).toThrow("workspace-relative");
+ expect(() =>
+ validateUiActionIntent({ type: "editor.openFile", path: "src/app.ts", line: 0 })
+ ).toThrow("positive integer");
+
+ expect(validateUiActionIntent({ type: "editor.closeFile", path: "src/app.ts" })).toEqual({
+ type: "editor.closeFile",
+ path: "src/app.ts",
+ });
+ expect(() =>
+ validateUiActionIntent({ type: "editor.closeFile", path: "../secret.txt" })
+ ).toThrow("workspace-relative");
+ });
+
+ it("accepts localhost URLs and rejects external URLs", () => {
+ expect(
+ validateUiActionIntent({ type: "browser.openUrl", url: "http://127.0.0.1:5173" })
+ ).toEqual({
+ type: "browser.openUrl",
+ url: "http://127.0.0.1:5173/",
+ });
+ expect(
+ validateUiActionIntent({ type: "browser.closeUrl", url: "http://127.0.0.1:5173" })
+ ).toEqual({
+ type: "browser.closeUrl",
+ url: "http://127.0.0.1:5173/",
+ });
+
+ expect(() =>
+ validateUiActionIntent({ type: "browser.openUrl", url: "https://example.com" })
+ ).toThrow("localhost URLs");
+ expect(() =>
+ validateUiActionIntent({ type: "browser.closeUrl", url: "https://example.com" })
+ ).toThrow("localhost URLs");
+ });
+
+ it("rejects non-allowlisted command.run ids", () => {
+ expect(validateUiActionIntent({ type: "command.run", commandId: "quickOpen.open" })).toEqual({
+ type: "command.run",
+ commandId: "quickOpen.open",
+ });
+ expect(() =>
+ validateUiActionIntent({ type: "command.run", commandId: "workspace.deleteAll" })
+ ).toThrow("not allowed");
+ });
+
+ it("normalizes requests and creates workspace-scoped events", () => {
+ const request: UiActionDispatchRequest = normalizeUiActionDispatchRequest({
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ });
+
+ expect(request).toEqual({
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ });
+ expect(createUiActionEvent({ request, workspaceId: "ws-1", dispatchedAt: 123 })).toEqual({
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ dispatchedAt: 123,
+ });
+ });
+});
diff --git a/packages/core/src/domain/ui-actions.ts b/packages/core/src/domain/ui-actions.ts
new file mode 100644
index 000000000..69921cfa1
--- /dev/null
+++ b/packages/core/src/domain/ui-actions.ts
@@ -0,0 +1,320 @@
+import { Topics } from "../protocol/topics.js";
+import type { AutomationPermission } from "./automation.js";
+
+export type UiActionRiskLevel = "read" | "write" | "dangerous";
+export type UiPanelId = "terminal" | "explorer" | "search" | "git" | "skills" | "agentInstructions";
+export type UiCommandId = "quickOpen.open" | "commandPalette.open";
+
+export type UiActionIntent =
+ | {
+ type: "editor.openFile";
+ workspaceId?: string;
+ path: string;
+ line?: number;
+ column?: number;
+ target?: "active" | "newPane" | { paneId: string };
+ }
+ | {
+ type: "editor.closeFile";
+ workspaceId?: string;
+ path: string;
+ }
+ | {
+ type: "browser.openUrl";
+ workspaceId?: string;
+ url: string;
+ target?: "preview" | "external";
+ }
+ | {
+ type: "browser.closeUrl";
+ workspaceId?: string;
+ url: string;
+ }
+ | {
+ type: "workspace.focus";
+ workspaceId: string;
+ }
+ | {
+ type: "panel.show";
+ workspaceId?: string;
+ panel: UiPanelId;
+ }
+ | {
+ type: "command.run";
+ commandId: UiCommandId;
+ args?: Record;
+ };
+
+export interface UiActionDescriptor {
+ type: UiActionIntent["type"];
+ cli: string;
+ description: string;
+ inputSchema: Record;
+ permissions: AutomationPermission[];
+ riskLevel: UiActionRiskLevel;
+ available: boolean;
+ examples: string[];
+}
+
+export interface UiActionDispatchRequest {
+ intent: UiActionIntent;
+ source?: {
+ kind: "agent" | "user" | "system";
+ sessionId?: string;
+ providerId?: string;
+ };
+ requestId?: string;
+}
+
+export interface UiActionDispatchResult {
+ accepted: boolean;
+ requestId: string;
+ topic: string;
+}
+
+export interface UiActionEvent {
+ requestId: string;
+ workspaceId: string;
+ intent: UiActionIntent;
+ source?: UiActionDispatchRequest["source"];
+ dispatchedAt: number;
+}
+
+export const ALLOWED_UI_COMMAND_IDS: readonly UiCommandId[] = [
+ "quickOpen.open",
+ "commandPalette.open",
+];
+
+const UI_ACTION_CAPABILITIES: UiActionDescriptor[] = [
+ {
+ type: "editor.openFile",
+ cli: "coder-studio ui open-file --path ",
+ description: "Open a workspace-relative file path in the built-in editor.",
+ inputSchema: {
+ workspaceId: "string optional",
+ path: "workspace-relative string",
+ line: "positive integer optional",
+ column: "positive integer optional",
+ target: "active | newPane | pane id optional",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui open-file --path src/index.ts --line 12 --json"],
+ },
+ {
+ type: "editor.closeFile",
+ cli: "coder-studio ui close-file --path ",
+ description: "Close a matching workspace-relative file tab in the built-in editor.",
+ inputSchema: {
+ workspaceId: "string optional",
+ path: "workspace-relative string",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui close-file --path src/index.ts --json"],
+ },
+ {
+ type: "browser.openUrl",
+ cli: "coder-studio ui open-url --url ",
+ description: "Open a localhost URL from the Coder Studio UI.",
+ inputSchema: {
+ workspaceId: "string optional",
+ url: "localhost URL",
+ target: "preview | external optional",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui open-url --url http://127.0.0.1:5173 --json"],
+ },
+ {
+ type: "browser.closeUrl",
+ cli: "coder-studio ui close-url --url ",
+ description: "Close the built-in browser tab only when its current URL matches.",
+ inputSchema: {
+ workspaceId: "string optional",
+ url: "localhost URL",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui close-url --url http://127.0.0.1:5173 --json"],
+ },
+ {
+ type: "workspace.focus",
+ cli: "coder-studio ui focus-workspace --workspace ",
+ description: "Focus a known workspace in the Coder Studio UI.",
+ inputSchema: { workspaceId: "string" },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui focus-workspace --workspace ws_123 --json"],
+ },
+ {
+ type: "panel.show",
+ cli: "coder-studio ui show-panel --panel ",
+ description: "Show a common workspace panel.",
+ inputSchema: {
+ workspaceId: "string optional",
+ panel: "terminal | explorer | search | git | skills | agentInstructions",
+ },
+ permissions: ["ui:navigate"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui show-panel --panel terminal --json"],
+ },
+ {
+ type: "command.run",
+ cli: "coder-studio ui run-command --command ",
+ description: "Run a small allowlist of frontend-only commands.",
+ inputSchema: {
+ commandId: "quickOpen.open | commandPalette.open",
+ args: "object optional",
+ },
+ permissions: ["ui:command"],
+ riskLevel: "read",
+ available: true,
+ examples: ["coder-studio ui run-command --command quickOpen.open --json"],
+ },
+];
+
+export function listUiActionCapabilities(input: {
+ permissions: readonly string[];
+}): UiActionDescriptor[] {
+ const allowed = new Set(input.permissions);
+ return UI_ACTION_CAPABILITIES.filter((capability) =>
+ capability.permissions.every((permission) => allowed.has(permission))
+ );
+}
+
+function assertPositiveInteger(value: number | undefined, field: string): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new Error(`${field} must be a positive integer`);
+ }
+}
+
+export function isSafeWorkspaceRelativePath(path: string): boolean {
+ if (!path || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) {
+ return false;
+ }
+
+ const segments = path.replace(/\\/g, "/").split("/");
+ return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
+}
+
+export function normalizeLocalhostUrl(url: string): string {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw new Error("url must be a valid URL");
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error("url must use http or https");
+ }
+
+ const hostname = parsed.hostname.toLowerCase();
+ const isLocalhost =
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "::1" ||
+ hostname.endsWith(".localhost");
+ if (!isLocalhost) {
+ throw new Error("browser.openUrl only supports localhost URLs");
+ }
+
+ return parsed.toString();
+}
+
+export function validateUiActionIntent(intent: UiActionIntent): UiActionIntent {
+ switch (intent.type) {
+ case "editor.openFile": {
+ if (!isSafeWorkspaceRelativePath(intent.path)) {
+ throw new Error("editor.openFile path must be workspace-relative");
+ }
+ assertPositiveInteger(intent.line, "line");
+ assertPositiveInteger(intent.column, "column");
+ return { ...intent };
+ }
+ case "editor.closeFile": {
+ if (!isSafeWorkspaceRelativePath(intent.path)) {
+ throw new Error("editor.closeFile path must be workspace-relative");
+ }
+ return { ...intent };
+ }
+ case "browser.openUrl":
+ return { ...intent, url: normalizeLocalhostUrl(intent.url) };
+ case "browser.closeUrl":
+ return { ...intent, url: normalizeLocalhostUrl(intent.url) };
+ case "workspace.focus":
+ if (!intent.workspaceId) {
+ throw new Error("workspace.focus requires workspaceId");
+ }
+ return { ...intent };
+ case "panel.show":
+ return { ...intent };
+ case "command.run":
+ if (!ALLOWED_UI_COMMAND_IDS.includes(intent.commandId)) {
+ throw new Error(`UI command is not allowed: ${intent.commandId}`);
+ }
+ return { ...intent };
+ }
+}
+
+export function normalizeUiActionDispatchRequest(
+ request: UiActionDispatchRequest
+): UiActionDispatchRequest {
+ return {
+ ...request,
+ intent: validateUiActionIntent(request.intent),
+ };
+}
+
+export function resolveUiActionWorkspaceId(
+ request: UiActionDispatchRequest,
+ fallbackWorkspaceId?: string
+): string {
+ const workspaceId = "workspaceId" in request.intent ? request.intent.workspaceId : undefined;
+ const resolved = workspaceId ?? fallbackWorkspaceId;
+ if (!resolved) {
+ throw new Error("workspaceId is required for this UI action");
+ }
+ return resolved;
+}
+
+function createRequestId(): string {
+ if (typeof globalThis.crypto?.randomUUID === "function") {
+ return globalThis.crypto.randomUUID();
+ }
+
+ return `ui_action_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
+}
+
+export function createUiActionEvent(input: {
+ request: UiActionDispatchRequest;
+ workspaceId: string;
+ dispatchedAt: number;
+}): UiActionEvent {
+ return {
+ requestId: input.request.requestId ?? createRequestId(),
+ workspaceId: input.workspaceId,
+ intent: input.request.intent,
+ source: input.request.source,
+ dispatchedAt: input.dispatchedAt,
+ };
+}
+
+export function createUiActionDispatchResult(event: UiActionEvent): UiActionDispatchResult {
+ return {
+ accepted: true,
+ requestId: event.requestId,
+ topic: Topics.workspaceUiAction(event.workspaceId),
+ };
+}
diff --git a/packages/core/src/domain/update.test.ts b/packages/core/src/domain/update.test.ts
index 86e5591cf..fdaef6665 100644
--- a/packages/core/src/domain/update.test.ts
+++ b/packages/core/src/domain/update.test.ts
@@ -27,7 +27,7 @@ describe("update domain helpers", () => {
it("creates the default update settings shape", () => {
expect(createDefaultUpdateSettings()).toEqual({
autoCheckEnabled: true,
- checkIntervalSec: 21600,
+ checkIntervalSec: 3600,
});
});
diff --git a/packages/core/src/domain/update.ts b/packages/core/src/domain/update.ts
index bafae6787..19f4c1299 100644
--- a/packages/core/src/domain/update.ts
+++ b/packages/core/src/domain/update.ts
@@ -1,6 +1,6 @@
export const UPDATE_CHECK_INTERVAL_OPTIONS = [3600, 21600, 43200, 86400] as const;
-export const DEFAULT_UPDATE_CHECK_INTERVAL_SEC = 21600;
+export const DEFAULT_UPDATE_CHECK_INTERVAL_SEC = 3600;
export const DEFAULT_UPDATE_AUTO_CHECK_ENABLED = true;
export type UpdateCheckIntervalSec = (typeof UPDATE_CHECK_INTERVAL_OPTIONS)[number];
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index efddaafbd..cc6faaa93 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -3,9 +3,9 @@
export * from "./domain/automation";
export * from "./domain/diagnostics";
export * from "./domain/events";
-export * from "./domain/extension-state";
export * from "./domain/lsp";
export * from "./domain/mcp";
+export * from "./domain/memory";
export * from "./domain/monitoring";
export * from "./domain/provider-install";
export * from "./domain/skill-management";
@@ -13,6 +13,7 @@ export * from "./domain/supervisor";
export * from "./domain/system-dependency-install";
// Domain
export * from "./domain/types";
+export * from "./domain/ui-actions";
export * from "./domain/update";
export * from "./protocol/messages";
export * from "./protocol/topics";
diff --git a/packages/core/src/protocol/messages.test.ts b/packages/core/src/protocol/messages.test.ts
index b3cfb438c..ba540bc6c 100644
--- a/packages/core/src/protocol/messages.test.ts
+++ b/packages/core/src/protocol/messages.test.ts
@@ -155,3 +155,9 @@ describe("terminal recovery protocol types", () => {
expect(event.reason).toBe("stream_drop");
});
});
+
+describe("ui action protocol events", () => {
+ it("exports a workspace UI action topic builder", () => {
+ expect(Topics.workspaceUiAction("ws-1")).toBe("workspace.ws-1.ui.action");
+ });
+});
diff --git a/packages/core/src/protocol/topics.ts b/packages/core/src/protocol/topics.ts
index 525bab21e..9fe18a70e 100644
--- a/packages/core/src/protocol/topics.ts
+++ b/packages/core/src/protocol/topics.ts
@@ -7,10 +7,10 @@ export const Topics = {
// Workspace-level
workspaceMeta: (id: string) => `workspace.${id}.meta`,
- workspaceExtensionState: (id: string) => `workspace.${id}.extension.state`,
workspaceFsDirty: (id: string) => `workspace.${id}.fs.dirty`,
workspaceGitState: (id: string) => `workspace.${id}.git.state`,
workspaceLspDiagnostics: (workspaceId: string) => `workspace.${workspaceId}.lsp.diagnostics`,
+ workspaceUiAction: (workspaceId: string) => `workspace.${workspaceId}.ui.action`,
workspaceAll: (id: string) => `workspace.${id}.*`,
// Session-level
@@ -43,6 +43,7 @@ export const Topics = {
notificationToast: "notification.toast",
monitoringSnapshotUpdated: "monitoring.snapshot.updated",
updateStateChanged: "update.state.changed",
+ skillLibraryChanged: "skills.library.changed",
systemDependencyInstallOutput: (jobId: string) => `systemDeps.install.${jobId}.output`,
// Supervisor-level (Phase 3)
diff --git a/packages/providers/src/codex/stdout-heuristics.test.ts b/packages/providers/src/codex/stdout-heuristics.test.ts
index c6c8cdaf0..f5c91baa6 100644
--- a/packages/providers/src/codex/stdout-heuristics.test.ts
+++ b/packages/providers/src/codex/stdout-heuristics.test.ts
@@ -44,12 +44,16 @@ describe("Codex Stdout Heuristics", () => {
["Output\n> ", true],
["Output\n$ ", true],
["Output\n>>> ", true],
- ["Output\n█ ", true],
])('should detect idle prompt in "%s"', (output, expected) => {
const result = detectIdlePrompt(output);
expect(result).toBe(expected);
});
+ it("should not treat a block cursor as an idle prompt by itself", () => {
+ const result = detectIdlePrompt("Still working\n█ ");
+ expect(result).toBe(false);
+ });
+
it("should return false for no idle prompt", () => {
const result = detectIdlePrompt("Still processing...");
expect(result).toBe(false);
diff --git a/packages/providers/src/codex/stdout-heuristics.ts b/packages/providers/src/codex/stdout-heuristics.ts
index 1b5ec5a38..b7179ae10 100644
--- a/packages/providers/src/codex/stdout-heuristics.ts
+++ b/packages/providers/src/codex/stdout-heuristics.ts
@@ -32,9 +32,6 @@ export const idlePromptPatterns: RegExp[] = [
// Codex-specific: newline + ">>> "
/\n>>>\s*$/,
-
- // Prompt with cursor indicator
- /\n.*\u2588\s*$/, // █ cursor
];
/**
diff --git a/packages/server/src/__tests__/dispatch.test.ts b/packages/server/src/__tests__/dispatch.test.ts
index 9706ae726..0d5719d1d 100644
--- a/packages/server/src/__tests__/dispatch.test.ts
+++ b/packages/server/src/__tests__/dispatch.test.ts
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { CommandContext } from "../ws/dispatch.js";
import "../commands/workspace-activity.js";
+import "../commands/ui-actions.js";
import { dispatch, getRegisteredCommands, registerCommand } from "../ws/dispatch.js";
describe("Command Dispatch", () => {
@@ -202,5 +203,35 @@ describe("Command Dispatch", () => {
expect(result.ok).toBe(true);
expect(ctx.autoFetch.unregisterViewer).toHaveBeenCalledWith("client-1");
});
+
+ it("allows UI action dispatch from command clients without an active browser lease", async () => {
+ const broadcast = vi.fn();
+ ctx = {
+ ...ctx,
+ broadcaster: {
+ broadcast,
+ getRequestMetadata: () => ({ url: "/ws" }),
+ } as never,
+ activationMgr: { getLease: () => undefined } as never,
+ };
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-action-allowlist-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ requestId: "req-1",
+ intent: { type: "panel.show", panel: "terminal" },
+ },
+ },
+ ctx,
+ "cli-client"
+ );
+
+ expect(result.ok).toBe(true);
+ expect(broadcast).toHaveBeenCalled();
+ });
});
});
diff --git a/packages/server/src/__tests__/server-builtin-skills-wiring.test.ts b/packages/server/src/__tests__/server-builtin-skills-wiring.test.ts
index e4e466b77..41c4ea0a2 100644
--- a/packages/server/src/__tests__/server-builtin-skills-wiring.test.ts
+++ b/packages/server/src/__tests__/server-builtin-skills-wiring.test.ts
@@ -1,5 +1,4 @@
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
-import { readFile } from "node:fs/promises";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -37,7 +36,7 @@ describe("server built-in skills wiring", () => {
vi.resetModules();
});
- it("syncs built-in skills and wires automation audit log on startup", async () => {
+ it("materializes and auto-mounts default built-in skills on startup", async () => {
tempDir = mkdtempSync(join(tmpdir(), "coder-studio-server-builtin-skills-"));
process.env.HOME = join(tempDir, "home");
process.env.USERPROFILE = join(tempDir, "home");
@@ -55,20 +54,65 @@ describe("server built-in skills wiring", () => {
expect(ctx.builtinSkillSyncMgr).toBeDefined();
expect(ctx.automationAuditLog).toBeDefined();
expect(ctx.stateRoot).toBe(join(tempDir, "state-root"));
- expect(ctx.skillLibraryRepo?.get("coder-studio-automation")).toMatchObject({
- source: "builtin",
- builtin: { defaultEnabled: true, autoMount: true },
- });
+ expect(ctx.skillLibraryRepo?.list().filter((entry) => entry.source === "builtin")).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ slug: "coder-studio-open",
+ source: "builtin",
+ installState: "installed",
+ builtin: { defaultEnabled: true, autoMount: true },
+ }),
+ expect.objectContaining({
+ slug: "coder-studio-memory",
+ source: "builtin",
+ installState: "installed",
+ builtin: { defaultEnabled: true, autoMount: true },
+ }),
+ ])
+ );
+ expect(ctx.skillLibraryRepo?.list().filter((entry) => entry.source === "builtin")).toHaveLength(
+ 2
+ );
+
+ const builtinRoot = join(tempDir, "state-root", "state", "skills", "builtin");
+ const builtinOpenSkillPath = join(builtinRoot, "coder-studio-open", "SKILL.md");
+ expect(existsSync(builtinOpenSkillPath)).toBe(true);
+ expect(readFileSync(builtinOpenSkillPath, "utf8")).toContain("coder-studio ui open-file");
+ expect(readFileSync(builtinOpenSkillPath, "utf8")).toContain("coder-studio ui open-url");
+ expect(readFileSync(builtinOpenSkillPath, "utf8")).toContain("coder-studio ui close-file");
+ expect(readFileSync(builtinOpenSkillPath, "utf8")).toContain("coder-studio ui close-url");
+
+ const builtinMemorySkillPath = join(builtinRoot, "coder-studio-memory", "SKILL.md");
+ expect(existsSync(builtinMemorySkillPath)).toBe(true);
+ expect(readFileSync(builtinMemorySkillPath, "utf8")).toContain("coder-studio memory list");
+ expect(readFileSync(builtinMemorySkillPath, "utf8")).toContain("coder-studio memory search");
+ expect(readFileSync(builtinMemorySkillPath, "utf8")).toContain("coder-studio memory add");
+
+ const homeOpenSkillPath = join(
+ tempDir,
+ "home",
+ ".agents",
+ "skills",
+ "coder-studio-open",
+ "SKILL.md"
+ );
+ expect(existsSync(homeOpenSkillPath)).toBe(true);
+ expect(readFileSync(homeOpenSkillPath, "utf8")).toContain("coder-studio ui open-file");
+ expect(readFileSync(homeOpenSkillPath, "utf8")).toContain("coder-studio ui open-url");
+ expect(readFileSync(homeOpenSkillPath, "utf8")).toContain("coder-studio ui close-file");
+ expect(readFileSync(homeOpenSkillPath, "utf8")).toContain("coder-studio ui close-url");
- const skillPath = join(
+ const homeMemorySkillPath = join(
tempDir,
"home",
".agents",
"skills",
- "coder-studio-automation",
+ "coder-studio-memory",
"SKILL.md"
);
- expect(existsSync(skillPath)).toBe(true);
- await expect(readFile(skillPath, "utf8")).resolves.toContain("coder-studio identify --json");
+ expect(existsSync(homeMemorySkillPath)).toBe(true);
+ expect(readFileSync(homeMemorySkillPath, "utf8")).toContain("coder-studio memory list");
+ expect(readFileSync(homeMemorySkillPath, "utf8")).toContain("coder-studio memory search");
+ expect(readFileSync(homeMemorySkillPath, "utf8")).toContain("coder-studio memory add");
}, 20_000);
});
diff --git a/packages/server/src/__tests__/server-memory-wiring.test.ts b/packages/server/src/__tests__/server-memory-wiring.test.ts
new file mode 100644
index 000000000..f21b8d5cd
--- /dev/null
+++ b/packages/server/src/__tests__/server-memory-wiring.test.ts
@@ -0,0 +1,59 @@
+import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { WorkspaceMemoryEntry } from "@coder-studio/core";
+import { afterEach, describe, expect, it } from "vitest";
+import { createServer, type Server } from "../server.js";
+import { dispatch } from "../ws/dispatch.js";
+
+describe("createServer memory wiring", () => {
+ let server: Server | undefined;
+ let tempDir: string | undefined;
+
+ afterEach(async () => {
+ if (server) {
+ await server.stop();
+ server = undefined;
+ }
+
+ if (tempDir) {
+ rmSync(tempDir, { recursive: true, force: true });
+ tempDir = undefined;
+ }
+ });
+
+ it("assembles workspace memory storage into the command context", async () => {
+ tempDir = mkdtempSync(join(tmpdir(), "coder-studio-server-memory-"));
+ const workspaceDir = join(tempDir, "workspace");
+ server = await createServer({
+ stateDir: join(tempDir, "state-root"),
+ host: "127.0.0.1",
+ port: 0,
+ });
+ const ctx = server.__test__!.commandContext;
+ mkdirSync(workspaceDir, { recursive: true });
+ const workspace = await ctx.workspaceMgr.open({ path: workspaceDir });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "server-memory-create",
+ op: "memory.create",
+ args: {
+ workspaceId: workspace.id,
+ type: "project",
+ content: "Memory commands use the server-assembled MemoryRepo.",
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toMatchObject({
+ workspaceId: workspace.id,
+ type: "project",
+ content: "Memory commands use the server-assembled MemoryRepo.",
+ } satisfies Partial);
+ expect(ctx.memoryRepo?.list({ workspaceId: workspace.id })).toHaveLength(1);
+ }, 20_000);
+});
diff --git a/packages/server/src/__tests__/skills/builtin-mount-preferences.test.ts b/packages/server/src/__tests__/skills/builtin-mount-preferences.test.ts
new file mode 100644
index 000000000..bf456fb6e
--- /dev/null
+++ b/packages/server/src/__tests__/skills/builtin-mount-preferences.test.ts
@@ -0,0 +1,70 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { BuiltinSkillMountPreferences } from "../../skills/builtin/mount-preferences.js";
+import { SettingsRepo } from "../../storage/repositories/settings-repo.js";
+
+describe("BuiltinSkillMountPreferences", () => {
+ let tempDir: string | undefined;
+
+ afterEach(async () => {
+ if (tempDir) {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ async function createPreferences() {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-mount-preferences-"));
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+
+ return {
+ settingsRepo,
+ preferences: new BuiltinSkillMountPreferences(settingsRepo),
+ };
+ }
+
+ it("marks disabled mounts as skipped even for auto-mounted built-ins", async () => {
+ const { preferences } = await createPreferences();
+
+ preferences.setMountEnabled("codex", "coder-studio-open", false);
+
+ expect(preferences.getMountDecision("codex", "coder-studio-open", true)).toEqual({
+ shouldMount: false,
+ reason: "disabled",
+ });
+ expect(preferences.isMountDisabled("codex", "coder-studio-open")).toBe(true);
+ });
+
+ it("requires explicit enable for built-ins that are not auto-mounted in MVP", async () => {
+ const { preferences } = await createPreferences();
+
+ expect(preferences.getMountDecision("codex", "example-builtin", false)).toEqual({
+ shouldMount: false,
+ reason: "not_mvp_auto",
+ });
+
+ preferences.setMountEnabled("codex", "example-builtin", true);
+
+ expect(preferences.getMountDecision("codex", "example-builtin", false)).toEqual({
+ shouldMount: true,
+ });
+ });
+
+ it("removes stale skill preference entries from both enabled and disabled maps", async () => {
+ const { preferences, settingsRepo } = await createPreferences();
+
+ preferences.setMountEnabled("codex", "old-builtin-skill", false);
+ preferences.setMountEnabled("claude", "old-builtin-skill", true);
+ preferences.setMountEnabled("codex", "keep-me", false);
+
+ preferences.removeSkill("old-builtin-skill");
+
+ expect(settingsRepo.get("skills.builtin.disabledMounts")).toEqual({
+ "codex:keep-me": true,
+ });
+ expect(settingsRepo.get("skills.builtin.enabledMounts")).toEqual({});
+ });
+});
diff --git a/packages/server/src/__tests__/skills/builtin-registry.test.ts b/packages/server/src/__tests__/skills/builtin-registry.test.ts
index b7dd9cfa7..23dbc7981 100644
--- a/packages/server/src/__tests__/skills/builtin-registry.test.ts
+++ b/packages/server/src/__tests__/skills/builtin-registry.test.ts
@@ -2,8 +2,31 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
+import { CODER_STUDIO_MEMORY_SKILL } from "../../skills/builtin/definitions/coder-studio-memory.js";
+import { CODER_STUDIO_OPEN_SKILL } from "../../skills/builtin/definitions/coder-studio-open.js";
+import { BUILTIN_SKILLS } from "../../skills/builtin/definitions/index.js";
+import type { BuiltinSkillDefinition } from "../../skills/builtin/definitions/types.js";
import { materializeBuiltinSkills } from "../../skills/builtin/materialize.js";
-import { BUILTIN_SKILLS } from "../../skills/builtin/registry.js";
+
+const TEST_BUILTIN_SKILL: BuiltinSkillDefinition = {
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ description: "Test fixture for built-in skill materialization.",
+ version: "1.0.0",
+ defaultEnabled: true,
+ autoMountInMvp: false,
+ content: [
+ "---",
+ "name: coder-studio-example-builtin",
+ "description: Test fixture for built-in skill materialization.",
+ "---",
+ "",
+ "# Example Builtin",
+ "",
+ "Use this fixture only in tests.",
+ "",
+ ].join("\n"),
+};
describe("builtin skills", () => {
let tempDir: string | undefined;
@@ -14,33 +37,101 @@ describe("builtin skills", () => {
}
});
- it("declares the MVP built-in skills with stable defaults", () => {
- expect(BUILTIN_SKILLS.map((skill) => skill.slug)).toEqual([
- "coder-studio-automation",
- "coder-studio-browser-verification",
- "coder-studio-review",
- ]);
- expect(BUILTIN_SKILLS.find((skill) => skill.slug === "coder-studio-automation")).toMatchObject({
+ it("declares the Coder Studio open skill as an auto-mounted built-in", () => {
+ expect(CODER_STUDIO_OPEN_SKILL).toMatchObject({
+ slug: "coder-studio-open",
+ displayName: "Coder Studio Open",
+ description: "Open workspace files and localhost URLs in Coder Studio.",
+ version: "1.0.0",
defaultEnabled: true,
autoMountInMvp: true,
});
- expect(
- BUILTIN_SKILLS.find((skill) => skill.slug === "coder-studio-browser-verification")
- ).toMatchObject({
+ expect(CODER_STUDIO_OPEN_SKILL.content).toContain("name: coder-studio-open");
+ expect(CODER_STUDIO_OPEN_SKILL.content).toContain("coder-studio ui open-file");
+ expect(CODER_STUDIO_OPEN_SKILL.content).toContain("coder-studio ui open-url");
+ expect(CODER_STUDIO_OPEN_SKILL.content).toContain("coder-studio ui close-file");
+ expect(CODER_STUDIO_OPEN_SKILL.content).toContain("coder-studio ui close-url");
+ expect(CODER_STUDIO_OPEN_SKILL.content).not.toContain("coder-studio ui show-panel");
+ expect(CODER_STUDIO_OPEN_SKILL.content).not.toContain("coder-studio ui run-command");
+ expect(BUILTIN_SKILLS).toContain(CODER_STUDIO_OPEN_SKILL);
+ });
+
+ it("declares the workspace memory skill as default enabled and auto-mounted", () => {
+ expect(CODER_STUDIO_MEMORY_SKILL).toMatchObject({
+ slug: "coder-studio-memory",
+ displayName: "Coder Studio Memory",
defaultEnabled: true,
- autoMountInMvp: false,
+ autoMountInMvp: true,
});
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain("coder-studio memory list");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain("coder-studio memory search");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain("coder-studio memory add");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain(
+ "Read workspace memory when you need to understand the project or context better, recover debugging information, verify commands or conventions, or review durable notes before making changes."
+ );
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain(
+ "Write memory when you learn something durable that should help a future session, such as a confirmed project rule, a stable debugging conclusion, a reusable command, a repository convention, a technical constraint, a real follow-up todo, or an important feature constraint."
+ );
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain(
+ "Use `feature` for product behavior, `todo` for pending work, `bugfix` for defects, `project` for repository-operating knowledge, and `note` only as a fallback."
+ );
+ expect(CODER_STUDIO_MEMORY_SKILL.content).toContain(
+ 'coder-studio memory add --workspace --type project --content "This workspace uses pnpm for package scripts." --skill coder-studio-memory --json'
+ );
+ expect(CODER_STUDIO_MEMORY_SKILL.content).not.toContain("--tag");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).not.toContain("--type decision");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).not.toContain("--title");
+ expect(CODER_STUDIO_MEMORY_SKILL.content).not.toContain("mem_");
+ expect(BUILTIN_SKILLS).toContain(CODER_STUDIO_MEMORY_SKILL);
});
- it("materializes built-in SKILL.md files into the state directory", async () => {
+ it("materializes built-in SKILL.md files", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-memory-"));
+
+ const entries = await materializeBuiltinSkills({
+ builtinRoot: tempDir,
+ now: () => 1234,
+ });
+
+ const openEntry = entries.find((entry) => entry.slug === "coder-studio-open");
+ expect(openEntry).toMatchObject({
+ slug: "coder-studio-open",
+ source: "builtin",
+ installState: "installed",
+ builtin: { defaultEnabled: true, autoMount: true },
+ });
+
+ const openContent = await readFile(join(openEntry!.libraryPath, "SKILL.md"), "utf8");
+ expect(openContent).toContain("name: coder-studio-open");
+ expect(openContent).toContain("coder-studio ui open-file");
+ expect(openContent).toContain("coder-studio ui open-url");
+
+ const memoryEntry = entries.find((entry) => entry.slug === "coder-studio-memory");
+ expect(memoryEntry).toMatchObject({
+ slug: "coder-studio-memory",
+ source: "builtin",
+ installState: "installed",
+ builtin: { defaultEnabled: true, autoMount: true },
+ });
+
+ const content = await readFile(join(memoryEntry!.libraryPath, "SKILL.md"), "utf8");
+ expect(content).toContain("name: coder-studio-memory");
+ expect(content).toContain(
+ "Read workspace memory when you need to understand the project or context better"
+ );
+ expect(content).not.toContain("Actual workspace memory");
+ });
+
+ it("materializes provided built-in SKILL.md files into the state directory", async () => {
tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-skills-"));
const entries = await materializeBuiltinSkills({
builtinRoot: tempDir,
now: () => 1234,
+ skills: [TEST_BUILTIN_SKILL],
});
- expect(entries).toHaveLength(3);
+ expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
source: "builtin",
installState: "installed",
@@ -48,10 +139,10 @@ describe("builtin skills", () => {
updatedAt: 1234,
});
- const automation = entries.find((entry) => entry.slug === "coder-studio-automation");
- expect(automation).toBeDefined();
- expect(await readFile(join(automation!.libraryPath, "SKILL.md"), "utf8")).toContain(
- "coder-studio identify --json"
- );
+ const exampleBuiltin = entries.find((entry) => entry.slug === "coder-studio-example-builtin");
+ expect(exampleBuiltin).toBeDefined();
+ const content = await readFile(join(exampleBuiltin!.libraryPath, "SKILL.md"), "utf8");
+ expect(content).toContain("coder-studio-example-builtin");
+ expect(content).toContain("Use this fixture only in tests.");
});
});
diff --git a/packages/server/src/__tests__/skills/builtin-sync-manager.test.ts b/packages/server/src/__tests__/skills/builtin-sync-manager.test.ts
index 084a4225b..5831513d1 100644
--- a/packages/server/src/__tests__/skills/builtin-sync-manager.test.ts
+++ b/packages/server/src/__tests__/skills/builtin-sync-manager.test.ts
@@ -1,14 +1,34 @@
-import { mkdtemp, rm } from "node:fs/promises";
+import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ProviderDefinition } from "@coder-studio/core";
import { afterEach, describe, expect, it } from "vitest";
+import type { BuiltinSkillDefinition } from "../../skills/builtin/registry.js";
+import { BUILTIN_SKILLS } from "../../skills/builtin/registry.js";
import { BuiltinSkillSyncManager } from "../../skills/builtin/sync-manager.js";
import { SkillMountManager } from "../../skills/mount-manager.js";
import { SettingsRepo } from "../../storage/repositories/settings-repo.js";
import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
+const TEST_BUILTIN_SKILL: BuiltinSkillDefinition = {
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ description: "Test fixture for built-in skill sync.",
+ version: "1.0.0",
+ defaultEnabled: true,
+ autoMountInMvp: false,
+ content: [
+ "---",
+ "name: coder-studio-example-builtin",
+ "description: Test fixture for built-in skill sync.",
+ "---",
+ "",
+ "# Example Builtin",
+ "",
+ ].join("\n"),
+};
+
function provider(id: string, skillDir?: string): ProviderDefinition {
return {
id,
@@ -40,7 +60,76 @@ describe("BuiltinSkillSyncManager", () => {
}
});
- it("syncs built-ins into the library and mounts MVP defaults", async () => {
+ it("auto-mounts the default Coder Studio built-in skills", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-default-sync-"));
+ const skillDir = join(tempDir, "codex-skills");
+ const providers = [provider("codex", skillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ });
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot: join(tempDir, "builtin"),
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ });
+
+ const result = await manager.sync();
+
+ expect(BUILTIN_SKILLS.map((skill) => skill.slug)).toEqual(
+ expect.arrayContaining(["coder-studio-open", "coder-studio-memory"])
+ );
+ expect(result.libraryEntries.map((entry) => entry.slug)).toEqual(
+ expect.arrayContaining(["coder-studio-open", "coder-studio-memory"])
+ );
+ expect(result.mounted).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ providerId: "codex",
+ skillSlug: "coder-studio-open",
+ status: "mounted",
+ }),
+ expect.objectContaining({
+ providerId: "codex",
+ skillSlug: "coder-studio-memory",
+ status: "mounted",
+ }),
+ ])
+ );
+ expect(result.mounted).toHaveLength(2);
+ expect(result.skipped).toEqual([]);
+ expect(mountRepo.get("codex", "coder-studio-open")).toEqual(
+ expect.objectContaining({
+ providerId: "codex",
+ skillSlug: "coder-studio-open",
+ })
+ );
+ expect(mountRepo.get("codex", "coder-studio-memory")).toEqual(
+ expect.objectContaining({
+ providerId: "codex",
+ skillSlug: "coder-studio-memory",
+ })
+ );
+ await expect(lstat(join(skillDir, "coder-studio-open", "SKILL.md"))).resolves.toBeTruthy();
+ await expect(lstat(join(skillDir, "coder-studio-memory", "SKILL.md"))).resolves.toBeTruthy();
+ });
+
+ it("syncs provided built-ins into the library without auto-mounting by default", async () => {
tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-sync-"));
const skillDir = join(tempDir, "codex-skills");
const providers = [provider("codex", skillDir)];
@@ -67,29 +156,73 @@ describe("BuiltinSkillSyncManager", () => {
skillMountMgr: mountManager,
settingsRepo,
now: () => 1000,
+ skills: [TEST_BUILTIN_SKILL],
});
const result = await manager.sync();
expect(result.libraryEntries.map((entry) => entry.slug)).toEqual([
- "coder-studio-automation",
- "coder-studio-browser-verification",
- "coder-studio-review",
+ "coder-studio-example-builtin",
]);
- expect(mountRepo.get("codex", "coder-studio-automation")).toMatchObject({
- enabled: true,
- status: "mounted",
+ expect(mountRepo.get("codex", "coder-studio-example-builtin")).toBeUndefined();
+ expect(result.mounted).toEqual([]);
+ expect(result.skipped).toEqual([
+ {
+ providerId: "codex",
+ skillSlug: "coder-studio-example-builtin",
+ reason: "not_mvp_auto",
+ },
+ ]);
+ });
+
+ it("persists disabled mount settings for built-in skills", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-disabled-"));
+ const skillDir = join(tempDir, "codex-skills");
+ const providers = [provider("codex", skillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
});
- expect(mountRepo.get("codex", "coder-studio-review")).toMatchObject({
- enabled: true,
- status: "mounted",
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+ settingsRepo.set("skills.builtin.disabledMounts", {
+ "codex:coder-studio-example-builtin": true,
+ });
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot: join(tempDir, "builtin"),
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ skills: [TEST_BUILTIN_SKILL],
+ });
+
+ await manager.sync();
+
+ expect(mountRepo.get("codex", "coder-studio-example-builtin")).toBeUndefined();
+ expect(settingsRepo.get("skills.builtin.disabledMounts")).toEqual({
+ "codex:coder-studio-example-builtin": true,
});
- expect(mountRepo.get("codex", "coder-studio-browser-verification")).toBeUndefined();
});
- it("does not re-mount a user-disabled built-in skill", async () => {
- tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-disabled-"));
+ it("removes stale built-in skills without touching other installed skills", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-stale-"));
+ const builtinRoot = join(tempDir, "builtin");
const skillDir = join(tempDir, "codex-skills");
+ const staleLibraryPath = join(builtinRoot, "old-builtin-skill");
+ const staleTargetPath = join(skillDir, "old-builtin-skill");
+ const skillHubLibraryPath = join(tempDir, "skillhub", "frontend-design");
const providers = [provider("codex", skillDir)];
const libraryRepo = new SkillLibraryRepo({
filePath: join(tempDir, "library-index.json"),
@@ -100,8 +233,278 @@ describe("BuiltinSkillSyncManager", () => {
const settingsRepo = new SettingsRepo({
filePath: join(tempDir, "settings.json"),
});
+
+ await mkdir(staleLibraryPath, { recursive: true });
+ await writeFile(join(staleLibraryPath, "SKILL.md"), "# Old Builtin\n", "utf8");
+ await mkdir(staleTargetPath, { recursive: true });
+ await writeFile(join(staleTargetPath, "SKILL.md"), "# Mounted Old Builtin\n", "utf8");
+ await mkdir(skillHubLibraryPath, { recursive: true });
+ await writeFile(join(skillHubLibraryPath, "SKILL.md"), "# Frontend Design\n", "utf8");
+
+ libraryRepo.set({
+ slug: "old-builtin-skill",
+ displayName: "Old Builtin Skill",
+ description: "No longer registered",
+ version: "0.1.0",
+ source: "builtin",
+ libraryPath: staleLibraryPath,
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 1,
+ builtin: { defaultEnabled: true, autoMount: true },
+ });
+ libraryRepo.set({
+ slug: "frontend-design",
+ displayName: "Frontend Design",
+ description: "Installed from Skill Hub",
+ version: "1.0.0",
+ source: "skillhub",
+ libraryPath: skillHubLibraryPath,
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 1,
+ });
+ mountRepo.upsert({
+ providerId: "codex",
+ skillSlug: "old-builtin-skill",
+ enabled: true,
+ sourcePath: staleLibraryPath,
+ targetPath: staleTargetPath,
+ mountModeResolved: "copy",
+ status: "mounted",
+ lastSyncedAt: 1,
+ });
settingsRepo.set("skills.builtin.disabledMounts", {
- "codex:coder-studio-review": true,
+ "codex:old-builtin-skill": true,
+ "codex:unrelated-builtin-skill": true,
+ });
+ settingsRepo.set("skills.builtin.enabledMounts", {
+ "codex:old-builtin-skill": true,
+ "codex:unrelated-builtin-skill": true,
+ });
+
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot,
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ });
+
+ const result = await manager.sync();
+
+ expect(libraryRepo.get("old-builtin-skill")).toBeUndefined();
+ expect(libraryRepo.get("frontend-design")).toEqual(
+ expect.objectContaining({ slug: "frontend-design", source: "skillhub" })
+ );
+ await expect(lstat(staleLibraryPath)).rejects.toThrow();
+ await expect(lstat(staleTargetPath)).rejects.toThrow();
+ await expect(lstat(skillHubLibraryPath)).resolves.toBeTruthy();
+ expect(mountRepo.get("codex", "old-builtin-skill")).toBeUndefined();
+ expect(settingsRepo.get("skills.builtin.disabledMounts")).toEqual({
+ "codex:unrelated-builtin-skill": true,
+ });
+ expect(settingsRepo.get("skills.builtin.enabledMounts")).toEqual({
+ "codex:unrelated-builtin-skill": true,
+ });
+ expect(result.removed).toEqual([
+ {
+ skillSlug: "old-builtin-skill",
+ unmountedProviderIds: ["codex"],
+ },
+ ]);
+ });
+
+ it("removes stale built-in symlink artifacts that were already dropped from the library index", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-stale-local-"));
+ const builtinRoot = join(tempDir, "builtin");
+ const skillDir = join(tempDir, "agent-skills");
+ const staleLibraryPath = join(builtinRoot, "old-builtin-skill");
+ const staleTargetPath = join(skillDir, "old-builtin-skill");
+ const providers = [provider("codex", skillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ localSkillRoots: [skillDir],
+ });
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+
+ await mkdir(staleLibraryPath, { recursive: true });
+ await writeFile(join(staleLibraryPath, "SKILL.md"), "# Old Builtin\n", "utf8");
+ await mkdir(skillDir, { recursive: true });
+ await symlink(staleLibraryPath, staleTargetPath);
+
+ expect(libraryRepo.get("old-builtin-skill")).toEqual(
+ expect.objectContaining({ slug: "old-builtin-skill", source: "local" })
+ );
+
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot,
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ });
+
+ const result = await manager.sync();
+
+ expect(libraryRepo.get("old-builtin-skill")).toBeUndefined();
+ await expect(lstat(staleTargetPath)).rejects.toThrow();
+ await expect(lstat(staleLibraryPath)).rejects.toThrow();
+ expect(result.removed).toEqual([
+ {
+ skillSlug: "old-builtin-skill",
+ unmountedProviderIds: ["codex"],
+ },
+ ]);
+ });
+
+ it("removes local stale built-in symlinks that point at another Coder Studio state directory", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-stale-external-"));
+ const builtinRoot = join(tempDir, "dev", "state", "skills", "builtin");
+ const externalBuiltinRoot = join(tempDir, "prod", "state", "skills", "builtin");
+ const skillDir = join(tempDir, "agent-skills");
+ const providerSkillDir = join(tempDir, "provider-skills");
+ const staleLibraryPath = join(externalBuiltinRoot, "old-builtin-skill");
+ const sharedTargetPath = join(skillDir, "old-builtin-skill");
+ const providerTargetPath = join(providerSkillDir, "old-builtin-skill");
+ const providers = [provider("codex", skillDir), provider("claude", providerSkillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ localSkillRoots: [skillDir],
+ });
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+
+ await mkdir(staleLibraryPath, { recursive: true });
+ await writeFile(join(staleLibraryPath, "SKILL.md"), "# Old Builtin\n", "utf8");
+ await mkdir(skillDir, { recursive: true });
+ await mkdir(providerSkillDir, { recursive: true });
+ await symlink(staleLibraryPath, sharedTargetPath);
+ await symlink(staleLibraryPath, providerTargetPath);
+
+ expect(libraryRepo.get("old-builtin-skill")).toEqual(
+ expect.objectContaining({ slug: "old-builtin-skill", source: "local" })
+ );
+
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot,
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ });
+
+ const result = await manager.sync();
+
+ expect(libraryRepo.get("old-builtin-skill")).toBeUndefined();
+ await expect(lstat(sharedTargetPath)).rejects.toThrow();
+ await expect(lstat(providerTargetPath)).rejects.toThrow();
+ await expect(lstat(staleLibraryPath)).resolves.toBeTruthy();
+ expect(result.removed).toEqual([
+ {
+ skillSlug: "old-builtin-skill",
+ unmountedProviderIds: ["codex", "claude"],
+ },
+ ]);
+ });
+
+ it("does not remove a real local skill that reuses a stale built-in slug", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-stale-real-local-"));
+ const builtinRoot = join(tempDir, "builtin");
+ const skillDir = join(tempDir, "agent-skills");
+ const staleLibraryPath = join(builtinRoot, "old-builtin-skill");
+ const localSkillPath = join(skillDir, "old-builtin-skill");
+ const providers = [provider("codex", skillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ localSkillRoots: [skillDir],
+ });
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
+ });
+
+ await mkdir(staleLibraryPath, { recursive: true });
+ await writeFile(join(staleLibraryPath, "SKILL.md"), "# Old Builtin\n", "utf8");
+ await mkdir(localSkillPath, { recursive: true });
+ await writeFile(join(localSkillPath, "SKILL.md"), "# User Local Skill\n", "utf8");
+
+ const mountManager = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ });
+
+ const manager = new BuiltinSkillSyncManager({
+ builtinRoot,
+ getProviderRegistry: () => providers,
+ skillLibraryRepo: libraryRepo,
+ skillMountRepo: mountRepo,
+ skillMountMgr: mountManager,
+ settingsRepo,
+ now: () => 1000,
+ });
+
+ await manager.sync();
+
+ expect(libraryRepo.get("old-builtin-skill")).toEqual(
+ expect.objectContaining({
+ slug: "old-builtin-skill",
+ source: "local",
+ libraryPath: localSkillPath,
+ })
+ );
+ await expect(lstat(localSkillPath)).resolves.toBeTruthy();
+ await expect(lstat(staleLibraryPath)).rejects.toThrow();
+ });
+
+ it("mounts and unmounts built-in skills when their mount preference changes", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "coder-studio-builtin-toggle-"));
+ const skillDir = join(tempDir, "codex-skills");
+ const providers = [provider("codex", skillDir)];
+ const libraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ });
+ const mountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const settingsRepo = new SettingsRepo({
+ filePath: join(tempDir, "settings.json"),
});
const mountManager = new SkillMountManager({
getProviderRegistry: () => providers,
@@ -117,11 +520,29 @@ describe("BuiltinSkillSyncManager", () => {
skillMountMgr: mountManager,
settingsRepo,
now: () => 1000,
+ skills: [TEST_BUILTIN_SKILL],
});
+ await manager.sync();
+ manager.setMountEnabled("codex", "coder-studio-example-builtin", true);
+ await manager.sync();
+
+ expect(mountRepo.get("codex", "coder-studio-example-builtin")).toEqual(
+ expect.objectContaining({
+ providerId: "codex",
+ skillSlug: "coder-studio-example-builtin",
+ status: "mounted",
+ })
+ );
+ await expect(lstat(join(skillDir, "coder-studio-example-builtin"))).resolves.toBeTruthy();
+
+ manager.setMountEnabled("codex", "coder-studio-example-builtin", false);
await manager.sync();
- expect(mountRepo.get("codex", "coder-studio-automation")).toBeDefined();
- expect(mountRepo.get("codex", "coder-studio-review")).toBeUndefined();
+ expect(mountRepo.get("codex", "coder-studio-example-builtin")).toBeUndefined();
+ await expect(lstat(join(skillDir, "coder-studio-example-builtin"))).rejects.toThrow();
+ expect(settingsRepo.get("skills.builtin.disabledMounts")).toEqual({
+ "codex:coder-studio-example-builtin": true,
+ });
});
});
diff --git a/packages/server/src/__tests__/skills/commands.test.ts b/packages/server/src/__tests__/skills/commands.test.ts
index 48acc9a83..8610ba452 100644
--- a/packages/server/src/__tests__/skills/commands.test.ts
+++ b/packages/server/src/__tests__/skills/commands.test.ts
@@ -1,14 +1,17 @@
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { Topics } from "@coder-studio/core";
import { describe, expect, it, vi } from "vitest";
+import { registerSkillsCommands } from "../../commands/skills/index.js";
import { SkillHealthManager } from "../../skills/health-manager.js";
import { SkillMountManager } from "../../skills/mount-manager.js";
import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
import type { CommandContext } from "../../ws/dispatch.js";
import { dispatch } from "../../ws/dispatch.js";
-import "../../commands/skills.js";
+
+registerSkillsCommands();
function createBaseContext(overrides: Partial = {}): CommandContext {
return {
@@ -86,6 +89,7 @@ describe("skills commands", () => {
slug: "code-review",
displayName: "Code Review",
description: "Review code changes before merge",
+ version: "1.3.0",
},
]),
} as never,
@@ -107,6 +111,7 @@ describe("skills commands", () => {
slug: "code-review",
displayName: "Code Review",
description: "Review code changes before merge",
+ version: "1.3.0",
installed: true,
installedVersion: "1.2.3",
mountedProviderIds: ["codex"],
@@ -114,6 +119,103 @@ describe("skills commands", () => {
]);
});
+ it("recommends uninstalled skills from workspace intelligence", async () => {
+ const workspaceRoot = await mkdtemp(join(tmpdir(), "skills-recommend-workspace-"));
+ try {
+ await writeFile(
+ join(workspaceRoot, "package.json"),
+ JSON.stringify(
+ {
+ dependencies: {
+ react: "^19.0.0",
+ },
+ devDependencies: {
+ vite: "^7.0.0",
+ },
+ scripts: {
+ test: "vitest run",
+ build: "vite build",
+ lint: "eslint .",
+ },
+ },
+ null,
+ 2
+ )
+ );
+
+ const ctx = createBaseContext({
+ workspaceMgr: {
+ get: vi.fn(() => ({ id: "ws-1", path: workspaceRoot })),
+ } as never,
+ skillLibraryRepo: {
+ get: vi.fn((slug: string) =>
+ slug === "react-review"
+ ? {
+ slug: "react-review",
+ displayName: "React Review",
+ description: "Review React code",
+ version: "1.0.0",
+ source: "skillhub",
+ libraryPath: "/library/react-review",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 1,
+ }
+ : undefined
+ ),
+ } as never,
+ skillMountRepo: {
+ listBySkillSlug: vi.fn(() => []),
+ } as never,
+ skillsHubClient: {
+ search: vi.fn(async (query: string) => {
+ if (query.includes("React")) {
+ return [
+ {
+ slug: "vite-testing",
+ displayName: "Vite Testing",
+ description: "Testing Vite apps",
+ },
+ {
+ slug: "react-review",
+ displayName: "React Review",
+ description: "Review React code",
+ },
+ ];
+ }
+
+ return [];
+ }),
+ } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-recommend-1",
+ op: "skills.recommend",
+ args: { workspaceId: "ws-1" },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual([
+ expect.objectContaining({
+ slug: "vite-testing",
+ installed: false,
+ reason: expect.any(String),
+ sourceQuery: expect.any(String),
+ }),
+ ]);
+ expect(
+ (result.data as Array<{ slug: string }>).some((item) => item.slug === "react-review")
+ ).toBe(false);
+ } finally {
+ await rm(workspaceRoot, { recursive: true, force: true });
+ }
+ });
+
it("returns merged remote and local info for a skill", async () => {
const ctx = createBaseContext({
skillLibraryRepo: {
@@ -228,21 +330,192 @@ describe("skills commands", () => {
]);
});
+ it("checks installed Skill Hub versions and skips local or built-in skills", async () => {
+ const info = vi.fn(async (slug: string) => {
+ if (slug === "code-review") {
+ return {
+ slug,
+ name: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.3.0",
+ };
+ }
+
+ if (slug === "security-review") {
+ return {
+ slug,
+ name: "Security Review",
+ description: "Review security issues",
+ version: "1.2.3",
+ };
+ }
+
+ return { slug };
+ });
+ const ctx = createBaseContext({
+ skillLibraryRepo: {
+ list: vi.fn(() => [
+ {
+ slug: "code-review",
+ displayName: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ source: "skillhub",
+ libraryPath: "/library/code-review",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ },
+ {
+ slug: "security-review",
+ displayName: "Security Review",
+ description: "Review security issues",
+ version: "1.2.3",
+ source: "skillhub",
+ libraryPath: "/library/security-review",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ },
+ {
+ slug: "local-helper",
+ displayName: "Local Helper",
+ version: "local",
+ source: "local",
+ libraryPath: "/library/local-helper",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ },
+ {
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ version: "1.0.0",
+ source: "builtin",
+ libraryPath: "/library/builtin/example-builtin",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ builtin: { defaultEnabled: true, autoMount: false },
+ },
+ ]),
+ } as never,
+ skillMountRepo: {
+ listBySkillSlug: vi.fn(() => []),
+ } as never,
+ skillsHubClient: { info } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-versions-check-1",
+ op: "skills.versions.check",
+ args: {},
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual([
+ {
+ slug: "code-review",
+ currentVersion: "1.2.3",
+ latestVersion: "1.3.0",
+ status: "update_available",
+ },
+ {
+ slug: "security-review",
+ currentVersion: "1.2.3",
+ latestVersion: "1.2.3",
+ status: "up_to_date",
+ },
+ ]);
+ expect(info).toHaveBeenCalledTimes(2);
+ expect(info).toHaveBeenCalledWith("code-review");
+ expect(info).toHaveBeenCalledWith("security-review");
+ });
+
+ it("reports unknown and error states when Skill Hub version checks cannot compare versions", async () => {
+ const info = vi.fn(async (slug: string) => {
+ if (slug === "missing-version") {
+ return { slug, name: "Missing Version" };
+ }
+
+ throw new Error("Skill Hub unavailable");
+ });
+ const ctx = createBaseContext({
+ skillLibraryRepo: {
+ list: vi.fn(() => [
+ {
+ slug: "missing-version",
+ displayName: "Missing Version",
+ version: "1.0.0",
+ source: "skillhub",
+ libraryPath: "/library/missing-version",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ },
+ {
+ slug: "lookup-failed",
+ displayName: "Lookup Failed",
+ version: "1.0.0",
+ source: "skillhub",
+ libraryPath: "/library/lookup-failed",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ },
+ ]),
+ } as never,
+ skillMountRepo: {
+ listBySkillSlug: vi.fn(() => []),
+ } as never,
+ skillsHubClient: { info } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-versions-check-unknown-1",
+ op: "skills.versions.check",
+ args: {},
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual([
+ {
+ slug: "missing-version",
+ currentVersion: "1.0.0",
+ status: "unknown",
+ },
+ {
+ slug: "lookup-failed",
+ currentVersion: "1.0.0",
+ status: "error",
+ error: "Skill Hub unavailable",
+ },
+ ]);
+ });
+
it("returns builtin library metadata", async () => {
const ctx = createBaseContext({
skillLibraryRepo: {
list: vi.fn(() => [
{
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Teach agents",
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ description: "Test fixture for built-in skill command behavior.",
version: "1.0.0",
source: "builtin",
- libraryPath: "/skills/builtin/coder-studio-automation",
+ libraryPath: "/skills/builtin/coder-studio-example-builtin",
installState: "installed",
installedAt: 1,
updatedAt: 2,
- builtin: { defaultEnabled: true, autoMount: true },
+ builtin: { defaultEnabled: true, autoMount: false },
},
]),
} as never,
@@ -265,9 +538,9 @@ describe("skills commands", () => {
expect(result.ok).toBe(true);
expect(result.data).toEqual([
expect.objectContaining({
- slug: "coder-studio-automation",
+ slug: "coder-studio-example-builtin",
source: "builtin",
- builtin: { defaultEnabled: true, autoMount: true },
+ builtin: { defaultEnabled: true, autoMount: false },
}),
]);
});
@@ -296,6 +569,40 @@ describe("skills commands", () => {
expect(sync).toHaveBeenCalledTimes(1);
});
+ it("broadcasts a skill library change after builtin sync removes stale skills", async () => {
+ const broadcast = vi.fn();
+ const removed = [{ skillSlug: "old-builtin-skill", unmountedProviderIds: ["codex"] }];
+ const sync = vi.fn(async () => ({
+ libraryEntries: [],
+ mounted: [],
+ skipped: [],
+ removed,
+ }));
+ const ctx = createBaseContext({
+ broadcaster: { broadcast } as never,
+ builtinSkillSyncMgr: { sync } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-builtin-sync-broadcast-1",
+ op: "skills.builtin.sync",
+ args: {},
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(broadcast).toHaveBeenCalledWith(
+ Topics.skillLibraryChanged,
+ expect.objectContaining({
+ reason: "builtin_sync",
+ removed,
+ })
+ );
+ });
+
it("persists builtin mount disablement through command dispatch", async () => {
const setMountEnabled = vi.fn();
const sync = vi.fn(async () => ({
@@ -314,7 +621,7 @@ describe("skills commands", () => {
op: "skills.builtin.setMountEnabled",
args: {
providerId: "codex",
- skillSlug: "coder-studio-review",
+ skillSlug: "coder-studio-example-builtin",
enabled: false,
},
},
@@ -322,7 +629,7 @@ describe("skills commands", () => {
);
expect(result.ok).toBe(true);
- expect(setMountEnabled).toHaveBeenCalledWith("codex", "coder-studio-review", false);
+ expect(setMountEnabled).toHaveBeenCalledWith("codex", "coder-studio-example-builtin", false);
expect(sync).toHaveBeenCalledTimes(1);
});
@@ -368,6 +675,117 @@ describe("skills commands", () => {
expect(get).toHaveBeenCalledWith("job-1");
});
+ it("starts updates for installed Skill Hub skills only", async () => {
+ const start = vi.fn(async () => ({
+ jobId: "job-update-1",
+ slug: "code-review",
+ status: "queued",
+ steps: [],
+ }));
+ const ctx = createBaseContext({
+ skillInstallMgr: { start } as never,
+ skillLibraryRepo: {
+ get: vi.fn(() => ({
+ slug: "code-review",
+ displayName: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ source: "skillhub",
+ libraryPath: "/library/code-review",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ })),
+ } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-update-start-1",
+ op: "skills.update.start",
+ args: { slug: "code-review" },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(start).toHaveBeenCalledWith("code-review");
+ });
+
+ it("rejects update requests for non-Skill Hub skills", async () => {
+ const start = vi.fn();
+ const ctx = createBaseContext({
+ skillInstallMgr: { start } as never,
+ skillLibraryRepo: {
+ get: vi.fn(() => ({
+ slug: "local-helper",
+ displayName: "Local Helper",
+ version: "local",
+ source: "local",
+ libraryPath: "/library/local-helper",
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 2,
+ })),
+ } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-update-start-local-1",
+ op: "skills.update.start",
+ args: { slug: "local-helper" },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatchObject({
+ code: "skill_update_unavailable",
+ message: "Only installed Skill Hub skills can be updated: local-helper",
+ });
+ expect(start).not.toHaveBeenCalled();
+ });
+
+ it("rejects update requests for Skill Hub skills that are not installed", async () => {
+ const start = vi.fn();
+ const ctx = createBaseContext({
+ skillInstallMgr: { start } as never,
+ skillLibraryRepo: {
+ get: vi.fn(() => ({
+ slug: "code-review",
+ displayName: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ source: "skillhub",
+ libraryPath: "/library/code-review",
+ installState: "failed",
+ installedAt: 1,
+ updatedAt: 2,
+ })),
+ } as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-update-start-failed-1",
+ op: "skills.update.start",
+ args: { slug: "code-review" },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatchObject({
+ code: "skill_update_unavailable",
+ message: "Only installed Skill Hub skills can be updated: code-review",
+ });
+ expect(start).not.toHaveBeenCalled();
+ });
+
it("mounts a skill and persists scanned relation", async () => {
const mount = vi.fn(async () => ({
providerId: "codex",
@@ -797,9 +1215,11 @@ describe("skills commands", () => {
const tempDir = join(tmpdir(), `skill-uninstall-${Date.now()}`);
const libraryPath = join(tempDir, "code-review");
await mkdir(libraryPath, { recursive: true });
+ const broadcast = vi.fn();
const deleteBySkillSlug = vi.fn();
const deleteEntry = vi.fn();
const ctx = createBaseContext({
+ broadcaster: { broadcast } as never,
skillsHubClient: {} as never,
skillLibraryRepo: {
get: vi.fn(() => ({
@@ -834,9 +1254,65 @@ describe("skills commands", () => {
expect(result.ok).toBe(true);
expect(deleteBySkillSlug).toHaveBeenCalledWith("code-review");
expect(deleteEntry).toHaveBeenCalledWith("code-review");
+ expect(broadcast).toHaveBeenCalledWith(
+ Topics.skillLibraryChanged,
+ expect.objectContaining({
+ reason: "uninstalled",
+ slug: "code-review",
+ })
+ );
await expect(rm(libraryPath, { recursive: true, force: false })).rejects.toBeTruthy();
});
+ it("rejects uninstalling built-in skills", async () => {
+ const tempDir = join(tmpdir(), `skill-uninstall-builtin-${Date.now()}`);
+ const libraryPath = join(tempDir, "coder-studio-example-builtin");
+ await mkdir(libraryPath, { recursive: true });
+ const deleteBySkillSlug = vi.fn();
+ const deleteEntry = vi.fn();
+ const ctx = createBaseContext({
+ skillsHubClient: {} as never,
+ skillLibraryRepo: {
+ get: vi.fn(() => ({
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ description: "Test fixture for built-in skill command behavior.",
+ version: "1.0.0",
+ source: "builtin",
+ libraryPath,
+ installState: "installed",
+ installedAt: 1,
+ updatedAt: 1,
+ builtin: { defaultEnabled: true, autoMount: false },
+ })),
+ delete: deleteEntry,
+ } as never,
+ skillMountRepo: {
+ listBySkillSlug: vi.fn(() => []),
+ deleteBySkillSlug,
+ } as never,
+ skillMountMgr: {} as never,
+ });
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "skills-uninstall-builtin-1",
+ op: "skills.uninstall",
+ args: { slug: "coder-studio-example-builtin", force: true },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("skill_uninstall_unavailable");
+ expect(deleteBySkillSlug).not.toHaveBeenCalled();
+ expect(deleteEntry).not.toHaveBeenCalled();
+ expect((await lstat(libraryPath)).isDirectory()).toBe(true);
+
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
it("scans and persists mount health state", async () => {
const upsert = vi.fn();
const ctx = createBaseContext({
diff --git a/packages/server/src/__tests__/skills/install-manager.test.ts b/packages/server/src/__tests__/skills/install-manager.test.ts
new file mode 100644
index 000000000..5ca788515
--- /dev/null
+++ b/packages/server/src/__tests__/skills/install-manager.test.ts
@@ -0,0 +1,117 @@
+import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { ProviderDefinition } from "@coder-studio/core";
+import { describe, expect, it, vi } from "vitest";
+import { SkillInstallManager } from "../../skills/install-manager.js";
+import { SkillMountManager } from "../../skills/mount-manager.js";
+import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
+import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
+
+function provider(id: string, skillDir?: string): ProviderDefinition {
+ return {
+ id,
+ displayName: id,
+ badge: id,
+ kind: "built_in",
+ supportsSkillsMount: true,
+ capability: "full",
+ capabilities: [],
+ install: {
+ prerequisites: [],
+ manualGuideKeys: [],
+ docUrls: { provider: "", prerequisites: {} },
+ strategies: {},
+ },
+ buildCommand: () => ({ argv: [id], env: {}, cwd: "/tmp" }),
+ configSchema: { parse: (value: unknown) => value } as never,
+ defaultConfig: {},
+ requiredCommands: [id],
+ skillMountDirectories: skillDir ? [skillDir] : undefined,
+ };
+}
+
+async function waitForJob(
+ manager: SkillInstallManager,
+ jobId: string
+): Promise> {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ const job = manager.get(jobId);
+ if (job?.status === "succeeded" || job?.status === "failed") {
+ return job;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return manager.get(jobId);
+}
+
+describe("SkillInstallManager", () => {
+ it("auto-mounts installed Skill Hub skills into installed agent skill targets", async () => {
+ const tempDir = await mkdtemp(join(tmpdir(), "skill-install-auto-mount-"));
+ try {
+ const libraryRoot = join(tempDir, "library");
+ const exportDir = join(tempDir, "export");
+ const stagedSkillPath = join(exportDir, "code-review");
+ const codexSkillDir = join(tempDir, "codex-skills");
+ const uninstalledSkillDir = join(tempDir, "uninstalled-skills");
+ await mkdir(stagedSkillPath, { recursive: true });
+ await writeFile(join(stagedSkillPath, "SKILL.md"), "---\nversion: 1.2.3\n---\n");
+
+ const skillLibraryRepo = new SkillLibraryRepo({
+ filePath: join(tempDir, "library-index.json"),
+ });
+ const skillMountRepo = new SkillMountRepo({
+ filePath: join(tempDir, "mounts.json"),
+ });
+ const providers = [
+ provider("codex", codexSkillDir),
+ provider("uninstalled-agent", uninstalledSkillDir),
+ provider("unconfigured-agent"),
+ ];
+ const skillMountMgr = new SkillMountManager({
+ getProviderRegistry: () => providers,
+ skillLibraryRepo,
+ skillMountRepo,
+ });
+ const manager = new SkillInstallManager({
+ skillsHubClient: {
+ info: vi.fn(async () => ({
+ slug: "code-review",
+ name: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ })),
+ stageInstall: vi.fn(async () => ({ tempHome: join(tempDir, "home"), exportDir })),
+ readStagedSkill: vi.fn(async () => "skill body"),
+ cleanupStage: vi.fn(async () => undefined),
+ } as never,
+ skillLibraryRepo,
+ libraryRoot,
+ skillMountMgr,
+ getInstalledSkillTargetProviderIds: async () => ["codex"],
+ });
+
+ const started = await manager.start("code-review");
+ const finished = await waitForJob(manager, started.jobId);
+
+ expect(finished?.status).toBe("succeeded");
+ expect(finished?.steps.map((step) => step.id)).toEqual([
+ "stage-install",
+ "write-library",
+ "mount-targets",
+ ]);
+ expect(skillMountRepo.get("codex", "code-review")).toMatchObject({
+ providerId: "codex",
+ skillSlug: "code-review",
+ enabled: true,
+ status: "mounted",
+ });
+ await expect(lstat(join(codexSkillDir, "code-review"))).resolves.toBeTruthy();
+ await expect(lstat(join(uninstalledSkillDir, "code-review"))).rejects.toBeTruthy();
+ expect(skillMountRepo.get("uninstalled-agent", "code-review")).toBeUndefined();
+ expect(skillMountRepo.get("unconfigured-agent", "code-review")).toBeUndefined();
+ } finally {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/server/src/__tests__/skills/search-parser.test.ts b/packages/server/src/__tests__/skills/search-parser.test.ts
index 2fe3641de..b861b9cb5 100644
--- a/packages/server/src/__tests__/skills/search-parser.test.ts
+++ b/packages/server/src/__tests__/skills/search-parser.test.ts
@@ -2,6 +2,38 @@ import { describe, expect, it } from "vitest";
import { parseSkillsHubSearchOutput } from "../../skills/search-parser.js";
describe("parseSkillsHubSearchOutput", () => {
+ it("extracts search rows from Skills Hub JSON output", () => {
+ const output = JSON.stringify([
+ {
+ slug: "garrytan-gstack-review",
+ name: "review",
+ version: "1.2.3",
+ description:
+ "Pre-landing PR review checking SQL safety, LLM trust boundaries, conditional side effects, and structural issues.",
+ },
+ {
+ slug: "frontend-design",
+ name: "frontend-design",
+ description: "Generates distinctive frontend code with strong aesthetic direction.",
+ },
+ ]);
+
+ expect(parseSkillsHubSearchOutput(output)).toEqual([
+ {
+ slug: "garrytan-gstack-review",
+ displayName: "review",
+ version: "1.2.3",
+ description:
+ "Pre-landing PR review checking SQL safety, LLM trust boundaries, conditional side effects, and structural issues.",
+ },
+ {
+ slug: "frontend-design",
+ displayName: "frontend-design",
+ description: "Generates distinctive frontend code with strong aesthetic direction.",
+ },
+ ]);
+ });
+
it("extracts search rows from CLI text output", () => {
const output = [
"1. code-review",
@@ -26,28 +58,30 @@ describe("parseSkillsHubSearchOutput", () => {
]);
});
- it("extracts rows from the current Skills Hub CLI output", () => {
+ it("extracts rows from legacy Skills Hub CLI text output", () => {
const output = [
"\u001b[1mFound 2 skills:\u001b[22m",
"",
" \u001b[90m[--]\u001b[39m \u001b[1mcode-review\u001b[22m \u001b[90mv1.0.0\u001b[39m",
" \u001b[90mThorough code review - checks correctness, security, performance\u001b[39m",
- " \u001b[36mnpx @skills-hub-ai/cli install code-review\u001b[39m \u001b[90m40 installs\u001b[39m",
+ " \u001b[36mnpx @skill-hub/cli install code-review\u001b[39m \u001b[90m40 installs\u001b[39m",
"",
" \u001b[90m[--]\u001b[39m \u001b[1msecurity-review\u001b[22m \u001b[90mv2.0.0\u001b[39m",
" \u001b[90mSecurity audit and vulnerability assessment for any codebase.\u001b[39m",
- " \u001b[36mnpx @skills-hub-ai/cli install security-review\u001b[39m \u001b[90m14 installs\u001b[39m",
+ " \u001b[36mnpx @skill-hub/cli install security-review\u001b[39m \u001b[90m14 installs\u001b[39m",
].join("\n");
expect(parseSkillsHubSearchOutput(output)).toEqual([
{
slug: "code-review",
displayName: "code-review",
+ version: "1.0.0",
description: "Thorough code review - checks correctness, security, performance",
},
{
slug: "security-review",
displayName: "security-review",
+ version: "2.0.0",
description: "Security audit and vulnerability assessment for any codebase.",
},
]);
diff --git a/packages/server/src/__tests__/skills/skills-hub-client.test.ts b/packages/server/src/__tests__/skills/skills-hub-client.test.ts
index 98bee8cbe..56ce84fa3 100644
--- a/packages/server/src/__tests__/skills/skills-hub-client.test.ts
+++ b/packages/server/src/__tests__/skills/skills-hub-client.test.ts
@@ -4,7 +4,14 @@ import { SkillsHubClient } from "../../skills/skills-hub-client.js";
describe("SkillsHubClient", () => {
it("runs search with the expected CLI arguments", async () => {
const runCommand = vi.fn(async () => ({
- stdout: "1. code-review\n Name: Code Review\n",
+ stdout: JSON.stringify([
+ {
+ slug: "code-review",
+ name: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ },
+ ]),
stderr: "",
}));
const client = new SkillsHubClient({ runCommand });
@@ -13,33 +20,86 @@ describe("SkillsHubClient", () => {
expect(runCommand).toHaveBeenCalledWith(
"npx",
- ["-y", "@skills-hub-ai/cli", "search", "review", "--limit", "20"],
- undefined
+ ["-y", "@skill-hub/cli", "search", "review", "--limit", "20", "--json"],
+ expect.objectContaining({
+ env: expect.objectContaining({ NO_COLOR: expect.any(String) }),
+ })
);
expect(results[0]).toMatchObject({ slug: "code-review", displayName: "Code Review" });
});
- it("stages install through a temp HOME and then syncs to an export dir", async () => {
- const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
+ it("derives skill info from exact JSON search results", async () => {
+ const runCommand = vi.fn(async () => ({
+ stdout: JSON.stringify([
+ {
+ slug: "other-review",
+ name: "Other Review",
+ description: "Another result",
+ },
+ {
+ slug: "code-review",
+ name: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ },
+ ]),
+ stderr: "",
+ }));
const client = new SkillsHubClient({ runCommand });
- const staged = await client.stageInstall("code-review");
+ const info = await client.info("code-review");
- expect(runCommand).toHaveBeenNthCalledWith(
- 1,
+ expect(runCommand).toHaveBeenCalledWith(
"npx",
- ["-y", "@skills-hub-ai/cli", "install", "code-review", "--target", "codex", "--no-save"],
+ ["-y", "@skill-hub/cli", "search", "code-review", "--limit", "50", "--json"],
expect.objectContaining({
- env: expect.objectContaining({ HOME: staged.tempHome }),
+ env: expect.objectContaining({ NO_COLOR: expect.any(String) }),
})
);
+ expect(info).toEqual({
+ slug: "code-review",
+ name: "Code Review",
+ description: "Review code changes before merge",
+ version: "1.2.3",
+ });
+ });
+
+ it("stages install directly into an export dir", async () => {
+ const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
+ const client = new SkillsHubClient({ runCommand });
+
+ const staged = await client.stageInstall("code-review");
+
expect(runCommand).toHaveBeenNthCalledWith(
- 2,
+ 1,
"npx",
- ["-y", "@skills-hub-ai/cli", "sync", "codex", "--output", staged.exportDir],
+ [
+ "-y",
+ "@skill-hub/cli",
+ "install",
+ "code-review",
+ "--agent",
+ "codex",
+ "--yes",
+ "--dir",
+ staged.exportDir,
+ ],
expect.objectContaining({
env: expect.objectContaining({ HOME: staged.tempHome }),
})
);
+ expect(runCommand).toHaveBeenCalledTimes(1);
+ });
+
+ it("includes CLI stderr when a Skills Hub command fails", async () => {
+ const runCommand = vi.fn(async () => {
+ throw Object.assign(new Error("Command failed with exit code 1"), {
+ stderr: "npm error 404 Not Found - GET https://registry.npmjs.org/missing-package",
+ stdout: "",
+ });
+ });
+ const client = new SkillsHubClient({ runCommand });
+
+ await expect(client.search("review")).rejects.toThrow("npm error 404 Not Found");
});
});
diff --git a/packages/server/src/__tests__/storage/skill-library-repo.test.ts b/packages/server/src/__tests__/storage/skill-library-repo.test.ts
index 482a8abc3..12f75732f 100644
--- a/packages/server/src/__tests__/storage/skill-library-repo.test.ts
+++ b/packages/server/src/__tests__/storage/skill-library-repo.test.ts
@@ -83,13 +83,13 @@ describe("SkillLibraryRepo", () => {
it("does not let scanned local skills override persisted built-in entries", async () => {
const skillsRoot = join(tempDir, "agents-skills");
- const localSkillDir = join(skillsRoot, "coder-studio-automation");
+ const localSkillDir = join(skillsRoot, "coder-studio-example-builtin");
await mkdir(localSkillDir, { recursive: true });
await writeFile(
join(localSkillDir, "SKILL.md"),
[
"---",
- "name: coder-studio-automation",
+ "name: coder-studio-example-builtin",
"description: Local shadow copy",
"---",
"",
@@ -100,16 +100,16 @@ describe("SkillLibraryRepo", () => {
);
repo.set({
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Built-in automation skill",
+ slug: "coder-studio-example-builtin",
+ displayName: "Coder Studio Example Builtin",
+ description: "Built-in test fixture",
version: "1.0.0",
source: "builtin",
- libraryPath: join(tempDir, "state", "skills", "builtin", "coder-studio-automation"),
+ libraryPath: join(tempDir, "state", "skills", "builtin", "coder-studio-example-builtin"),
installState: "installed",
installedAt: 1,
updatedAt: 2,
- builtin: { defaultEnabled: true, autoMount: true },
+ builtin: { defaultEnabled: true, autoMount: false },
});
const scannedRepo = new SkillLibraryRepo({
@@ -117,12 +117,12 @@ describe("SkillLibraryRepo", () => {
localSkillRoots: [skillsRoot],
});
- expect(scannedRepo.get("coder-studio-automation")).toMatchObject({
- slug: "coder-studio-automation",
- description: "Built-in automation skill",
+ expect(scannedRepo.get("coder-studio-example-builtin")).toMatchObject({
+ slug: "coder-studio-example-builtin",
+ description: "Built-in test fixture",
source: "builtin",
- libraryPath: join(tempDir, "state", "skills", "builtin", "coder-studio-automation"),
- builtin: { defaultEnabled: true, autoMount: true },
+ libraryPath: join(tempDir, "state", "skills", "builtin", "coder-studio-example-builtin"),
+ builtin: { defaultEnabled: true, autoMount: false },
});
});
});
diff --git a/packages/server/src/__tests__/supervisor-integration.test.ts b/packages/server/src/__tests__/supervisor-integration.test.ts
index 28f068eca..f5faa6ee2 100644
--- a/packages/server/src/__tests__/supervisor-integration.test.ts
+++ b/packages/server/src/__tests__/supervisor-integration.test.ts
@@ -50,6 +50,37 @@ const createSessionRecord = (): Session => ({
lastActiveAt: 1,
});
+const buildTargetMemory = () => ({
+ schemaVersion: 2 as const,
+ targetId: "tgt-1",
+ planTree: {
+ id: "plan-root",
+ title: "Supervisor target",
+ objective: "Complete the supervised target",
+ deliverable: "Completed target",
+ acceptanceCriteria: ["Target objective is complete"],
+ status: "in_progress" as const,
+ taskType: "generic" as const,
+ children: [
+ {
+ id: "stage-1",
+ title: "Verify end-to-end persistence",
+ objective: "Confirm the end-to-end persistence flow works",
+ deliverable: "A validated persistence verification pass",
+ acceptanceCriteria: ["Persistence flow is verified"],
+ status: "in_progress" as const,
+ taskType: "generic" as const,
+ children: [],
+ },
+ ],
+ },
+ activeNodeId: "stage-1",
+ maxDepth: 6,
+ planRevision: 0,
+ stalledCount: 0,
+ updatedAt: 1,
+});
+
describe("Supervisor integration", () => {
let server: Server;
@@ -87,25 +118,7 @@ describe("Supervisor integration", () => {
terminalExcerpt: "assistant: built the persistent supervisor repos",
evidenceSource: "headless_snapshot",
latestUserInput: "run the tests",
- targetMemory: {
- targetId: "tgt-1",
- decompositionGenerated: true,
- decompositionMode: "stage",
- items: [
- {
- id: "stage-1",
- kind: "stage",
- title: "Verify end-to-end persistence",
- objective: "Confirm the end-to-end persistence flow works",
- deliverable: "A validated persistence verification pass",
- acceptanceCriteria: ["Persistence flow is verified"],
- status: "in_progress",
- },
- ],
- activeItemId: "stage-1",
- stalledCount: 0,
- updatedAt: 1,
- },
+ targetMemory: buildTargetMemory(),
}),
};
supervisorManager.evaluator = {
@@ -113,27 +126,35 @@ describe("Supervisor integration", () => {
options?.mode === "decompose"
? {
mode: "decompose",
- decompositionMode: "stage",
- items: [
+ children: [
{
id: "stage-1",
- kind: "stage",
title: "Verify end-to-end persistence",
objective: "Confirm the end-to-end persistence flow works",
deliverable: "A validated persistence verification pass",
acceptanceCriteria: ["Persistence flow is verified"],
status: "in_progress",
+ taskType: "generic",
+ children: [],
},
],
- activeItemId: "stage-1",
+ activeNodeId: "stage-1",
progressSummary: "Decomposition complete",
}
- : {
- mode: "evaluate",
- status: "continue",
- reason: "Keep going",
- guidance: "",
- },
+ : options?.mode === "ready_check"
+ ? {
+ mode: "ready_check",
+ nodeId: "stage-1",
+ taskType: "generic",
+ granularity: "too_small",
+ reason: "Current stage is already narrow enough",
+ }
+ : {
+ mode: "evaluate",
+ status: "continue",
+ reason: "Keep going",
+ guidance: "",
+ },
};
});
diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts
index 38893f52c..a95c63e41 100644
--- a/packages/server/src/__tests__/supervisor-manager.test.ts
+++ b/packages/server/src/__tests__/supervisor-manager.test.ts
@@ -5,6 +5,7 @@ import type {
Supervisor,
SupervisorCycle,
SupervisorCycleTargetRecord,
+ SupervisorPlanNode,
SupervisorTargetMemory,
} from "@coder-studio/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -127,6 +128,223 @@ function applySupervisorPatch(current: Supervisor, patch: SupervisorUpdatePatch)
};
}
+type ReadyCheckEvaluation = Extract;
+type DecomposeChildEvaluation = Extract;
+type ExecutableTaskEvaluation = Extract;
+type ContinueEvaluation = Extract<
+ SupervisorEvaluationResult,
+ { mode: "evaluate"; status: "continue" }
+>;
+
+type DecomposeEvaluation = Extract;
+
+function readyCheckEvaluation(overrides: Partial = {}): ReadyCheckEvaluation {
+ return {
+ mode: "ready_check",
+ nodeId: "stage-1",
+ taskType: "generic",
+ granularity: "ready",
+ reason: "Stage is executable",
+ ...overrides,
+ };
+}
+
+function executableTaskEvaluation(
+ overrides: Partial = {}
+): ExecutableTaskEvaluation {
+ return {
+ mode: "executable_task",
+ nodeId: "stage-1",
+ guidance: "Run the focused parser test.",
+ ...overrides,
+ };
+}
+
+function continueEvaluation(overrides: Partial = {}): ContinueEvaluation {
+ return {
+ mode: "evaluate",
+ status: "continue",
+ reason: "Need more work",
+ guidance: "Run the focused parser test.",
+ ...overrides,
+ };
+}
+
+function planNode(
+ overrides: Partial &
+ Pick
+): SupervisorPlanNode {
+ return {
+ id: overrides.id,
+ title: overrides.title,
+ objective: overrides.objective,
+ deliverable: overrides.deliverable,
+ acceptanceCriteria: overrides.acceptanceCriteria ?? [`${overrides.title} is complete`],
+ status: overrides.status ?? "pending",
+ taskType: overrides.taskType ?? "generic",
+ children: overrides.children ?? [],
+ readyCheck: overrides.readyCheck,
+ execution: overrides.execution,
+ };
+}
+
+function stageNode(overrides: Partial = {}): SupervisorPlanNode {
+ return planNode({
+ id: "stage-1",
+ title: "Verify the fix",
+ objective: "Confirm the fix works",
+ deliverable: "A passing focused verification run",
+ acceptanceCriteria: ["Focused verification passes"],
+ status: "in_progress",
+ taskType: "generic",
+ children: [],
+ ...overrides,
+ });
+}
+
+function planRoot(children: SupervisorPlanNode[] = []): SupervisorPlanNode {
+ return planNode({
+ id: "plan-root",
+ title: "Supervisor target",
+ objective: "Complete the supervised target",
+ deliverable: "Completed target",
+ acceptanceCriteria: ["Target objective is complete"],
+ status:
+ children.length === 0
+ ? "pending"
+ : children.every((child) => child.status === "done")
+ ? "done"
+ : "in_progress",
+ taskType: "generic",
+ children,
+ });
+}
+
+function decomposeEvaluation(overrides: Partial = {}): DecomposeEvaluation {
+ return {
+ mode: "decompose",
+ children: [
+ stageNode({
+ title: "Inspect current behavior",
+ objective: "Understand the current implementation",
+ deliverable: "A verified behavior summary",
+ acceptanceCriteria: ["Behavior summary is captured"],
+ }),
+ ],
+ activeNodeId: "stage-1",
+ progressSummary: "Decomposition complete",
+ ...overrides,
+ };
+}
+
+function targetMemory(
+ targetId: string,
+ updatedAt: number,
+ overrides: Partial = {}
+): SupervisorTargetMemory {
+ return {
+ schemaVersion: 2,
+ targetId,
+ planTree: planRoot([stageNode()]),
+ activeNodeId: "stage-1",
+ maxDepth: 6,
+ planRevision: 0,
+ progressSummary: "Verification in progress",
+ lastGuidance: "Run the focused parser test.",
+ stalledCount: 0,
+ updatedAt,
+ ...overrides,
+ };
+}
+
+function emptyTargetMemory(targetId: string, updatedAt: number): SupervisorTargetMemory {
+ return {
+ schemaVersion: 2,
+ targetId,
+ planTree: planRoot(),
+ activeNodeId: undefined,
+ maxDepth: 6,
+ planRevision: 0,
+ stalledCount: 0,
+ updatedAt,
+ };
+}
+
+function mockPreparedEvaluation(
+ finalResult: Extract
+) {
+ return async (
+ _supervisor: Supervisor,
+ _context: SupervisorEvaluationContext,
+ options?: { signal?: AbortSignal; mode?: string }
+ ): Promise => {
+ switch (options?.mode) {
+ case "decompose":
+ return decomposeEvaluation();
+ case "ready_check":
+ return readyCheckEvaluation();
+ case "decompose_child":
+ return decomposeChildEvaluation();
+ case "executable_task":
+ return executableTaskEvaluation({
+ guidance:
+ ("guidance" in finalResult ? finalResult.guidance : undefined) ??
+ "Run the focused parser test.",
+ });
+ case "evaluate":
+ default:
+ return finalResult;
+ }
+ };
+}
+
+function decomposeChildEvaluation(
+ overrides: Partial = {}
+): DecomposeChildEvaluation {
+ return {
+ mode: "decompose_child",
+ parentNodeId: "stage-1",
+ children: [
+ planNode({
+ id: "stage-1-1",
+ title: "Run focused verification",
+ objective: "Confirm the current stage with focused verification",
+ deliverable: "A passing focused verification run",
+ acceptanceCriteria: ["Focused verification passes"],
+ status: "in_progress",
+ taskType: "generic",
+ children: [],
+ }),
+ ],
+ ...overrides,
+ };
+}
+
+function selectSupervisorEvalPayload(prompt: string | undefined): SupervisorEvaluationResult {
+ const text = prompt ?? "";
+ if (text.includes('"mode": "decompose"')) {
+ return decomposeEvaluation();
+ }
+ if (text.includes('"mode": "ready_check"')) {
+ return readyCheckEvaluation();
+ }
+ if (text.includes('"mode": "executable_task"')) {
+ return executableTaskEvaluation();
+ }
+ if (text.includes('"mode": "decompose_child"')) {
+ return decomposeChildEvaluation();
+ }
+ return continueEvaluation();
+}
+
+function buildSupervisorEvalNodeCommand(payload: SupervisorEvaluationResult) {
+ return {
+ argv: ["node", "-e", `process.stdout.write(${JSON.stringify(JSON.stringify(payload))})`],
+ cwd: process.cwd(),
+ env: {},
+ };
+}
+
function createManagerDeps() {
const supervisors = new Map();
const targetMetaById = new Map();
@@ -138,33 +356,15 @@ function createManagerDeps() {
error: vi.fn(),
};
- const codexBuildSupervisorEvalCommand = vi.fn(() => ({
- argv: [
- "node",
- "-e",
- `process.stdout.write(${JSON.stringify(
- JSON.stringify({
- mode: "evaluate",
- status: "continue",
- reason: "Need more work",
- guidance: "Run the focused parser test.",
- })
- )})`,
- ],
- cwd: process.cwd(),
- env: {},
- }));
- const cloneMemory = (memory: SupervisorTargetMemory): SupervisorTargetMemory => ({
- ...memory,
- items: memory.items.map((item) => ({
- ...item,
- acceptanceCriteria: [...item.acceptanceCriteria],
- })),
- });
- const cloneCycleRecord = (record: SupervisorCycleTargetRecord): SupervisorCycleTargetRecord => ({
- ...record,
- itemUpdates: record.itemUpdates?.map((item) => ({ ...item })),
- });
+ const codexBuildSupervisorEvalCommand = vi.fn((...args: unknown[]) =>
+ buildSupervisorEvalNodeCommand(
+ selectSupervisorEvalPayload((args[2] as { prompt?: string } | undefined)?.prompt)
+ )
+ );
+ const cloneMemory = (memory: SupervisorTargetMemory): SupervisorTargetMemory =>
+ structuredClone(memory);
+ const cloneCycleRecord = (record: SupervisorCycleTargetRecord): SupervisorCycleTargetRecord =>
+ structuredClone(record);
const cloneMeta = (meta: SupervisorTargetMeta): SupervisorTargetMeta => ({ ...meta });
const persistedSupervisor = (supervisor: Supervisor): Supervisor => ({
...supervisor,
@@ -188,27 +388,8 @@ function createManagerDeps() {
supersededBy: null,
completedAt: null,
});
- const buildTargetMemory = (targetId: string, createdAt: number): SupervisorTargetMemory => ({
- targetId,
- decompositionGenerated: true,
- decompositionMode: "stage",
- items: [
- {
- id: "stage-1",
- kind: "stage",
- title: "Verify the fix",
- objective: "Confirm the fix works",
- deliverable: "A passing focused verification run",
- acceptanceCriteria: ["Focused verification passes"],
- status: "in_progress",
- },
- ],
- activeItemId: "stage-1",
- progressSummary: "Verification in progress",
- lastGuidance: "Run the focused parser test.",
- stalledCount: 0,
- updatedAt: createdAt,
- });
+ const buildTargetMemory = (targetId: string, createdAt: number): SupervisorTargetMemory =>
+ targetMemory(targetId, createdAt);
const getTargetCycles = (targetId: string, limit = 20): SupervisorCycleTargetRecord[] =>
[...(targetCyclesById.get(targetId) ?? [])].slice(-limit).reverse().map(cloneCycleRecord);
@@ -645,6 +826,9 @@ describe("SupervisorManager cycle triggers", () => {
objective: "Ship the fix",
evaluatorProviderId: "codex",
});
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation(continueEvaluation())
+ );
const cycle = await manager.triggerEvaluation(supervisor.id);
@@ -674,6 +858,9 @@ describe("SupervisorManager cycle triggers", () => {
objective: "Ship the fix",
evaluatorProviderId: "codex",
});
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation(continueEvaluation())
+ );
await getManagerInternals().runEvaluation(supervisor.id);
@@ -692,40 +879,34 @@ describe("SupervisorManager cycle triggers", () => {
});
const managerInternals = getManagerInternals();
- deps.targetStore.loadTargetMemory.mockResolvedValueOnce({
- targetId: supervisor.targetId,
- decompositionGenerated: false,
- items: [],
- stalledCount: 0,
- updatedAt: 1,
- });
+ deps.targetStore.loadTargetMemory.mockResolvedValueOnce(
+ emptyTargetMemory(supervisor.targetId, 1)
+ );
const evaluateSpy = vi.spyOn(managerInternals.evaluator, "evaluate");
evaluateSpy
.mockResolvedValueOnce({
mode: "decompose",
- decompositionMode: "stage",
- items: [
- {
- id: "stage-1",
- kind: "stage",
+ children: [
+ stageNode({
title: "Inspect current behavior",
objective: "Understand the current implementation",
deliverable: "A verified behavior summary",
acceptanceCriteria: ["Behavior summary is captured"],
- status: "in_progress",
- },
+ }),
],
- activeItemId: "stage-1",
+ activeNodeId: "stage-1",
progressSummary: "Decomposition complete",
})
+ .mockResolvedValueOnce(readyCheckEvaluation())
+ .mockResolvedValueOnce(executableTaskEvaluation())
.mockResolvedValueOnce({
mode: "evaluate",
status: "continue",
reason: "Need more work",
guidance: "Run the focused parser test.",
- activeItemId: "stage-1",
- itemUpdates: [{ id: "stage-1", status: "in_progress" }],
+ activeNodeId: "stage-1",
+ nodeUpdates: [{ id: "stage-1", status: "in_progress" }],
});
const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed");
@@ -736,8 +917,9 @@ describe("SupervisorManager cycle triggers", () => {
expect.anything(),
expect.objectContaining({
targetMemory: expect.objectContaining({
- decompositionGenerated: false,
- items: [],
+ planTree: expect.objectContaining({
+ children: [],
+ }),
}),
}),
expect.objectContaining({ mode: "decompose" })
@@ -747,14 +929,51 @@ describe("SupervisorManager cycle triggers", () => {
expect.anything(),
expect.objectContaining({
targetMemory: expect.objectContaining({
- decompositionGenerated: true,
- decompositionMode: "stage",
- items: [
- expect.objectContaining({
- id: "stage-1",
- title: "Inspect current behavior",
- }),
- ],
+ activeNodeId: "stage-1",
+ planTree: expect.objectContaining({
+ children: [
+ expect.objectContaining({
+ id: "stage-1",
+ title: "Inspect current behavior",
+ }),
+ ],
+ }),
+ }),
+ }),
+ expect.objectContaining({ mode: "ready_check" })
+ );
+ expect(evaluateSpy).toHaveBeenNthCalledWith(
+ 3,
+ expect.anything(),
+ expect.objectContaining({
+ targetMemory: expect.objectContaining({
+ activeNodeId: "stage-1",
+ planTree: expect.objectContaining({
+ children: [
+ expect.objectContaining({
+ id: "stage-1",
+ title: "Inspect current behavior",
+ }),
+ ],
+ }),
+ }),
+ }),
+ expect.objectContaining({ mode: "executable_task" })
+ );
+ expect(evaluateSpy).toHaveBeenNthCalledWith(
+ 4,
+ expect.anything(),
+ expect.objectContaining({
+ targetMemory: expect.objectContaining({
+ activeNodeId: "stage-1",
+ planTree: expect.objectContaining({
+ children: [
+ expect.objectContaining({
+ id: "stage-1",
+ title: "Inspect current behavior",
+ }),
+ ],
+ }),
}),
}),
expect.objectContaining({ mode: "evaluate" })
@@ -763,14 +982,15 @@ describe("SupervisorManager cycle triggers", () => {
expect.any(String),
supervisor.targetId,
expect.objectContaining({
- decompositionGenerated: true,
- decompositionMode: "stage",
- items: [
- expect.objectContaining({
- id: "stage-1",
- title: "Inspect current behavior",
- }),
- ],
+ activeNodeId: "stage-1",
+ planTree: expect.objectContaining({
+ children: [
+ expect.objectContaining({
+ id: "stage-1",
+ title: "Inspect current behavior",
+ }),
+ ],
+ }),
})
);
});
@@ -784,11 +1004,14 @@ describe("SupervisorManager cycle triggers", () => {
maxSupervisionCount: 0,
});
- vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({
- status: "stop",
- stopReason: "objective_complete",
- reason: "[objective complete]",
- });
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "stop",
+ stopReason: "objective_complete",
+ reason: "[objective complete]",
+ })
+ );
const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
@@ -807,11 +1030,15 @@ describe("SupervisorManager cycle triggers", () => {
maxSupervisionCount: 1,
});
- vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({
- status: "stop",
- stopReason: "objective_complete",
- reason: "done",
- });
+ const evaluateSpy = vi.spyOn(getManagerInternals().evaluator, "evaluate");
+ evaluateSpy.mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "stop",
+ stopReason: "objective_complete",
+ reason: "done",
+ })
+ );
await getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
@@ -833,11 +1060,14 @@ describe("SupervisorManager cycle triggers", () => {
})
);
- vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({
- status: "continue",
- reason: "keep going",
- guidance: "do the next step",
- });
+ evaluateSpy.mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "continue",
+ reason: "keep going",
+ guidance: "do the next step",
+ })
+ );
const nextCycle = await getManagerInternals().runEvaluation(updated.id, "turn_completed");
expect(nextCycle?.status).toBe("injected");
@@ -958,11 +1188,14 @@ describe("SupervisorManager cycle triggers", () => {
evaluatorProviderId: "codex",
});
- vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({
- status: "stop",
- stopReason: "supervisor_uncertain",
- reason: "I cannot determine the next step safely",
- });
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "stop",
+ stopReason: "supervisor_uncertain",
+ reason: "I cannot determine the next step safely",
+ })
+ );
await getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
@@ -1257,13 +1490,19 @@ describe("SupervisorManager cycle triggers", () => {
evaluatorProviderId: "codex",
});
- vi.spyOn(getManagerInternals().evaluator, "evaluate")
- .mockRejectedValueOnce({ code: "supervisor_eval_timeout", message: "timed out" })
- .mockResolvedValueOnce({
+ let shouldTimeout = true;
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(async (...args) => {
+ if (shouldTimeout) {
+ shouldTimeout = false;
+ throw { code: "supervisor_eval_timeout", message: "timed out" };
+ }
+ return mockPreparedEvaluation({
+ mode: "evaluate",
status: "continue",
reason: "Run tests",
guidance: "Run tests",
- });
+ })(...args);
+ });
const pending = getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
await vi.advanceTimersByTimeAsync(1000);
@@ -1298,16 +1537,22 @@ describe("SupervisorManager cycle triggers", () => {
evaluatorProviderId: "codex",
});
- vi.spyOn(getManagerInternals().evaluator, "evaluate")
- .mockRejectedValueOnce({
- code: "supervisor_eval_failed",
- message: "spawn failed",
- })
- .mockResolvedValueOnce({
+ let shouldFail = true;
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(async (...args) => {
+ if (shouldFail) {
+ shouldFail = false;
+ throw {
+ code: "supervisor_eval_failed",
+ message: "spawn failed",
+ };
+ }
+ return mockPreparedEvaluation({
+ mode: "evaluate",
status: "continue",
reason: "Run tests",
guidance: "Run tests",
- });
+ })(...args);
+ });
const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
@@ -1323,6 +1568,9 @@ describe("SupervisorManager cycle triggers", () => {
evaluatorProviderId: "codex",
maxSupervisionCount: 1,
});
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation(continueEvaluation())
+ );
const first = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed");
expect(first?.status).toBe("injected");
@@ -1342,14 +1590,29 @@ describe("SupervisorManager cycle triggers", () => {
maxSupervisionCount: 1,
});
const managerInternals = getManagerInternals();
+ let blockEvaluate = true;
+ let evaluateStarted = false;
- const evaluate = vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation(
+ vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation(
async (
_supervisor: Supervisor,
_context: SupervisorEvaluationContext,
- options?: { signal?: AbortSignal }
- ) =>
- await new Promise((_resolve, reject) => {
+ options?: { signal?: AbortSignal; mode?: string }
+ ) => {
+ if (options?.mode === "ready_check") {
+ return readyCheckEvaluation();
+ }
+ if (options?.mode === "executable_task") {
+ return executableTaskEvaluation({ guidance: "Run tests" });
+ }
+ if (options?.mode !== "evaluate") {
+ return decomposeEvaluation();
+ }
+ evaluateStarted = true;
+ if (!blockEvaluate) {
+ return continueEvaluation({ reason: "Run tests", guidance: "Run tests" });
+ }
+ return await new Promise((_resolve, reject) => {
const signal = options?.signal;
const abort = () =>
reject({
@@ -1367,12 +1630,13 @@ describe("SupervisorManager cycle triggers", () => {
}
signal.addEventListener("abort", abort, { once: true });
- })
+ });
+ }
);
await manager.triggerEvaluation(supervisor.id);
await waitFor(() => {
- expect(evaluate).toHaveBeenCalledTimes(1);
+ expect(evaluateStarted).toBe(true);
});
await manager.pause(supervisor.id);
@@ -1387,13 +1651,8 @@ describe("SupervisorManager cycle triggers", () => {
expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0);
+ blockEvaluate = false;
await manager.resume(supervisor.id);
- evaluate.mockResolvedValueOnce({
- status: "continue",
- reason: "Run tests",
- guidance: "Run tests",
- });
-
const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed");
expect(finished?.status).toBe("injected");
@@ -1409,6 +1668,9 @@ describe("SupervisorManager cycle triggers", () => {
evaluatorProviderId: "codex",
scheduledAt: Date.now() - 1_000,
});
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation(continueEvaluation())
+ );
const finished = await getManagerInternals().runEvaluation(supervisor.id, "scheduled");
@@ -1426,7 +1688,9 @@ describe("SupervisorManager cycle triggers", () => {
});
const managerInternals = getManagerInternals();
- const evaluate = vi.spyOn(managerInternals.evaluator, "evaluate");
+ const evaluate = vi
+ .spyOn(managerInternals.evaluator, "evaluate")
+ .mockImplementation(mockPreparedEvaluation(continueEvaluation()));
const first = await managerInternals.runEvaluation(supervisor.id, "turn_completed");
const second = await managerInternals.runEvaluation(supervisor.id, "scheduled");
@@ -1435,7 +1699,7 @@ describe("SupervisorManager cycle triggers", () => {
expect(second).toBeNull();
expect(manager.get(supervisor.id)?.scheduledAt).toBeUndefined();
expect(manager.get(supervisor.id)?.recentTargetCycles).toHaveLength(1);
- expect(evaluate).toHaveBeenCalledTimes(1);
+ expect(evaluate).toHaveBeenCalledTimes(3);
});
it("retries a due scheduled run until the session becomes runnable", async () => {
@@ -1444,11 +1708,14 @@ describe("SupervisorManager cycle triggers", () => {
vi.mocked(deps.sessionMgr.get).mockImplementation((sessionId: string) =>
createSessionRecord(sessionId, { state: sessionState })
);
- vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({
- status: "continue",
- reason: "Run tests",
- guidance: "Run tests",
- });
+ vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "continue",
+ reason: "Run tests",
+ guidance: "Run tests",
+ })
+ );
const supervisor = await manager.create({
sessionId: "sess-scheduled-retry",
@@ -1481,11 +1748,14 @@ describe("SupervisorManager cycle triggers", () => {
});
const managerInternals = getManagerInternals();
- vi.spyOn(managerInternals.evaluator, "evaluate").mockResolvedValueOnce({
- status: "continue",
- reason: "Run the focused parser test.",
- guidance: "Run the focused parser test.",
- });
+ vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "continue",
+ reason: "Run the focused parser test.",
+ guidance: "Run the focused parser test.",
+ })
+ );
vi.spyOn(managerInternals.injector, "inject").mockResolvedValueOnce({
injected: true,
text: "[Supervisor] Run the focused parser test.",
@@ -1842,7 +2112,7 @@ describe("SupervisorManager cycle triggers", () => {
})
);
- const cycle = await manager.triggerEvaluation(supervisor.id);
+ await manager.triggerEvaluation(supervisor.id);
await waitFor(() => {
expect(evaluate).toHaveBeenCalledTimes(1);
});
@@ -1879,11 +2149,14 @@ describe("SupervisorManager cycle triggers", () => {
return updated;
});
- vi.spyOn(managerInternals.evaluator, "evaluate").mockResolvedValueOnce({
- status: "continue",
- reason: "Run tests",
- guidance: "Run tests",
- });
+ vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation(
+ mockPreparedEvaluation({
+ mode: "evaluate",
+ status: "continue",
+ reason: "Run tests",
+ guidance: "Run tests",
+ })
+ );
const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed");
diff --git a/packages/server/src/__tests__/ui-actions-commands.test.ts b/packages/server/src/__tests__/ui-actions-commands.test.ts
new file mode 100644
index 000000000..874013c61
--- /dev/null
+++ b/packages/server/src/__tests__/ui-actions-commands.test.ts
@@ -0,0 +1,219 @@
+import { Topics } from "@coder-studio/core";
+import { describe, expect, it, vi } from "vitest";
+import "../commands/ui-actions.js";
+import type { CommandContext } from "../ws/dispatch.js";
+import { dispatch } from "../ws/dispatch.js";
+
+function createContext(overrides: Partial = {}): CommandContext {
+ return {
+ workspaceMgr: {} as never,
+ sessionMgr: {} as never,
+ terminalMgr: {} as never,
+ taskMgr: {} as never,
+ eventBus: {} as never,
+ broadcaster: {
+ broadcast: vi.fn(),
+ } as never,
+ settingsRepo: {} as never,
+ providerConfigRepo: {} as never,
+ providerRegistry: [],
+ fencingMgr: {} as never,
+ supervisorMgr: {} as never,
+ autoFetch: {} as never,
+ activationMgr: { getLease: () => undefined } as never,
+ lspMgr: {} as never,
+ ...overrides,
+ } as CommandContext;
+}
+
+describe("ui action commands", () => {
+ it("returns UI action capabilities", async () => {
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-capabilities-1",
+ op: "uiAction.capabilities",
+ args: { permissions: ["ui:navigate"] },
+ },
+ createContext()
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toMatchObject({
+ version: 1,
+ actions: expect.arrayContaining([expect.objectContaining({ type: "editor.openFile" })]),
+ });
+ });
+
+ it("validates, broadcasts, and returns accepted dispatch metadata", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1234);
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-1",
+ op: "uiAction.dispatch",
+ args: {
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ requestId: "req-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual({
+ accepted: true,
+ requestId: "req-1",
+ topic: Topics.workspaceUiAction("ws-1"),
+ });
+ expect(ctx.broadcaster.broadcast).toHaveBeenCalledWith(Topics.workspaceUiAction("ws-1"), {
+ requestId: "req-1",
+ workspaceId: "ws-1",
+ intent: { type: "editor.openFile", workspaceId: "ws-1", path: "src/index.ts" },
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ dispatchedAt: 1234,
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("validates and broadcasts close-file dispatches", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(2234);
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-close-file-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: { type: "editor.closeFile", path: "src/index.ts" },
+ requestId: "req-close-file-1",
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual({
+ accepted: true,
+ requestId: "req-close-file-1",
+ topic: Topics.workspaceUiAction("ws-1"),
+ });
+ expect(ctx.broadcaster.broadcast).toHaveBeenCalledWith(Topics.workspaceUiAction("ws-1"), {
+ requestId: "req-close-file-1",
+ workspaceId: "ws-1",
+ intent: { type: "editor.closeFile", path: "src/index.ts" },
+ source: { kind: "agent", sessionId: "sess-1", providerId: "codex" },
+ dispatchedAt: 2234,
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("validates and broadcasts normalized close-url dispatches", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(3234);
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-close-url-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: { type: "browser.closeUrl", url: "http://127.0.0.1:5173" },
+ requestId: "req-close-url-1",
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toEqual({
+ accepted: true,
+ requestId: "req-close-url-1",
+ topic: Topics.workspaceUiAction("ws-1"),
+ });
+ expect(ctx.broadcaster.broadcast).toHaveBeenCalledWith(Topics.workspaceUiAction("ws-1"), {
+ requestId: "req-close-url-1",
+ workspaceId: "ws-1",
+ intent: { type: "browser.closeUrl", url: "http://127.0.0.1:5173/" },
+ source: undefined,
+ dispatchedAt: 3234,
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("uses fallback workspaceId when the intent does not include one", async () => {
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-2",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-fallback",
+ intent: { type: "panel.show", panel: "terminal" },
+ requestId: "req-2",
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.data).toMatchObject({ topic: Topics.workspaceUiAction("ws-fallback") });
+ });
+
+ it("rejects unsafe UI action intents before broadcasting", async () => {
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-unsafe-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: { type: "browser.openUrl", url: "https://example.com" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("internal_error");
+ expect(ctx.broadcaster.broadcast).not.toHaveBeenCalled();
+ });
+
+ it("rejects unsafe close-url intents before broadcasting", async () => {
+ const ctx = createContext();
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "ui-dispatch-unsafe-close-url-1",
+ op: "uiAction.dispatch",
+ args: {
+ workspaceId: "ws-1",
+ intent: { type: "browser.closeUrl", url: "https://example.com" },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("internal_error");
+ expect(ctx.broadcaster.broadcast).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts b/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts
index ff3eaa2f4..7823c27e4 100644
--- a/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts
+++ b/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildCursorWorkspaceHash,
+ decodeProviderWorkspacePathFromProjectDir,
encodeProviderWorkspacePath,
isWithinRange,
parseOptionalTimestamp,
@@ -16,6 +17,31 @@ describe("work analysis log source helpers", () => {
);
});
+ it("decodes Cursor project directories back to workspace paths", () => {
+ const projectDir = "c-Users-yeshaopeng-workspace-coder-studio";
+ expect(decodeProviderWorkspacePathFromProjectDir("-repo-app")).toBe("/repo/app");
+ expect(decodeProviderWorkspacePathFromProjectDir("home-w-workspace-lark-docs")).toBe(
+ "/home/w/workspace/lark/docs"
+ );
+ expect(
+ decodeProviderWorkspacePathFromProjectDir("home-w-workspace-lark-docs", [
+ "/home/w/workspace/lark-docs",
+ ])
+ ).toBe("/home/w/workspace/lark-docs");
+ expect(
+ decodeProviderWorkspacePathFromProjectDir(projectDir, [
+ "c:\\Users\\yeshaopeng\\workspace\\coder-studio",
+ ])
+ ).toBe("c:\\Users\\yeshaopeng\\workspace\\coder-studio");
+ expect(
+ decodeProviderWorkspacePathFromProjectDir(projectDir, [
+ "c:/Users/yeshaopeng/workspace/coder-studio",
+ ])
+ ).toBe("c:/Users/yeshaopeng/workspace/coder-studio");
+ expect(decodeProviderWorkspacePathFromProjectDir("empty-window")).toBeUndefined();
+ expect(decodeProviderWorkspacePathFromProjectDir("not-a-real-workspace")).toBeUndefined();
+ });
+
it("builds the Cursor md5 workspace hash from the absolute workspace path", () => {
expect(buildCursorWorkspaceHash("/home/spencer/workspace/coder-studio")).toBe(
"cf4c2089ed329fb5e3bba38e6a05f0bc"
diff --git a/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts b/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts
index 6c8ef89f2..9c2d6adec 100644
--- a/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts
+++ b/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts
@@ -1216,12 +1216,10 @@ describe("file provider work log sources", () => {
[
JSON.stringify({
role: "user",
- cwd: "/repo/app",
message: { content: [{ type: "text", text: "fix" }] },
}),
JSON.stringify({
role: "assistant",
- cwd: "/repo/app",
message: { content: [{ type: "tool_call", name: "shell" }] },
}),
].join("\n")
@@ -1242,10 +1240,97 @@ describe("file provider work log sources", () => {
});
});
- it("skips Cursor transcripts that do not expose a workspace path in the log records", async () => {
+ it("reads Cursor transcripts from WSL-style project directories without cwd in records", async () => {
+ const home = await makeHome();
+ const dir = join(
+ home,
+ ".cursor/projects/home-w-workspace-lark-docs/agent-transcripts/cursor-session-1"
+ );
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "transcript.jsonl"),
+ [
+ JSON.stringify({
+ role: "user",
+ message: { content: [{ type: "text", text: "fix docs" }] },
+ }),
+ JSON.stringify({
+ role: "assistant",
+ message: { content: [{ type: "tool_use", name: "Read" }] },
+ }),
+ ].join("\n")
+ );
+
+ const result = await createCursorWorkLogSource({ home }).discover({
+ workspacePaths: ["/home/w/workspace/lark-docs"],
+ timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" },
+ });
+
+ expect(result.sessions[0]).toMatchObject({
+ providerId: "cursor",
+ sessionId: "transcript",
+ workspacePath: "/home/w/workspace/lark-docs",
+ userTurnCount: 1,
+ assistantTurnCount: 1,
+ toolUseCount: 1,
+ });
+ });
+
+ it("reads Cursor transcripts from Windows-style project directories without cwd in records", async () => {
+ const home = await makeHome();
+ const dir = join(
+ home,
+ ".cursor/projects/c-Users-yeshaopeng-workspace-coder-studio/agent-transcripts/cursor-session-1"
+ );
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "transcript.jsonl"),
+ [
+ JSON.stringify({
+ role: "user",
+ message: { content: [{ type: "text", text: "fix work analysis" }] },
+ }),
+ ].join("\n")
+ );
+
+ const result = await createCursorWorkLogSource({ home }).discover({
+ workspacePaths: ["c:\\Users\\yeshaopeng\\workspace\\coder-studio"],
+ timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" },
+ });
+
+ expect(result.sessions[0]).toMatchObject({
+ providerId: "cursor",
+ sessionId: "transcript",
+ workspacePath: "c:\\Users\\yeshaopeng\\workspace\\coder-studio",
+ userTurnCount: 1,
+ });
+ });
+
+ it("prefers cwd from Cursor transcript records when present", async () => {
+ const home = await makeHome();
+ const dir = join(home, ".cursor/projects/-repo-app/agent-transcripts/cursor-session-1");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, "transcript.jsonl"),
+ [
+ JSON.stringify({
+ role: "user",
+ cwd: "/repo/app/feature",
+ message: { content: [{ type: "text", text: "fix" }] },
+ }),
+ ].join("\n")
+ );
+
+ const result = await createCursorWorkLogSource({ home }).discover({
+ timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" },
+ });
+
+ expect(result.sessions[0]?.workspacePath).toBe("/repo/app/feature");
+ });
+
+ it("skips Cursor project directories that cannot be mapped to a workspace path", async () => {
const home = await makeHome();
- const encodedPath = "-tmp-a-b-c";
- const dir = join(home, `.cursor/projects/${encodedPath}/agent-transcripts/cursor-session-1`);
+ const dir = join(home, ".cursor/projects/empty-window/agent-transcripts/cursor-session-1");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "transcript.jsonl"),
diff --git a/packages/server/src/__tests__/work-analysis-service.test.ts b/packages/server/src/__tests__/work-analysis-service.test.ts
index 6efabb858..1ea2e0081 100644
--- a/packages/server/src/__tests__/work-analysis-service.test.ts
+++ b/packages/server/src/__tests__/work-analysis-service.test.ts
@@ -48,10 +48,10 @@ describe("WorkAnalysisService", () => {
assistantTurnCount: 2,
toolUseCount: 1,
usage: {
- inputTokens: 800,
- outputTokens: 150,
+ inputTokens: 7_557_137_523,
+ outputTokens: 63_241_890,
reasoningOutputTokens: 50,
- totalTokens: 1_000,
+ totalTokens: 7_620_379_413,
},
usageCoverage: {
hasUsage: true,
@@ -164,11 +164,16 @@ describe("WorkAnalysisService", () => {
const dashboard = await service.refreshDashboard({ timeRange: { preset: "7d" } }, "manual");
expect(dashboard.scanState.status).toBe("succeeded");
- expect(dashboard.dashboard.kpis.find((item) => item.key === "totalTokens")?.value).toBe(1_500);
+ expect(dashboard.dashboard.kpis.find((item) => item.key === "totalTokens")?.value).toBe(
+ 7_620_379_913
+ );
+ expect(dashboard.dashboard.kpis.find((item) => item.key === "inputOutput")?.helper).toBe(
+ "7.56B input / 63.24M output"
+ );
expect(dashboard.dashboard.trends.tokenHourly).toEqual([
expect.objectContaining({
hourStart: Date.UTC(2026, 5, 1, 10),
- totalTokens: 1_000,
+ totalTokens: 7_620_379_413,
sessionCount: 1,
}),
expect.objectContaining({
@@ -1207,6 +1212,130 @@ describe("WorkAnalysisService", () => {
expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(500);
});
+ it("refreshes stale hourly index before returning a dashboard request", async () => {
+ let hourlyIndex: WorkAnalysisHourlyIndex = {
+ version: 1,
+ bucketMode: "hourly_session_slices",
+ indexedAt: Date.UTC(2026, 5, 7, 1, 30),
+ indexedThroughHourStart: Date.UTC(2026, 5, 7, 0),
+ sourceDigest: "hourly-index-stale",
+ providerStatuses: [
+ {
+ providerId: "codex",
+ status: "supported",
+ sessionCount: 1,
+ parseErrorCount: 0,
+ warningCount: 0,
+ },
+ ],
+ buckets: [
+ {
+ hourStart: Date.UTC(2026, 5, 7, 0),
+ sessions: [
+ {
+ providerId: "codex",
+ sessionId: "codex-old",
+ workspacePath: "/repo/app",
+ startedAt: Date.UTC(2026, 5, 7, 0, 10),
+ lastActiveAt: Date.UTC(2026, 5, 7, 0, 20),
+ sourceRef: "codex-old",
+ userTurnCount: 1,
+ assistantTurnCount: 1,
+ toolUseCount: 0,
+ usage: {
+ inputTokens: 100,
+ totalTokens: 100,
+ },
+ usageCoverage: {
+ hasUsage: true,
+ callCount: 1,
+ callsWithTotalTokens: 1,
+ estimatedCallCount: 0,
+ },
+ parseErrorCount: 0,
+ timestampQuality: "explicit",
+ events: [],
+ },
+ ],
+ },
+ ],
+ };
+ const collect = vi.fn(async () => ({
+ sourceDigest: "source-refreshed",
+ providers: [
+ {
+ providerId: "codex" as const,
+ status: "supported" as const,
+ sessions: [],
+ sourceRefs: [],
+ parseErrorCount: 0,
+ warnings: [],
+ },
+ ],
+ sessions: [
+ {
+ providerId: "codex" as const,
+ sessionId: "codex-new",
+ workspacePath: "/repo/app",
+ startedAt: Date.UTC(2026, 5, 7, 2, 10),
+ lastActiveAt: Date.UTC(2026, 5, 7, 2, 30),
+ sourceRef: "codex-new",
+ userTurnCount: 1,
+ assistantTurnCount: 1,
+ toolUseCount: 0,
+ usage: {
+ inputTokens: 200,
+ totalTokens: 200,
+ },
+ usageCoverage: {
+ hasUsage: true,
+ callCount: 1,
+ callsWithTotalTokens: 1,
+ estimatedCallCount: 0,
+ },
+ parseErrorCount: 0,
+ timestampQuality: "explicit" as const,
+ events: [],
+ },
+ ],
+ }));
+
+ const service = new WorkAnalysisService({
+ repo: {
+ findByQueryDigest: vi.fn(() => undefined),
+ upsert: vi.fn((record) => record),
+ findHourlyIndex: vi.fn(() => hourlyIndex),
+ upsertHourlyIndex: vi.fn((nextIndex) => {
+ hourlyIndex = nextIndex;
+ return nextIndex;
+ }),
+ },
+ workspaceMgr: { get: vi.fn() },
+ workLogCollector: { collect },
+ skillLibraryRepo: { list: vi.fn(() => []) },
+ skillMountRepo: { list: vi.fn(() => []) },
+ deepRunner: {
+ run: vi.fn(),
+ },
+ now: () => Date.UTC(2026, 5, 7, 3, 30),
+ });
+
+ const dashboard = await service.getDashboard({ timeRange: { preset: "24h" } });
+
+ expect(collect).toHaveBeenCalledWith({
+ workspacePaths: [],
+ timeRange: {
+ startAt: Date.UTC(2026, 5, 7, 1),
+ endAt: Date.UTC(2026, 5, 7, 3, 30),
+ label: "incremental",
+ },
+ });
+ expect(hourlyIndex.indexedThroughHourStart).toBe(Date.UTC(2026, 5, 7, 2));
+ expect(dashboard.scanState.mode).toBe("auto");
+ expect(dashboard.scanState.sourceDigest).toBe("source-refreshed");
+ expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(300);
+ });
+
it("serializes concurrent dashboard refreshes without returning another query result", async () => {
const firstCollection = createDeferred<{
sourceDigest: string;
diff --git a/packages/server/src/__tests__/workspace-commands.test.ts b/packages/server/src/__tests__/workspace-commands.test.ts
index 23bdf873e..95b66147b 100644
--- a/packages/server/src/__tests__/workspace-commands.test.ts
+++ b/packages/server/src/__tests__/workspace-commands.test.ts
@@ -832,6 +832,184 @@ describe("Workspace Commands", () => {
).toBe("src/app.tsx");
});
+ it("persists multiple browser editor tabs with duplicate urls", async () => {
+ const dir = join(tmpdir(), `workspace-browser-editors-test-${Date.now()}`);
+ await mkdir(dir);
+
+ const openResult = await dispatch(
+ {
+ kind: "command",
+ id: "open-workspace-browser-editors",
+ op: "workspace.open",
+ args: { path: dir },
+ },
+ ctx
+ );
+
+ expect(openResult.ok).toBe(true);
+ const workspaceId = (openResult.data as { id: string }).id;
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "set-ui-state-browser-editors",
+ op: "workspace.uiState.set",
+ args: {
+ workspaceId,
+ uiState: {
+ leftPanelWidth: 320,
+ bottomPanelHeight: 210,
+ focusMode: false,
+ openEditorTabs: [
+ {
+ kind: "browser",
+ id: "browser-1",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ },
+ {
+ kind: "browser",
+ id: "browser-2",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ },
+ ],
+ activeEditorTab: {
+ kind: "browser",
+ id: "browser-2",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ },
+ },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(
+ (result.data as { uiState: { openEditorTabs?: unknown[] } }).uiState.openEditorTabs
+ ).toEqual([
+ {
+ kind: "browser",
+ id: "browser-1",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ },
+ {
+ kind: "browser",
+ id: "browser-2",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ },
+ ]);
+ expect(
+ (result.data as { uiState: { activeEditorTab?: unknown } }).uiState.activeEditorTab
+ ).toEqual({
+ kind: "browser",
+ id: "browser-2",
+ url: "localhost:8001",
+ devicePreset: "desktop",
+ viewportWidth: null,
+ viewportHeight: null,
+ orientation: "portrait",
+ userAgentMode: "desktop",
+ });
+ });
+
+ it("accepts persisted browser device settings and returns them in open editor tabs", async () => {
+ const dir = join(tmpdir(), `workspace-browser-device-editors-test-${Date.now()}`);
+ await mkdir(dir);
+
+ const openResult = await dispatch(
+ {
+ kind: "command",
+ id: "open-workspace-browser-device-editors",
+ op: "workspace.open",
+ args: { path: dir },
+ },
+ ctx
+ );
+
+ expect(openResult.ok).toBe(true);
+ const workspaceId = (openResult.data as { id: string }).id;
+
+ const result = await dispatch(
+ {
+ kind: "command",
+ id: "set-ui-state-browser-device-editors",
+ op: "workspace.uiState.set",
+ args: {
+ workspaceId,
+ uiState: {
+ leftPanelWidth: 320,
+ bottomPanelHeight: 210,
+ focusMode: false,
+ openEditorTabs: [
+ {
+ kind: "browser",
+ id: "browser-1",
+ url: "localhost:8001",
+ devicePreset: "iphone-14",
+ viewportWidth: 390,
+ viewportHeight: 844,
+ orientation: "portrait",
+ userAgentMode: "mobile",
+ },
+ ],
+ activeEditorTab: {
+ kind: "browser",
+ id: "browser-1",
+ url: "localhost:8001",
+ devicePreset: "iphone-14",
+ viewportWidth: 390,
+ viewportHeight: 844,
+ orientation: "portrait",
+ userAgentMode: "mobile",
+ },
+ },
+ },
+ },
+ ctx
+ );
+
+ expect(result.ok).toBe(true);
+ expect(
+ (result.data as { uiState: { openEditorTabs?: unknown[] } }).uiState.openEditorTabs
+ ).toEqual([
+ {
+ kind: "browser",
+ id: "browser-1",
+ url: "localhost:8001",
+ devicePreset: "iphone-14",
+ viewportWidth: 390,
+ viewportHeight: 844,
+ orientation: "portrait",
+ userAgentMode: "mobile",
+ },
+ ]);
+ });
+
it("drops auto attach state while persisting other agent instruction ui state", async () => {
const dir = join(tmpdir(), `workspace-agent-instructions-ui-state-${Date.now()}`);
await mkdir(dir);
diff --git a/packages/server/src/__tests__/workspace-extension-state-commands.test.ts b/packages/server/src/__tests__/workspace-extension-state-commands.test.ts
deleted file mode 100644
index 52e9da31c..000000000
--- a/packages/server/src/__tests__/workspace-extension-state-commands.test.ts
+++ /dev/null
@@ -1,395 +0,0 @@
-import { mkdir, mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import type { WorkspaceExtensionStateView } from "@coder-studio/core";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { EventBus } from "../bus/event-bus.js";
-import { WorkspaceExtensionStateService } from "../extension-state/workspace-extension-state-service.js";
-import { WorkspaceExtensionStateRepo } from "../storage/repositories/workspace-extension-state-repo.js";
-import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js";
-import type { CommandContext } from "../ws/dispatch.js";
-import { dispatch } from "../ws/dispatch.js";
-import "../commands/workspace-extension-state.js";
-
-describe("workspace extension state commands", () => {
- let tempDir: string;
- let workspaceRepo: WorkspaceRepo;
- let eventBus: EventBus;
- let service: WorkspaceExtensionStateService;
- let ctx: CommandContext;
- let now = 1000;
- const changedEvents: Array<{ workspaceId: string; state: WorkspaceExtensionStateView }> = [];
-
- beforeEach(async () => {
- tempDir = await mkdtemp(join(tmpdir(), "workspace-extension-state-commands-"));
- const workspacePath = join(tempDir, "workspace");
- await mkdir(workspacePath, { recursive: true });
- workspaceRepo = new WorkspaceRepo({
- filePath: join(tempDir, "workspaces.json"),
- });
- workspaceRepo.create({
- id: "ws-1",
- path: workspacePath,
- targetRuntime: "native",
- openedAt: 1,
- lastActiveAt: 1,
- uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false },
- });
- eventBus = new EventBus();
- eventBus.on("workspace.extension_state.changed" as never, (event) => {
- changedEvents.push(event as { workspaceId: string; state: WorkspaceExtensionStateView });
- });
- service = new WorkspaceExtensionStateService({
- repo: new WorkspaceExtensionStateRepo({
- workspaceRepo,
- now: () => now,
- }),
- eventBus,
- now: () => now,
- });
- ctx = {
- workspaceMgr: { get: (workspaceId: string) => workspaceRepo.findById(workspaceId) },
- sessionMgr: {},
- terminalMgr: {},
- eventBus,
- broadcaster: {},
- settingsRepo: {},
- providerConfigRepo: {},
- providerRegistry: [],
- fencingMgr: {},
- supervisorMgr: {},
- autoFetch: {},
- activationMgr: { getLease: vi.fn(() => undefined) },
- lspMgr: {},
- workspaceExtensionStateService: service,
- } as unknown as CommandContext;
- });
-
- afterEach(async () => {
- changedEvents.length = 0;
- await rm(tempDir, { recursive: true, force: true });
- });
-
- it("sets and lists status, progress, log, and quick action contributions", async () => {
- await dispatch(
- {
- kind: "command",
- id: "status-set-1",
- op: "workspace.extensionState.statusPills.set",
- args: {
- workspaceId: "ws-1",
- key: "ci",
- label: "CI running",
- state: "running",
- detail: "unit tests",
- },
- },
- ctx
- );
- now = 1100;
- await dispatch(
- {
- kind: "command",
- id: "progress-set-1",
- op: "workspace.extensionState.progress.set",
- args: {
- workspaceId: "ws-1",
- key: "tests",
- label: "Tests",
- value: 42,
- max: 100,
- detail: "unit tests",
- },
- },
- ctx
- );
- now = 1200;
- await dispatch(
- {
- kind: "command",
- id: "log-append-1",
- op: "workspace.extensionState.logs.append",
- args: {
- workspaceId: "ws-1",
- key: "ci",
- message: "Unit tests started",
- level: "info",
- },
- },
- ctx
- );
- await dispatch(
- {
- kind: "command",
- id: "quick-action-set-1",
- op: "workspace.extensionState.quickActions.set",
- args: {
- workspaceId: "ws-1",
- id: "rerun-tests",
- label: "Rerun tests",
- command: "pnpm test",
- description: "Run the focused test suite again",
- },
- },
- ctx
- );
-
- const result = await dispatch(
- {
- kind: "command",
- id: "extension-state-list-1",
- op: "workspace.extensionState.list",
- args: { workspaceId: "ws-1" },
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(result.data).toMatchObject({
- workspaceId: "ws-1",
- statusPills: [
- {
- key: "ci",
- label: "CI running",
- state: "running",
- detail: "unit tests",
- updatedAt: 1000,
- },
- ],
- progress: [
- {
- key: "tests",
- label: "Tests",
- value: 42,
- max: 100,
- detail: "unit tests",
- updatedAt: 1100,
- },
- ],
- logs: [
- {
- key: "ci",
- level: "info",
- message: "Unit tests started",
- timestamp: 1200,
- },
- ],
- quickActions: [
- {
- id: "rerun-tests",
- label: "Rerun tests",
- command: "pnpm test",
- description: "Run the focused test suite again",
- },
- ],
- updatedAt: 1200,
- });
- expect(changedEvents).toHaveLength(4);
- expect(changedEvents.at(-1)).toMatchObject({
- workspaceId: "ws-1",
- state: expect.objectContaining({
- quickActions: [expect.objectContaining({ id: "rerun-tests" })],
- }),
- });
- });
-
- it("clears contributions and broadcasts the resulting workspace extension state", async () => {
- await service.setStatusPill({
- workspaceId: "ws-1",
- key: "ci",
- label: "CI running",
- state: "running",
- });
- await service.setProgress({
- workspaceId: "ws-1",
- key: "tests",
- label: "Tests",
- value: 1,
- });
- await service.appendLog({
- workspaceId: "ws-1",
- key: "ci",
- message: "Unit tests started",
- level: "info",
- });
- await service.setQuickAction({
- workspaceId: "ws-1",
- id: "rerun-tests",
- label: "Rerun tests",
- command: "pnpm test",
- });
- changedEvents.length = 0;
-
- await dispatch(
- {
- kind: "command",
- id: "status-clear-1",
- op: "workspace.extensionState.statusPills.clear",
- args: { workspaceId: "ws-1", key: "ci" },
- },
- ctx
- );
- await dispatch(
- {
- kind: "command",
- id: "progress-clear-1",
- op: "workspace.extensionState.progress.clear",
- args: { workspaceId: "ws-1", key: "tests" },
- },
- ctx
- );
- await dispatch(
- {
- kind: "command",
- id: "logs-clear-1",
- op: "workspace.extensionState.logs.clear",
- args: { workspaceId: "ws-1", key: "ci" },
- },
- ctx
- );
- const result = await dispatch(
- {
- kind: "command",
- id: "quick-action-clear-1",
- op: "workspace.extensionState.quickActions.clear",
- args: { workspaceId: "ws-1", id: "rerun-tests" },
- },
- ctx
- );
-
- expect(result.ok).toBe(true);
- expect(result.data).toMatchObject({
- workspaceId: "ws-1",
- statusPills: [],
- progress: [],
- logs: [],
- quickActions: [],
- });
- expect(changedEvents).toHaveLength(4);
- expect(changedEvents.at(-1)?.state.quickActions).toEqual([]);
- });
-
- it("lists individual workspace extension state contribution categories", async () => {
- service.setStatusPill({
- workspaceId: "ws-1",
- key: "ci",
- label: "CI running",
- state: "running",
- });
- service.setProgress({
- workspaceId: "ws-1",
- key: "tests",
- label: "Tests",
- value: 1,
- });
- service.appendLog({
- workspaceId: "ws-1",
- key: "ci",
- message: "Unit tests started",
- level: "info",
- });
- service.setQuickAction({
- workspaceId: "ws-1",
- id: "rerun-tests",
- label: "Rerun tests",
- command: "pnpm test",
- });
-
- await expect(
- dispatch(
- {
- kind: "command",
- id: "status-pills-list-1",
- op: "workspace.extensionState.statusPills.list",
- args: { workspaceId: "ws-1" },
- },
- ctx
- )
- ).resolves.toMatchObject({
- ok: true,
- data: [expect.objectContaining({ key: "ci" })],
- });
- await expect(
- dispatch(
- {
- kind: "command",
- id: "progress-list-1",
- op: "workspace.extensionState.progress.list",
- args: { workspaceId: "ws-1" },
- },
- ctx
- )
- ).resolves.toMatchObject({
- ok: true,
- data: [expect.objectContaining({ key: "tests" })],
- });
- await expect(
- dispatch(
- {
- kind: "command",
- id: "logs-list-1",
- op: "workspace.extensionState.logs.list",
- args: { workspaceId: "ws-1" },
- },
- ctx
- )
- ).resolves.toMatchObject({
- ok: true,
- data: [expect.objectContaining({ key: "ci" })],
- });
- await expect(
- dispatch(
- {
- kind: "command",
- id: "quick-actions-list-1",
- op: "workspace.extensionState.quickActions.list",
- args: { workspaceId: "ws-1" },
- },
- ctx
- )
- ).resolves.toMatchObject({
- ok: true,
- data: [expect.objectContaining({ id: "rerun-tests" })],
- });
- });
-
- it("allows workspace extension state commands from WebSocket callers without an activation lease", async () => {
- const result = await dispatch(
- {
- kind: "command",
- id: "extension-state-ws-1",
- op: "workspace.extensionState.statusPills.set",
- args: {
- workspaceId: "ws-1",
- key: "ci",
- label: "CI running",
- state: "running",
- },
- },
- ctx,
- "script-client"
- );
-
- expect(result.ok).toBe(true);
- expect(ctx.activationMgr.getLease).not.toHaveBeenCalled();
- });
-
- it("returns a typed unavailable error when the service is not configured", async () => {
- const result = await dispatch(
- {
- kind: "command",
- id: "extension-state-unavailable-1",
- op: "workspace.extensionState.list",
- args: { workspaceId: "ws-1" },
- },
- {
- ...ctx,
- workspaceExtensionStateService: undefined,
- },
- "script-client"
- );
-
- expect(result.ok).toBe(false);
- expect(result.error?.code).toBe("workspace_extension_state_unavailable");
- });
-});
diff --git a/packages/server/src/__tests__/workspace-extension-state-repo.test.ts b/packages/server/src/__tests__/workspace-extension-state-repo.test.ts
deleted file mode 100644
index f455c6fb1..000000000
--- a/packages/server/src/__tests__/workspace-extension-state-repo.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { WorkspaceExtensionStateRepo } from "../storage/repositories/workspace-extension-state-repo.js";
-import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js";
-
-describe("WorkspaceExtensionStateRepo", () => {
- let tempDir: string;
- let workspacePath: string;
- let workspaceRepo: WorkspaceRepo;
- let repo: WorkspaceExtensionStateRepo;
- let now = 1000;
-
- beforeEach(async () => {
- tempDir = await mkdtemp(join(tmpdir(), "workspace-extension-state-repo-"));
- workspacePath = join(tempDir, "workspace");
- await mkdir(workspacePath, { recursive: true });
- workspaceRepo = new WorkspaceRepo({
- filePath: join(tempDir, "workspaces.json"),
- });
- workspaceRepo.create({
- id: "ws-1",
- path: workspacePath,
- targetRuntime: "native",
- openedAt: 1,
- lastActiveAt: 1,
- uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false },
- });
- repo = new WorkspaceExtensionStateRepo({
- workspaceRepo,
- now: () => now,
- });
- });
-
- afterEach(async () => {
- await rm(tempDir, { recursive: true, force: true });
- });
-
- it("returns an empty workspace-scoped state before any contributions exist", () => {
- expect(repo.get("ws-1")).toEqual({
- workspaceId: "ws-1",
- statusPills: [],
- progress: [],
- logs: [],
- quickActions: [],
- updatedAt: 1000,
- });
- });
-
- it("persists workspace extension state under the workspace state directory", async () => {
- repo.save({
- workspaceId: "ws-1",
- statusPills: [
- {
- key: "ci",
- label: "CI running",
- state: "running",
- detail: "unit tests",
- updatedAt: 1100,
- },
- ],
- progress: [],
- logs: [],
- quickActions: [],
- updatedAt: 1100,
- });
-
- const filePath = join(workspacePath, ".coder-studio", "extension-state.json");
- await expect(stat(filePath)).resolves.toBeDefined();
- await expect(readFile(filePath, "utf8").then((raw) => JSON.parse(raw))).resolves.toMatchObject({
- version: 1,
- state: {
- workspaceId: "ws-1",
- statusPills: [
- {
- key: "ci",
- label: "CI running",
- state: "running",
- detail: "unit tests",
- updatedAt: 1100,
- },
- ],
- updatedAt: 1100,
- },
- });
-
- now = 9999;
- const reloaded = new WorkspaceExtensionStateRepo({
- workspaceRepo,
- now: () => now,
- });
- expect(reloaded.get("ws-1").statusPills).toEqual([
- {
- key: "ci",
- label: "CI running",
- state: "running",
- detail: "unit tests",
- updatedAt: 1100,
- },
- ]);
- expect(reloaded.get("ws-1").updatedAt).toBe(1100);
- });
-});
diff --git a/packages/server/src/__tests__/ws-hub.test.ts b/packages/server/src/__tests__/ws-hub.test.ts
index 93e84a4ac..dd1721f50 100644
--- a/packages/server/src/__tests__/ws-hub.test.ts
+++ b/packages/server/src/__tests__/ws-hub.test.ts
@@ -4,7 +4,6 @@
import type { Result, ServerToClient, Session, Workspace } from "@coder-studio/core";
import {
- createEmptyWorkspaceExtensionStateView,
decodeTerminalOutputFrame,
TERMINAL_BINARY_HEADER_SIZE,
TERMINAL_BINARY_OUTPUT_VERSION,
@@ -373,25 +372,6 @@ describe("WsHub", () => {
});
});
- it("broadcasts workspace extension state updates on the workspace extension topic", () => {
- const socket = createMockSocket();
- hub.handleConnection(socket as never, createMockRequest());
- subscribeToAllTopics(socket);
-
- const state = createEmptyWorkspaceExtensionStateView("workspace-42", { now: () => 1234 });
- eventBus.emit({
- type: "workspace.extension_state.changed",
- workspaceId: "workspace-42",
- state,
- } as never);
-
- expect(getLastSentEvent(socket)).toMatchObject({
- kind: "event",
- topic: Topics.workspaceExtensionState("workspace-42"),
- data: state,
- });
- });
-
it("should translate terminal.created events to the terminal created topic and payload", () => {
const socket = createMockSocket();
hub.handleConnection(socket as never, createMockRequest());
@@ -620,78 +600,6 @@ describe("WsHub", () => {
);
});
- it("re-emits current workspace extension state on resync for subscribed topics", () => {
- hub.destroy();
- const workspace: Workspace = {
- id: "ws1",
- path: "/tmp/ws1",
- targetRuntime: "native",
- openedAt: 1,
- lastActiveAt: 1,
- uiState: { leftPanelWidth: 320, bottomPanelHeight: 240, focusMode: false },
- };
- const state = {
- ...createEmptyWorkspaceExtensionStateView("ws1", { now: () => 1234 }),
- statusPills: [
- {
- key: "ci",
- label: "CI running",
- state: "running" as const,
- updatedAt: 1234,
- },
- ],
- };
- const getExtensionState = vi.fn(() => state);
- const resyncContext = {
- ...mockCommandContext,
- workspaceMgr: {
- list: vi.fn().mockReturnValue([workspace]),
- } as unknown as WorkspaceManager,
- sessionMgr: {
- getForWorkspace: vi.fn().mockReturnValue([]),
- } as unknown as SessionManager,
- workspaceExtensionStateService: {
- get: getExtensionState,
- },
- } as CommandContext;
- hub = createHub(eventBus, resyncContext);
-
- const socket = createMockSocket();
- hub.handleConnection(socket as never, createMockRequest());
- const messageHandler = getMessageHandler(socket);
- socket.send.mockClear();
-
- messageHandler?.(
- Buffer.from(
- JSON.stringify({
- kind: "subscribe",
- topics: [Topics.workspaceExtensionState("ws1")],
- })
- )
- );
- messageHandler?.(
- Buffer.from(
- JSON.stringify({
- kind: "resync",
- lastSeen: {
- [Topics.workspaceExtensionState("ws1")]: 4,
- },
- })
- )
- );
-
- expect(getExtensionState).toHaveBeenCalledWith("ws1");
- expect(parseSentEvents(socket)).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- kind: "event",
- topic: Topics.workspaceExtensionState("ws1"),
- data: state,
- }),
- ])
- );
- });
-
it("should close all connections on destroy", () => {
const socket = createMockSocket();
hub.handleConnection(socket as never, createMockRequest());
diff --git a/packages/server/src/agent-instructions/agent-generator.ts b/packages/server/src/agent-instructions/agent-generator.ts
index d6565818e..f90e3ae22 100644
--- a/packages/server/src/agent-instructions/agent-generator.ts
+++ b/packages/server/src/agent-instructions/agent-generator.ts
@@ -89,6 +89,7 @@ export class AgentInstructionsGenerator {
env: { ...process.env, ...command.env },
windowsHide: true,
timeoutMs: AGENT_INSTRUCTIONS_GENERATION_TIMEOUT_MS,
+ prompt,
});
const replyText = extractAgentInstructionsReplyText(provider.id, stdout);
const content = parseGeneratedAgentInstructionsPayload(replyText);
diff --git a/packages/server/src/app-routing.test.ts b/packages/server/src/app-routing.test.ts
index 42a2cbf4c..65c533ccd 100644
--- a/packages/server/src/app-routing.test.ts
+++ b/packages/server/src/app-routing.test.ts
@@ -41,8 +41,11 @@ describe("app routing", () => {
const createApp = async (
authEnabled = false,
- extraConfig: Record = {}
+ extraConfig: Record = {},
+ options?: { webRoot?: string | null }
): Promise => {
+ const effectiveWebRoot =
+ options?.webRoot === undefined ? webRoot : (options.webRoot ?? undefined);
const eventBus = new EventBus();
const fencingMgr = new FencingManager();
const config = {
@@ -51,7 +54,7 @@ describe("app routing", () => {
stateDir,
uploadsDir: join(tempDir, "uploads"),
logLevel: "info" as const,
- webRoot,
+ webRoot: effectiveWebRoot,
auth: {
enabled: authEnabled,
password: authEnabled ? "sekrit" : undefined,
@@ -67,7 +70,7 @@ describe("app routing", () => {
app = await buildFastifyApp({
wsHub,
- webRoot,
+ webRoot: effectiveWebRoot,
workspaceMgr: new WorkspaceManager({
workspaceRepo: new WorkspaceRepo({
filePath: join(tempDir, "state", "workspaces.json"),
@@ -238,6 +241,34 @@ describe("app routing", () => {
expect(response.body).not.toContain("");
});
+ it("registers dev browser session API routes", async () => {
+ const instance = await createApp();
+
+ const response = await instance.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: "http://example.com:8000" },
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.json()).toMatchObject({ error: "invalid_dev_browser_target" });
+ });
+
+ it("does not serve index.html for dev browser proxy paths", async () => {
+ const instance = await createApp();
+
+ const response = await instance.inject({
+ method: "GET",
+ url: "/dev-browser/session/missing/proxy/app.js",
+ headers: {
+ accept: "application/javascript",
+ },
+ });
+
+ expect(response.statusCode).toBe(404);
+ expect(response.body).not.toContain("");
+ });
+
it("treats root static files as public even when auth is enabled", async () => {
const instance = await createApp(true);
@@ -249,4 +280,43 @@ describe("app routing", () => {
expect(response.statusCode).toBe(200);
expect(response.body).toBe("fake-wave");
});
+
+ it("serves the dev browser service worker when a web root is configured", async () => {
+ const instance = await createApp();
+
+ const response = await instance.inject({
+ method: "GET",
+ url: "/dev-browser-sw.js",
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("javascript");
+ expect(response.body).toContain("coder-studio-dev-browser-session");
+ });
+
+ it("serves the dev browser service worker without a configured web root", async () => {
+ const instance = await createApp(false, {}, { webRoot: null });
+
+ const response = await instance.inject({
+ method: "GET",
+ url: "/dev-browser-sw.js",
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("javascript");
+ expect(response.body).toContain("coder-studio-dev-browser-session");
+ });
+
+ it("treats the dev browser service worker as public when auth is enabled", async () => {
+ const instance = await createApp(true, {}, { webRoot: null });
+
+ const response = await instance.inject({
+ method: "GET",
+ url: "/dev-browser-sw.js",
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("javascript");
+ expect(response.body).toContain("coder-studio-dev-browser-session");
+ });
});
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 91c8a2f8d..c633083ff 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -4,7 +4,9 @@
* Builds the Fastify application with all routes and middleware
*/
-import { join, resolve } from "node:path";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
import { IN_MEMORY_STATE_DIR } from "@coder-studio/core/state-paths";
import compress from "@fastify/compress";
import cors from "@fastify/cors";
@@ -22,6 +24,7 @@ import {
import type { ServerConfig } from "./config.js";
import { PreviewSessionStore } from "./preview/session-store.js";
import { registerAppearanceAssetsRoutes } from "./routes/appearance-assets.js";
+import { registerDevBrowserRoutes } from "./routes/dev-browser.js";
import { registerFileAssetRoutes } from "./routes/file-asset.js";
import { registerPreviewRoutes } from "./routes/preview.js";
import { registerUploadsRoute } from "./routes/uploads.js";
@@ -47,6 +50,22 @@ interface AppDeps {
logger?: FastifyServerOptions["logger"];
}
+const SERVER_SRC_DIR = dirname(fileURLToPath(import.meta.url));
+const DEV_BROWSER_SW_FALLBACK_PATH = resolve(SERVER_SRC_DIR, "../../web/public/dev-browser-sw.js");
+
+function resolveDevBrowserServiceWorkerPath(webRoot?: string): string | null {
+ const webRootPath = webRoot ? join(webRoot, "dev-browser-sw.js") : null;
+ if (webRootPath && existsSync(webRootPath)) {
+ return webRootPath;
+ }
+
+ if (existsSync(DEV_BROWSER_SW_FALLBACK_PATH)) {
+ return DEV_BROWSER_SW_FALLBACK_PATH;
+ }
+
+ return null;
+}
+
/**
* Build Fastify application
*/
@@ -74,25 +93,22 @@ export async function buildFastifyApp(deps: AppDeps): Promise {
},
});
- // WebSocket plugin - routes must be registered within this scope
- await app.register(async function (fastify) {
- await fastify.register(websocket, {
- options: {
- // permessage-deflate: terminal ANSI streams (repeated escape codes,
- // whitespace, color sequences) typically compress 5-10x. Cross-message
- // context takeover is left enabled (default) so the zlib dictionary
- // persists across frames for highest ratio on continuous streams.
- perMessageDeflate: {
- threshold: 1024,
- zlibDeflateOptions: { level: 6 },
- },
+ await app.register(websocket, {
+ options: {
+ // permessage-deflate: terminal ANSI streams (repeated escape codes,
+ // whitespace, color sequences) typically compress 5-10x. Cross-message
+ // context takeover is left enabled (default) so the zlib dictionary
+ // persists across frames for highest ratio on continuous streams.
+ perMessageDeflate: {
+ threshold: 1024,
+ zlibDeflateOptions: { level: 6 },
},
- });
+ },
+ });
- // WebSocket endpoint - connection is the WebSocket directly in v11+
- fastify.get("/ws", { websocket: true }, (connection: WebSocket, req: FastifyRequest) => {
- deps.wsHub.handleConnection(connection, req);
- });
+ // WebSocket endpoint - connection is the WebSocket directly in v11+
+ app.get("/ws", { websocket: true }, (connection: WebSocket, req: FastifyRequest) => {
+ deps.wsHub.handleConnection(connection, req);
});
// Phase 2: Configurable auth middleware
@@ -167,6 +183,8 @@ export async function buildFastifyApp(deps: AppDeps): Promise {
repo: appearanceAssetRepo,
});
+ registerDevBrowserRoutes(app);
+
const previewSessions = new PreviewSessionStore();
registerPreviewRoutes(app, {
workspaceMgr: deps.workspaceMgr,
@@ -178,6 +196,17 @@ export async function buildFastifyApp(deps: AppDeps): Promise {
workspaceMgr: deps.workspaceMgr,
});
+ app.get("/dev-browser-sw.js", async (_request, reply) => {
+ const scriptPath = resolveDevBrowserServiceWorkerPath(deps.webRoot);
+ if (!scriptPath) {
+ return reply.callNotFound();
+ }
+
+ const script = readFileSync(scriptPath, "utf8");
+ reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
+ return reply.type("application/javascript; charset=utf-8").send(script);
+ });
+
// Static file serving (for web UI)
if (deps.webRoot) {
app.register(staticPlugin, {
diff --git a/packages/server/src/commands/index.ts b/packages/server/src/commands/index.ts
index 44ed379cc..e7eb49481 100644
--- a/packages/server/src/commands/index.ts
+++ b/packages/server/src/commands/index.ts
@@ -6,8 +6,8 @@
import "./workspace.js";
import "./workspace-activity.js";
-import "./workspace-extension-state.js";
import "./automation.js";
+import "./ui-actions.js";
import "./activation.js";
import "./connection.js";
import "./recovery.js";
@@ -33,3 +33,4 @@ import "./lsp.js";
import "./updates.js";
import "./monitoring.js";
import "./work-analysis.js";
+import "./memory.js";
diff --git a/packages/server/src/commands/memory.test.ts b/packages/server/src/commands/memory.test.ts
new file mode 100644
index 000000000..f2f9246aa
--- /dev/null
+++ b/packages/server/src/commands/memory.test.ts
@@ -0,0 +1,421 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { WorkspaceMemoryEntry } from "@coder-studio/core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MemoryRepo } from "../storage/repositories/memory-repo.js";
+import { type CommandContext, dispatch } from "../ws/dispatch.js";
+import "./index.js";
+
+function command(op: string, args: unknown) {
+ return {
+ kind: "command" as const,
+ id: `${op}-test`,
+ op,
+ args,
+ };
+}
+
+describe("memory commands", () => {
+ let tempDir: string;
+ let now: number;
+ let random: number;
+ let broadcast: ReturnType;
+ let ctx: CommandContext;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), "memory-command-"));
+ now = 1779120000000;
+ random = 0;
+ broadcast = vi.fn();
+ ctx = {
+ workspaceMgr: {
+ get: vi.fn((workspaceId: string) =>
+ workspaceId === "ws-1" ? { id: "ws-1", path: "/repo" } : undefined
+ ),
+ },
+ sessionMgr: {},
+ terminalMgr: {},
+ taskMgr: {},
+ eventBus: {},
+ broadcaster: { broadcast },
+ settingsRepo: {},
+ providerConfigRepo: {},
+ providerRegistry: [],
+ fencingMgr: {},
+ supervisorMgr: {},
+ autoFetch: {},
+ activationMgr: {},
+ lspMgr: {},
+ memoryRepo: new MemoryRepo({
+ rootDir: join(tempDir, "memory", "workspaces"),
+ now: () => now,
+ randomId: () => `r${++random}`,
+ }),
+ } as unknown as CommandContext;
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("creates, lists, searches, gets, updates, and deletes workspace memory entries", async () => {
+ const createdResult = await dispatch(
+ command("memory.create", {
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Keep durable context outside the Git workspace.",
+ sourceHint: {
+ kind: "skill",
+ skillSlug: "coder-studio-memory",
+ sessionId: "session-1",
+ },
+ }),
+ ctx
+ );
+
+ expect(createdResult.ok).toBe(true);
+ const created = createdResult.data as WorkspaceMemoryEntry;
+ expect(created).toEqual({
+ id: "mem_1779120000000_r1",
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Keep durable context outside the Git workspace.",
+ source: {
+ kind: "skill",
+ skillSlug: "coder-studio-memory",
+ sessionId: "session-1",
+ },
+ createdAt: now,
+ updatedAt: now,
+ });
+ expect(broadcast).toHaveBeenCalledWith("workspace.ws-1.memory.changed", {
+ workspaceId: "ws-1",
+ entryId: created.id,
+ action: "created",
+ });
+ const persistedRepo = new MemoryRepo({
+ rootDir: join(tempDir, "memory", "workspaces"),
+ now: () => now,
+ randomId: () => `r${++random}`,
+ });
+ expect(persistedRepo.get("ws-1", created.id)).toEqual(created);
+
+ const listResult = await dispatch(command("memory.list", { workspaceId: "ws-1" }), ctx);
+ expect(listResult.ok).toBe(true);
+ expect(listResult.data).toEqual([created]);
+
+ const typeSearchResult = await dispatch(
+ command("memory.search", { workspaceId: "ws-1", query: "PROJECT" }),
+ ctx
+ );
+ expect(typeSearchResult.ok).toBe(true);
+ expect(typeSearchResult.data).toEqual([created]);
+
+ const contentSearchResult = await dispatch(
+ command("memory.search", { workspaceId: "ws-1", query: "durable" }),
+ ctx
+ );
+ expect(contentSearchResult.ok).toBe(true);
+ expect(contentSearchResult.data).toEqual([created]);
+
+ const sourceSearchResult = await dispatch(
+ command("memory.search", { workspaceId: "ws-1", query: "coder-studio-memory" }),
+ ctx
+ );
+ expect(sourceSearchResult.ok).toBe(true);
+ expect(sourceSearchResult.data).toEqual([]);
+
+ const getResult = await dispatch(
+ command("memory.get", { workspaceId: "ws-1", id: created.id }),
+ ctx
+ );
+ expect(getResult.ok).toBe(true);
+ expect(getResult.data).toEqual(created);
+
+ now += 1000;
+ const updatedResult = await dispatch(
+ command("memory.update", {
+ workspaceId: "ws-1",
+ id: created.id,
+ type: "note",
+ content: "Keep one structured JSON file per workspace.",
+ }),
+ ctx
+ );
+ expect(updatedResult.ok).toBe(true);
+ const updated = updatedResult.data as WorkspaceMemoryEntry;
+ expect(updated).toEqual({
+ id: created.id,
+ workspaceId: "ws-1",
+ type: "note",
+ content: "Keep one structured JSON file per workspace.",
+ source: {
+ kind: "skill",
+ skillSlug: "coder-studio-memory",
+ sessionId: "session-1",
+ },
+ createdAt: created.createdAt,
+ updatedAt: now,
+ });
+ expect(broadcast).toHaveBeenCalledWith("workspace.ws-1.memory.changed", {
+ workspaceId: "ws-1",
+ entryId: created.id,
+ action: "updated",
+ });
+
+ now += 1000;
+ const deleteResult = await dispatch(
+ command("memory.delete", { workspaceId: "ws-1", id: created.id }),
+ ctx
+ );
+ expect(deleteResult.ok).toBe(true);
+ expect(deleteResult.data).toMatchObject({ id: created.id, archivedAt: now });
+ expect(broadcast).toHaveBeenCalledWith("workspace.ws-1.memory.changed", {
+ workspaceId: "ws-1",
+ entryId: created.id,
+ action: "deleted",
+ });
+
+ const hiddenResult = await dispatch(command("memory.list", { workspaceId: "ws-1" }), ctx);
+ expect(hiddenResult.ok).toBe(true);
+ expect(hiddenResult.data).toEqual([]);
+ });
+
+ it("skips unsupported legacy entries through the command layer and rewrites only new taxonomy entries", async () => {
+ const workspaceId = "ws-1";
+ const filePath = join(tempDir, "memory", "workspaces", `${workspaceId}.json`);
+ mkdirSync(join(tempDir, "memory", "workspaces"), { recursive: true });
+ writeFileSync(
+ filePath,
+ JSON.stringify(
+ {
+ version: 1,
+ workspaceId,
+ entries: {
+ "mem-legacy": {
+ id: "mem-legacy",
+ workspaceId,
+ type: "decision",
+ title: "Legacy title",
+ content: "Legacy content to skip.",
+ tags: ["legacy"],
+ source: { kind: "user" },
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ },
+ },
+ null,
+ 2
+ ) + "\n"
+ );
+
+ const getResult = await dispatch(command("memory.get", { workspaceId, id: "mem-legacy" }), ctx);
+ expect(getResult.ok).toBe(false);
+ expect(getResult.error?.code).toBe("memory_not_found");
+
+ const searchResult = await dispatch(
+ command("memory.search", { workspaceId, query: "legacy" }),
+ ctx
+ );
+ expect(searchResult.ok).toBe(true);
+ expect(searchResult.data).toEqual([]);
+
+ now += 1000;
+ const createResult = await dispatch(
+ command("memory.create", {
+ workspaceId,
+ type: "project",
+ content: "Persist only project-shaped memory records.",
+ }),
+ ctx
+ );
+ expect(createResult.ok).toBe(true);
+ const created = createResult.data as WorkspaceMemoryEntry;
+ expect(created).toEqual({
+ id: "mem_1779120001000_r1",
+ workspaceId,
+ type: "project",
+ content: "Persist only project-shaped memory records.",
+ source: { kind: "user" },
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({
+ version: 1,
+ workspaceId,
+ entries: {
+ [created.id]: {
+ id: created.id,
+ workspaceId,
+ type: "project",
+ content: "Persist only project-shaped memory records.",
+ source: { kind: "user" },
+ createdAt: now,
+ updatedAt: now,
+ },
+ },
+ });
+ });
+
+ it("returns workspace_not_found before touching memory storage", async () => {
+ const result = await dispatch(command("memory.list", { workspaceId: "missing" }), ctx);
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("workspace_not_found");
+ });
+
+ it("returns memory_storage_unavailable when the repository is not wired", async () => {
+ const { memoryRepo: _repo, ...missingRepoCtx } = ctx;
+
+ const result = await dispatch(
+ command("memory.list", { workspaceId: "ws-1" }),
+ missingRepoCtx as CommandContext
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("memory_storage_unavailable");
+ });
+
+ it("returns memory_not_found for unknown ids", async () => {
+ const result = await dispatch(
+ command("memory.get", { workspaceId: "ws-1", id: "mem_missing" }),
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("memory_not_found");
+ });
+
+ it("returns validation_error for invalid input", async () => {
+ const result = await dispatch(
+ command("memory.create", {
+ workspaceId: "ws-1",
+ type: "decision",
+ content: "",
+ }),
+ ctx
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.error?.code).toBe("validation_error");
+ });
+
+ it("returns validation_error for legacy tag arguments", async () => {
+ const createdResult = await dispatch(
+ command("memory.create", {
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Keep project rules durable.",
+ }),
+ ctx
+ );
+
+ expect(createdResult.ok).toBe(true);
+ const created = createdResult.data as WorkspaceMemoryEntry;
+
+ const createWithTags = await dispatch(
+ command("memory.create", {
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Legacy tag payload should be rejected.",
+ tags: ["legacy"],
+ }),
+ ctx
+ );
+ expect(createWithTags.ok).toBe(false);
+ expect(createWithTags.error?.code).toBe("validation_error");
+
+ const updateWithTags = await dispatch(
+ command("memory.update", {
+ workspaceId: "ws-1",
+ id: created.id,
+ tags: ["legacy"],
+ }),
+ ctx
+ );
+ expect(updateWithTags.ok).toBe(false);
+ expect(updateWithTags.error?.code).toBe("validation_error");
+
+ const listWithTag = await dispatch(
+ command("memory.list", {
+ workspaceId: "ws-1",
+ tag: "legacy",
+ }),
+ ctx
+ );
+ expect(listWithTag.ok).toBe(false);
+ expect(listWithTag.error?.code).toBe("validation_error");
+
+ const searchWithTag = await dispatch(
+ command("memory.search", {
+ workspaceId: "ws-1",
+ query: "project",
+ tag: "legacy",
+ }),
+ ctx
+ );
+ expect(searchWithTag.ok).toBe(false);
+ expect(searchWithTag.error?.code).toBe("validation_error");
+ });
+
+ it("ignores unrelated unknown arguments for memory commands", async () => {
+ const createdResult = await dispatch(
+ command("memory.create", {
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Keep project rules durable.",
+ extraField: "ignore me",
+ }),
+ ctx
+ );
+
+ expect(createdResult.ok).toBe(true);
+ const created = createdResult.data as WorkspaceMemoryEntry;
+ expect(created).toMatchObject({
+ workspaceId: "ws-1",
+ type: "project",
+ content: "Keep project rules durable.",
+ });
+
+ const updateResult = await dispatch(
+ command("memory.update", {
+ workspaceId: "ws-1",
+ id: created.id,
+ content: "Keep updated project rules durable.",
+ extraField: "ignore me too",
+ }),
+ ctx
+ );
+
+ expect(updateResult.ok).toBe(true);
+ expect(updateResult.data).toMatchObject({
+ id: created.id,
+ content: "Keep updated project rules durable.",
+ });
+
+ const listResult = await dispatch(
+ command("memory.list", {
+ workspaceId: "ws-1",
+ extraField: "ignore me as well",
+ }),
+ ctx
+ );
+
+ expect(listResult.ok).toBe(true);
+ expect(listResult.data).toEqual([updateResult.data]);
+
+ const searchResult = await dispatch(
+ command("memory.search", {
+ workspaceId: "ws-1",
+ query: "updated",
+ extraField: "still ignored",
+ }),
+ ctx
+ );
+ expect(searchResult.ok).toBe(true);
+ expect(searchResult.data).toEqual([updateResult.data]);
+ });
+});
diff --git a/packages/server/src/commands/memory.ts b/packages/server/src/commands/memory.ts
new file mode 100644
index 000000000..0816fd72c
--- /dev/null
+++ b/packages/server/src/commands/memory.ts
@@ -0,0 +1,176 @@
+import { WORKSPACE_MEMORY_SOURCE_KINDS, WORKSPACE_MEMORY_TYPES } from "@coder-studio/core";
+import { z } from "zod";
+import { type CommandContext, registerCommand } from "../ws/dispatch.js";
+
+const memoryTypeSchema = z.enum(WORKSPACE_MEMORY_TYPES);
+const memorySourceKindSchema = z.enum(WORKSPACE_MEMORY_SOURCE_KINDS);
+
+const workspaceSchema = z.object({
+ workspaceId: z.string().min(1),
+});
+
+const legacyTagArgGuard = z.unknown().superRefine((input, ctx) => {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return;
+ }
+
+ const keys = ["tag", "tags"].filter((key) => Object.hasOwn(input, key));
+ if (keys.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Legacy memory tag arguments are not supported: ${keys.join(", ")}`,
+ });
+ }
+});
+
+function rejectLegacyTagArgs(schema: T) {
+ return legacyTagArgGuard.pipe(schema);
+}
+
+const memoryListBaseSchema = workspaceSchema.extend({
+ query: z.string().optional(),
+ type: memoryTypeSchema.optional(),
+ includeArchived: z.boolean().optional(),
+});
+const memoryListSchema = rejectLegacyTagArgs(memoryListBaseSchema);
+const memorySearchSchema = rejectLegacyTagArgs(memoryListBaseSchema.required({ query: true }));
+
+const memoryIdSchema = workspaceSchema.extend({
+ id: z.string().min(1),
+});
+
+const sourceHintSchema = z
+ .object({
+ kind: memorySourceKindSchema.optional(),
+ providerId: z.string().trim().min(1).optional(),
+ sessionId: z.string().trim().min(1).optional(),
+ skillSlug: z.string().trim().min(1).optional(),
+ })
+ .strict();
+
+const memoryCreateSchema = rejectLegacyTagArgs(
+ workspaceSchema.extend({
+ type: memoryTypeSchema,
+ content: z.string().trim().min(1).max(20_000),
+ sourceHint: sourceHintSchema.optional(),
+ })
+);
+
+const memoryUpdateSchema = rejectLegacyTagArgs(
+ memoryIdSchema.extend({
+ type: memoryTypeSchema.optional(),
+ content: z.string().trim().min(1).max(20_000).optional(),
+ })
+);
+
+type MemorySourceHint = z.output;
+type MemoryChangeAction = "created" | "updated" | "deleted";
+
+function getWorkspaceOrThrow(ctx: CommandContext, workspaceId: string) {
+ const workspace = ctx.workspaceMgr.get(workspaceId);
+ if (!workspace) {
+ throw { code: "workspace_not_found", message: `Workspace not found: ${workspaceId}` };
+ }
+ return workspace;
+}
+
+function getMemoryRepoOrThrow(ctx: CommandContext) {
+ if (!ctx.memoryRepo) {
+ throw {
+ code: "memory_storage_unavailable",
+ message: "Workspace memory storage is not available",
+ };
+ }
+ return ctx.memoryRepo;
+}
+
+function notFound(id: string): never {
+ throw { code: "memory_not_found", message: `Memory entry not found: ${id}` };
+}
+
+function defaultSourceKind(sourceHint: MemorySourceHint | undefined) {
+ if (sourceHint?.kind) {
+ return sourceHint.kind;
+ }
+
+ if (sourceHint?.skillSlug) {
+ return "skill";
+ }
+
+ if (sourceHint?.providerId || sourceHint?.sessionId) {
+ return "agent";
+ }
+
+ return "user";
+}
+
+function broadcastMemoryChanged(
+ ctx: CommandContext,
+ workspaceId: string,
+ entryId: string,
+ action: MemoryChangeAction
+): void {
+ ctx.broadcaster.broadcast?.(`workspace.${workspaceId}.memory.changed`, {
+ workspaceId,
+ entryId,
+ action,
+ });
+}
+
+registerCommand("memory.list", memoryListSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ return memoryRepo.list(args);
+});
+
+registerCommand("memory.search", memorySearchSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ return memoryRepo.list(args);
+});
+
+registerCommand("memory.get", memoryIdSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ return memoryRepo.get(args.workspaceId, args.id) ?? notFound(args.id);
+});
+
+registerCommand("memory.create", memoryCreateSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ const entry = memoryRepo.create({
+ workspaceId: args.workspaceId,
+ type: args.type,
+ content: args.content,
+ source: {
+ defaultKind: defaultSourceKind(args.sourceHint),
+ ...args.sourceHint,
+ },
+ });
+
+ broadcastMemoryChanged(ctx, args.workspaceId, entry.id, "created");
+ return entry;
+});
+
+registerCommand("memory.update", memoryUpdateSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ const entry = memoryRepo.update({
+ workspaceId: args.workspaceId,
+ id: args.id,
+ type: args.type,
+ content: args.content,
+ });
+
+ broadcastMemoryChanged(ctx, args.workspaceId, entry.id, "updated");
+ return entry;
+});
+
+registerCommand("memory.delete", memoryIdSchema, async (args, ctx) => {
+ getWorkspaceOrThrow(ctx, args.workspaceId);
+ const memoryRepo = getMemoryRepoOrThrow(ctx);
+ const entry = memoryRepo.delete(args.workspaceId, args.id);
+
+ broadcastMemoryChanged(ctx, args.workspaceId, entry.id, "deleted");
+ return entry;
+});
diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts
index 03be2c5c7..c77be5b3d 100644
--- a/packages/server/src/commands/settings.test.ts
+++ b/packages/server/src/commands/settings.test.ts
@@ -223,7 +223,7 @@ describe("settings commands", () => {
settings: {
updates: {
autoCheckEnabled: false,
- checkIntervalSec: 21600,
+ checkIntervalSec: 3600,
},
},
},
@@ -233,7 +233,7 @@ describe("settings commands", () => {
expect(result.ok).toBe(true);
expect(settingsRepo.get("updates.autoCheckEnabled")).toBe(false);
- expect(settingsRepo.get("updates.checkIntervalSec")).toBe(21600);
+ expect(settingsRepo.get("updates.checkIntervalSec")).toBe(3600);
expect(updateService.reloadScheduleFromSettings).toHaveBeenCalledTimes(1);
});
diff --git a/packages/server/src/commands/skills.ts b/packages/server/src/commands/skills.ts
index 3b6ca8856..dfe859982 100644
--- a/packages/server/src/commands/skills.ts
+++ b/packages/server/src/commands/skills.ts
@@ -1,320 +1,5 @@
-import { rm } from "node:fs/promises";
-import { z } from "zod";
-import { buildAgentSkillTargets } from "../skills/target-registry.js";
-import type { CommandContext } from "../ws/dispatch.js";
-import { registerCommand } from "../ws/dispatch.js";
+export { registerSkillsCommands } from "./skills/index.js";
-function requireSkillsQuerySupport(ctx: CommandContext): asserts ctx is CommandContext & {
- skillsHubClient: NonNullable;
- skillLibraryRepo: NonNullable;
- skillMountRepo: NonNullable;
-} {
- if (!ctx.skillsHubClient || !ctx.skillLibraryRepo || !ctx.skillMountRepo) {
- throw { code: "skills_unavailable", message: "Skill management is not configured" };
- }
-}
+import { registerSkillsCommands } from "./skills/index.js";
-function requireSkillInstallSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- skillInstallMgr: NonNullable;
-} {
- if (!ctx.skillInstallMgr) {
- throw {
- code: "skill_install_unavailable",
- message: "Skill install manager is not configured",
- };
- }
-}
-
-function requireSkillMountSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- skillMountMgr: NonNullable;
-} {
- if (!ctx.skillMountMgr) {
- throw {
- code: "skill_mount_unavailable",
- message: "Skill mount manager is not configured",
- };
- }
-}
-
-function requireSkillHealthSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- skillHealthMgr: NonNullable;
-} {
- if (!ctx.skillHealthMgr) {
- throw {
- code: "skill_health_unavailable",
- message: "Skill health manager is not configured",
- };
- }
-}
-
-function requireSkillTargetSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- skillMountRepo: NonNullable;
-} {
- if (!ctx.skillMountRepo) {
- throw {
- code: "skill_targets_unavailable",
- message: "Skill target settings are not configured",
- };
- }
-}
-
-function requireBuiltinSkillSyncSupport(ctx: CommandContext): asserts ctx is CommandContext & {
- builtinSkillSyncMgr: NonNullable;
-} {
- if (!ctx.builtinSkillSyncMgr) {
- throw {
- code: "builtin_skills_unavailable",
- message: "Built-in skill sync is not configured",
- };
- }
-}
-
-async function listTargets(ctx: CommandContext) {
- requireSkillTargetSupport(ctx);
- requireSkillHealthSupport(ctx);
-
- const health = await ctx.skillHealthMgr.listTargetHealth();
- return buildAgentSkillTargets({
- providers: ctx.providerRegistry,
- resolvedSkillDirByProviderId: Object.fromEntries(
- ctx.providerRegistry.map((provider) => [provider.id, provider.skillMountDirectories?.[0]])
- ),
- mountCountsByProviderId: ctx.skillMountRepo.countsByProviderId(),
- targetHealthByProviderId: health,
- });
-}
-
-registerCommand(
- "skills.search",
- z.object({ query: z.string().trim().min(1) }),
- async (args, ctx) => {
- requireSkillsQuerySupport(ctx);
-
- const remote = await ctx.skillsHubClient.search(args.query);
- return remote.map((item) => {
- const installed = ctx.skillLibraryRepo.get(item.slug);
- const mounts = ctx.skillMountRepo.listBySkillSlug(item.slug).filter((entry) => entry.enabled);
-
- return {
- slug: item.slug,
- displayName: item.displayName,
- description: item.description,
- installed: Boolean(installed),
- installedVersion: installed?.version,
- mountedProviderIds: mounts.map((entry) => entry.providerId),
- };
- });
- }
-);
-
-registerCommand("skills.info", z.object({ slug: z.string().trim().min(1) }), async (args, ctx) => {
- requireSkillsQuerySupport(ctx);
-
- const libraryEntry = ctx.skillLibraryRepo.get(args.slug);
- const remote = await ctx.skillsHubClient.info(args.slug).catch(() => undefined);
-
- return {
- slug: args.slug,
- displayName: remote?.name ?? libraryEntry?.displayName ?? args.slug,
- description: remote?.description ?? libraryEntry?.description,
- version: remote?.version ?? libraryEntry?.version,
- installed: Boolean(libraryEntry),
- libraryEntry,
- mounts: ctx.skillMountRepo.listBySkillSlug(args.slug),
- };
-});
-
-registerCommand("skills.library.list", z.object({}), async (_args, ctx) => {
- requireSkillsQuerySupport(ctx);
-
- const skillLibraryRepo = ctx.skillLibraryRepo;
- const skillMountRepo = ctx.skillMountRepo;
-
- return skillLibraryRepo.list().map((entry) => {
- const mounts = skillMountRepo.listBySkillSlug(entry.slug).filter((item) => item.enabled);
- const errors = mounts.filter((item) => item.status === "failed" || item.status === "stale");
-
- return {
- ...entry,
- mountedProviderIds: mounts.map((item) => item.providerId),
- mountStatus:
- errors.length > 0
- ? "error"
- : mounts.length === 0
- ? "unmounted"
- : mounts.length === 1
- ? "partially_mounted"
- : "fully_mounted",
- errorCount: errors.length,
- };
- });
-});
-
-registerCommand("skills.builtin.sync", z.object({}), async (_args, ctx) => {
- requireBuiltinSkillSyncSupport(ctx);
- return ctx.builtinSkillSyncMgr.sync();
-});
-
-registerCommand(
- "skills.builtin.setMountEnabled",
- z.object({
- providerId: z.string().trim().min(1),
- skillSlug: z.string().trim().min(1),
- enabled: z.boolean(),
- }),
- async (args, ctx) => {
- requireBuiltinSkillSyncSupport(ctx);
- ctx.builtinSkillSyncMgr.setMountEnabled(args.providerId, args.skillSlug, args.enabled);
- return ctx.builtinSkillSyncMgr.sync();
- }
-);
-
-registerCommand(
- "skills.install.start",
- z.object({ slug: z.string().trim().min(1) }),
- async (args, ctx) => {
- requireSkillInstallSupport(ctx);
- return ctx.skillInstallMgr.start(args.slug);
- }
-);
-
-registerCommand(
- "skills.install.get",
- z.object({ jobId: z.string().trim().min(1) }),
- async (args, ctx) => {
- requireSkillInstallSupport(ctx);
- const job = ctx.skillInstallMgr.get(args.jobId);
- if (!job) {
- throw {
- code: "skill_install_job_not_found",
- message: `Install job not found: ${args.jobId}`,
- };
- }
-
- return job;
- }
-);
-
-registerCommand(
- "skills.mount",
- z.object({
- providerId: z.string().trim().min(1),
- skillSlug: z.string().trim().min(1),
- enabled: z.boolean().default(true),
- }),
- async (args, ctx) => {
- requireSkillMountSupport(ctx);
- requireSkillHealthSupport(ctx);
- requireSkillTargetSupport(ctx);
-
- const relation = await ctx.skillMountMgr.mount(args);
- const scanned = await ctx.skillHealthMgr.scanMount(relation);
- ctx.skillMountRepo.upsert(scanned);
- return scanned;
- }
-);
-
-registerCommand(
- "skills.unmount",
- z.object({
- providerId: z.string().trim().min(1),
- skillSlug: z.string().trim().min(1),
- }),
- async (args, ctx) => {
- requireSkillMountSupport(ctx);
- await ctx.skillMountMgr.unmount(args.providerId, args.skillSlug);
- return { ok: true };
- }
-);
-
-registerCommand(
- "skills.uninstall",
- z.object({
- slug: z.string().trim().min(1),
- force: z.boolean().optional(),
- }),
- async (args, ctx) => {
- requireSkillsQuerySupport(ctx);
- requireSkillMountSupport(ctx);
-
- const libraryEntry = ctx.skillLibraryRepo.get(args.slug);
- const mounts = ctx.skillMountRepo.listBySkillSlug(args.slug);
- const enabledMounts = mounts.filter((entry) => entry.enabled);
- if (enabledMounts.length > 0 && !args.force) {
- throw {
- code: "skill_uninstall_blocked",
- message: `Skill still mounted: ${args.slug}`,
- details: enabledMounts.map((entry) => entry.providerId),
- };
- }
-
- if (args.force) {
- for (const mount of mounts) {
- await ctx.skillMountMgr.unmount(mount.providerId, mount.skillSlug).catch(() => undefined);
- }
- }
-
- ctx.skillMountRepo.deleteBySkillSlug(args.slug);
- ctx.skillLibraryRepo.delete(args.slug);
- if (libraryEntry?.libraryPath) {
- await rm(libraryEntry.libraryPath, { recursive: true, force: true }).catch(() => undefined);
- }
- return { deleted: true, slug: args.slug };
- }
-);
-
-registerCommand("skills.targets.list", z.object({}), async (_args, ctx) => {
- return listTargets(ctx);
-});
-
-registerCommand("skills.health.scan", z.object({}), async (_args, ctx) => {
- requireSkillHealthSupport(ctx);
- requireSkillTargetSupport(ctx);
-
- const discoveredMounts = await ctx.skillHealthMgr.discoverMounts(ctx.skillMountRepo.list());
- for (const relation of discoveredMounts) {
- ctx.skillMountRepo.upsert(relation);
- }
-
- const scannedMounts = await Promise.all(
- ctx.skillMountRepo.list().map((relation) => ctx.skillHealthMgr.scanMount(relation))
- );
- for (const relation of scannedMounts) {
- ctx.skillMountRepo.upsert(relation);
- }
-
- return {
- targets: await listTargets(ctx),
- mounts: scannedMounts,
- };
-});
-
-registerCommand(
- "skills.repair",
- z.object({
- providerId: z.string().trim().min(1),
- skillSlug: z.string().trim().min(1),
- }),
- async (args, ctx) => {
- requireSkillMountSupport(ctx);
- requireSkillHealthSupport(ctx);
- requireSkillTargetSupport(ctx);
-
- const existing = ctx.skillMountRepo.get(args.providerId, args.skillSlug);
- if (!existing) {
- throw {
- code: "skill_mount_not_found",
- message: `Mount not found for ${args.providerId}:${args.skillSlug}`,
- };
- }
-
- const relation = await ctx.skillMountMgr.mount({
- providerId: args.providerId,
- skillSlug: args.skillSlug,
- enabled: existing.enabled,
- });
- const scanned = await ctx.skillHealthMgr.scanMount(relation);
- ctx.skillMountRepo.upsert(scanned);
- return scanned;
- }
-);
+registerSkillsCommands();
diff --git a/packages/server/src/commands/skills/builtin.ts b/packages/server/src/commands/skills/builtin.ts
new file mode 100644
index 000000000..7070e23fc
--- /dev/null
+++ b/packages/server/src/commands/skills/builtin.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+import { registerCommand } from "../../ws/dispatch.js";
+import {
+ broadcastSkillLibraryChanged,
+ hasSyncChanges,
+ requireBuiltinSkillSyncSupport,
+} from "./shared.js";
+
+export function registerBuiltinSkillCommands(): void {
+ registerCommand("skills.builtin.sync", z.object({}), async (_args, ctx) => {
+ requireBuiltinSkillSyncSupport(ctx);
+ const result = await ctx.builtinSkillSyncMgr.sync();
+ if (hasSyncChanges(result)) {
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "builtin_sync",
+ removed: result.removed ?? [],
+ });
+ }
+ return result;
+ });
+
+ registerCommand(
+ "skills.builtin.setMountEnabled",
+ z.object({
+ providerId: z.string().trim().min(1),
+ skillSlug: z.string().trim().min(1),
+ enabled: z.boolean(),
+ }),
+ async (args, ctx) => {
+ requireBuiltinSkillSyncSupport(ctx);
+ ctx.builtinSkillSyncMgr.setMountEnabled(args.providerId, args.skillSlug, args.enabled);
+ const result = await ctx.builtinSkillSyncMgr.sync();
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "builtin_mount_setting_changed",
+ providerId: args.providerId,
+ skillSlug: args.skillSlug,
+ enabled: args.enabled,
+ removed: result.removed ?? [],
+ });
+ return result;
+ }
+ );
+}
diff --git a/packages/server/src/commands/skills/health.ts b/packages/server/src/commands/skills/health.ts
new file mode 100644
index 000000000..3097caa68
--- /dev/null
+++ b/packages/server/src/commands/skills/health.ts
@@ -0,0 +1,31 @@
+import { z } from "zod";
+import { registerCommand } from "../../ws/dispatch.js";
+import { listTargets, requireSkillHealthSupport, requireSkillTargetSupport } from "./shared.js";
+
+export function registerSkillHealthCommands(): void {
+ registerCommand("skills.targets.list", z.object({}), async (_args, ctx) => {
+ return listTargets(ctx);
+ });
+
+ registerCommand("skills.health.scan", z.object({}), async (_args, ctx) => {
+ requireSkillHealthSupport(ctx);
+ requireSkillTargetSupport(ctx);
+
+ const discoveredMounts = await ctx.skillHealthMgr.discoverMounts(ctx.skillMountRepo.list());
+ for (const relation of discoveredMounts) {
+ ctx.skillMountRepo.upsert(relation);
+ }
+
+ const scannedMounts = await Promise.all(
+ ctx.skillMountRepo.list().map((relation) => ctx.skillHealthMgr.scanMount(relation))
+ );
+ for (const relation of scannedMounts) {
+ ctx.skillMountRepo.upsert(relation);
+ }
+
+ return {
+ targets: await listTargets(ctx),
+ mounts: scannedMounts,
+ };
+ });
+}
diff --git a/packages/server/src/commands/skills/index.ts b/packages/server/src/commands/skills/index.ts
new file mode 100644
index 000000000..68c4658e5
--- /dev/null
+++ b/packages/server/src/commands/skills/index.ts
@@ -0,0 +1,22 @@
+import { registerBuiltinSkillCommands } from "./builtin.js";
+import { registerSkillHealthCommands } from "./health.js";
+import { registerSkillInstallCommands } from "./install.js";
+import { registerSkillLibraryCommands } from "./library.js";
+import { registerSkillMountCommands } from "./mount.js";
+import { registerSkillQueryCommands } from "./query.js";
+
+let registered = false;
+
+export function registerSkillsCommands(): void {
+ if (registered) {
+ return;
+ }
+
+ registered = true;
+ registerSkillQueryCommands();
+ registerSkillLibraryCommands();
+ registerSkillInstallCommands();
+ registerSkillMountCommands();
+ registerSkillHealthCommands();
+ registerBuiltinSkillCommands();
+}
diff --git a/packages/server/src/commands/skills/install.ts b/packages/server/src/commands/skills/install.ts
new file mode 100644
index 000000000..83bdff4f5
--- /dev/null
+++ b/packages/server/src/commands/skills/install.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+import { registerCommand } from "../../ws/dispatch.js";
+import { requireSkillInstallSupport } from "./shared.js";
+
+export function registerSkillInstallCommands(): void {
+ registerCommand(
+ "skills.install.start",
+ z.object({ slug: z.string().trim().min(1) }),
+ async (args, ctx) => {
+ requireSkillInstallSupport(ctx);
+ return ctx.skillInstallMgr.start(args.slug);
+ }
+ );
+
+ registerCommand(
+ "skills.update.start",
+ z.object({ slug: z.string().trim().min(1) }),
+ async (args, ctx) => {
+ requireSkillInstallSupport(ctx);
+ if (!ctx.skillLibraryRepo) {
+ throw { code: "skills_unavailable", message: "Skill management is not configured" };
+ }
+
+ const entry = ctx.skillLibraryRepo.get(args.slug);
+ if (!entry || entry.source !== "skillhub" || entry.installState !== "installed") {
+ throw {
+ code: "skill_update_unavailable",
+ message: `Only installed Skill Hub skills can be updated: ${args.slug}`,
+ };
+ }
+
+ return ctx.skillInstallMgr.start(args.slug);
+ }
+ );
+
+ registerCommand(
+ "skills.install.get",
+ z.object({ jobId: z.string().trim().min(1) }),
+ async (args, ctx) => {
+ requireSkillInstallSupport(ctx);
+ const job = ctx.skillInstallMgr.get(args.jobId);
+ if (!job) {
+ throw {
+ code: "skill_install_job_not_found",
+ message: `Install job not found: ${args.jobId}`,
+ };
+ }
+
+ return job;
+ }
+ );
+}
diff --git a/packages/server/src/commands/skills/library.ts b/packages/server/src/commands/skills/library.ts
new file mode 100644
index 000000000..8375039c9
--- /dev/null
+++ b/packages/server/src/commands/skills/library.ts
@@ -0,0 +1,45 @@
+import { z } from "zod";
+import { registerCommand } from "../../ws/dispatch.js";
+import {
+ checkSkillHubVersion,
+ requireSkillsQuerySupport,
+ requireSkillVersionCheckSupport,
+} from "./shared.js";
+
+export function registerSkillLibraryCommands(): void {
+ registerCommand("skills.library.list", z.object({}), async (_args, ctx) => {
+ requireSkillsQuerySupport(ctx);
+
+ const skillLibraryRepo = ctx.skillLibraryRepo;
+ const skillMountRepo = ctx.skillMountRepo;
+
+ return skillLibraryRepo.list().map((entry) => {
+ const mounts = skillMountRepo.listBySkillSlug(entry.slug).filter((item) => item.enabled);
+ const errors = mounts.filter((item) => item.status === "failed" || item.status === "stale");
+
+ return {
+ ...entry,
+ mountedProviderIds: mounts.map((item) => item.providerId),
+ mountStatus:
+ errors.length > 0
+ ? "error"
+ : mounts.length === 0
+ ? "unmounted"
+ : mounts.length === 1
+ ? "partially_mounted"
+ : "fully_mounted",
+ errorCount: errors.length,
+ };
+ });
+ });
+
+ registerCommand("skills.versions.check", z.object({}), async (_args, ctx) => {
+ requireSkillVersionCheckSupport(ctx);
+
+ const skillHubEntries = ctx.skillLibraryRepo
+ .list()
+ .filter((entry) => entry.source === "skillhub" && entry.installState === "installed");
+
+ return Promise.all(skillHubEntries.map((entry) => checkSkillHubVersion(entry, ctx)));
+ });
+}
diff --git a/packages/server/src/commands/skills/mount.ts b/packages/server/src/commands/skills/mount.ts
new file mode 100644
index 000000000..d19a71044
--- /dev/null
+++ b/packages/server/src/commands/skills/mount.ts
@@ -0,0 +1,136 @@
+import { rm } from "node:fs/promises";
+import { z } from "zod";
+import { registerCommand } from "../../ws/dispatch.js";
+import {
+ broadcastSkillLibraryChanged,
+ requireSkillHealthSupport,
+ requireSkillMountSupport,
+ requireSkillsQuerySupport,
+ requireSkillTargetSupport,
+} from "./shared.js";
+
+export function registerSkillMountCommands(): void {
+ registerCommand(
+ "skills.mount",
+ z.object({
+ providerId: z.string().trim().min(1),
+ skillSlug: z.string().trim().min(1),
+ enabled: z.boolean().default(true),
+ }),
+ async (args, ctx) => {
+ requireSkillMountSupport(ctx);
+ requireSkillHealthSupport(ctx);
+ requireSkillTargetSupport(ctx);
+
+ const relation = await ctx.skillMountMgr.mount(args);
+ const scanned = await ctx.skillHealthMgr.scanMount(relation);
+ ctx.skillMountRepo.upsert(scanned);
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "mounted",
+ providerId: args.providerId,
+ skillSlug: args.skillSlug,
+ });
+ return scanned;
+ }
+ );
+
+ registerCommand(
+ "skills.unmount",
+ z.object({
+ providerId: z.string().trim().min(1),
+ skillSlug: z.string().trim().min(1),
+ }),
+ async (args, ctx) => {
+ requireSkillMountSupport(ctx);
+ await ctx.skillMountMgr.unmount(args.providerId, args.skillSlug);
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "unmounted",
+ providerId: args.providerId,
+ skillSlug: args.skillSlug,
+ });
+ return { ok: true };
+ }
+ );
+
+ registerCommand(
+ "skills.uninstall",
+ z.object({
+ slug: z.string().trim().min(1),
+ force: z.boolean().optional(),
+ }),
+ async (args, ctx) => {
+ requireSkillsQuerySupport(ctx);
+ requireSkillMountSupport(ctx);
+
+ const libraryEntry = ctx.skillLibraryRepo.get(args.slug);
+ if (libraryEntry?.source === "builtin") {
+ throw {
+ code: "skill_uninstall_unavailable",
+ message: `Built-in skills cannot be uninstalled: ${args.slug}`,
+ };
+ }
+
+ const mounts = ctx.skillMountRepo.listBySkillSlug(args.slug);
+ const enabledMounts = mounts.filter((entry) => entry.enabled);
+ if (enabledMounts.length > 0 && !args.force) {
+ throw {
+ code: "skill_uninstall_blocked",
+ message: `Skill still mounted: ${args.slug}`,
+ details: enabledMounts.map((entry) => entry.providerId),
+ };
+ }
+
+ if (args.force) {
+ for (const mount of mounts) {
+ await ctx.skillMountMgr.unmount(mount.providerId, mount.skillSlug).catch(() => undefined);
+ }
+ }
+
+ ctx.skillMountRepo.deleteBySkillSlug(args.slug);
+ ctx.skillLibraryRepo.delete(args.slug);
+ if (libraryEntry?.libraryPath) {
+ await rm(libraryEntry.libraryPath, { recursive: true, force: true }).catch(() => undefined);
+ }
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "uninstalled",
+ slug: args.slug,
+ });
+ return { deleted: true, slug: args.slug };
+ }
+ );
+
+ registerCommand(
+ "skills.repair",
+ z.object({
+ providerId: z.string().trim().min(1),
+ skillSlug: z.string().trim().min(1),
+ }),
+ async (args, ctx) => {
+ requireSkillMountSupport(ctx);
+ requireSkillHealthSupport(ctx);
+ requireSkillTargetSupport(ctx);
+
+ const existing = ctx.skillMountRepo.get(args.providerId, args.skillSlug);
+ if (!existing) {
+ throw {
+ code: "skill_mount_not_found",
+ message: `Mount not found for ${args.providerId}:${args.skillSlug}`,
+ };
+ }
+
+ const relation = await ctx.skillMountMgr.mount({
+ providerId: args.providerId,
+ skillSlug: args.skillSlug,
+ enabled: existing.enabled,
+ });
+ const scanned = await ctx.skillHealthMgr.scanMount(relation);
+ ctx.skillMountRepo.upsert(scanned);
+ broadcastSkillLibraryChanged(ctx, {
+ reason: "repaired",
+ providerId: args.providerId,
+ skillSlug: args.skillSlug,
+ });
+ return scanned;
+ }
+ );
+}
diff --git a/packages/server/src/commands/skills/query.ts b/packages/server/src/commands/skills/query.ts
new file mode 100644
index 000000000..db8762e32
--- /dev/null
+++ b/packages/server/src/commands/skills/query.ts
@@ -0,0 +1,85 @@
+import { z } from "zod";
+import { buildSkillRecommendations } from "../../skills/recommendation.js";
+import { inspectWorkspaceIntelligence } from "../../workspace/intelligence.js";
+import { registerCommand } from "../../ws/dispatch.js";
+import { requireSkillsQuerySupport } from "./shared.js";
+
+export function registerSkillQueryCommands(): void {
+ registerCommand(
+ "skills.search",
+ z.object({ query: z.string().trim().min(1) }),
+ async (args, ctx) => {
+ requireSkillsQuerySupport(ctx);
+
+ const remote = await ctx.skillsHubClient.search(args.query);
+ return remote.map((item) => {
+ const installed = ctx.skillLibraryRepo.get(item.slug);
+ const mounts = ctx.skillMountRepo
+ .listBySkillSlug(item.slug)
+ .filter((entry) => entry.enabled);
+
+ return {
+ slug: item.slug,
+ displayName: item.displayName,
+ description: item.description,
+ version: item.version,
+ installed: Boolean(installed),
+ installedVersion: installed?.version,
+ mountedProviderIds: mounts.map((entry) => entry.providerId),
+ };
+ });
+ }
+ );
+
+ registerCommand(
+ "skills.recommend",
+ z.object({
+ workspaceId: z.string().trim().min(1),
+ limit: z.number().int().positive().max(10).optional(),
+ }),
+ async (args, ctx) => {
+ requireSkillsQuerySupport(ctx);
+
+ const workspace = ctx.workspaceMgr.get(args.workspaceId);
+ if (!workspace) {
+ throw {
+ code: "workspace_not_found",
+ message: `Workspace not found: ${args.workspaceId}`,
+ };
+ }
+
+ const intelligence = await inspectWorkspaceIntelligence({
+ workspaceId: workspace.id,
+ rootPath: workspace.path,
+ });
+
+ return buildSkillRecommendations({
+ intelligence,
+ search: (query) => ctx.skillsHubClient.search(query),
+ isInstalled: (slug) => Boolean(ctx.skillLibraryRepo.get(slug)),
+ limit: args.limit,
+ });
+ }
+ );
+
+ registerCommand(
+ "skills.info",
+ z.object({ slug: z.string().trim().min(1) }),
+ async (args, ctx) => {
+ requireSkillsQuerySupport(ctx);
+
+ const libraryEntry = ctx.skillLibraryRepo.get(args.slug);
+ const remote = await ctx.skillsHubClient.info(args.slug).catch(() => undefined);
+
+ return {
+ slug: args.slug,
+ displayName: remote?.name ?? libraryEntry?.displayName ?? args.slug,
+ description: remote?.description ?? libraryEntry?.description,
+ version: remote?.version ?? libraryEntry?.version,
+ installed: Boolean(libraryEntry),
+ libraryEntry,
+ mounts: ctx.skillMountRepo.listBySkillSlug(args.slug),
+ };
+ }
+ );
+}
diff --git a/packages/server/src/commands/skills/shared.ts b/packages/server/src/commands/skills/shared.ts
new file mode 100644
index 000000000..2d58e04fa
--- /dev/null
+++ b/packages/server/src/commands/skills/shared.ts
@@ -0,0 +1,185 @@
+import { type SkillLibraryEntry, type SkillVersionCheckEntry, Topics } from "@coder-studio/core";
+import { buildAgentSkillTargets } from "../../skills/target-registry.js";
+import type { CommandContext } from "../../ws/dispatch.js";
+
+export function requireSkillsQuerySupport(ctx: CommandContext): asserts ctx is CommandContext & {
+ skillsHubClient: NonNullable;
+ skillLibraryRepo: NonNullable;
+ skillMountRepo: NonNullable;
+} {
+ if (!ctx.skillsHubClient || !ctx.skillLibraryRepo || !ctx.skillMountRepo) {
+ throw { code: "skills_unavailable", message: "Skill management is not configured" };
+ }
+}
+
+export function requireSkillVersionCheckSupport(
+ ctx: CommandContext
+): asserts ctx is CommandContext & {
+ skillsHubClient: NonNullable;
+ skillLibraryRepo: NonNullable;
+} {
+ if (!ctx.skillsHubClient || !ctx.skillLibraryRepo) {
+ throw { code: "skills_unavailable", message: "Skill management is not configured" };
+ }
+}
+
+export function requireSkillInstallSupport(ctx: CommandContext): asserts ctx is CommandContext & {
+ skillInstallMgr: NonNullable;
+} {
+ if (!ctx.skillInstallMgr) {
+ throw {
+ code: "skill_install_unavailable",
+ message: "Skill install manager is not configured",
+ };
+ }
+}
+
+export function requireSkillMountSupport(ctx: CommandContext): asserts ctx is CommandContext & {
+ skillMountMgr: NonNullable;
+} {
+ if (!ctx.skillMountMgr) {
+ throw {
+ code: "skill_mount_unavailable",
+ message: "Skill mount manager is not configured",
+ };
+ }
+}
+
+export function requireSkillHealthSupport(ctx: CommandContext): asserts ctx is CommandContext & {
+ skillHealthMgr: NonNullable;
+} {
+ if (!ctx.skillHealthMgr) {
+ throw {
+ code: "skill_health_unavailable",
+ message: "Skill health manager is not configured",
+ };
+ }
+}
+
+export function requireSkillTargetSupport(ctx: CommandContext): asserts ctx is CommandContext & {
+ skillMountRepo: NonNullable;
+} {
+ if (!ctx.skillMountRepo) {
+ throw {
+ code: "skill_targets_unavailable",
+ message: "Skill target settings are not configured",
+ };
+ }
+}
+
+export function requireBuiltinSkillSyncSupport(
+ ctx: CommandContext
+): asserts ctx is CommandContext & {
+ builtinSkillSyncMgr: NonNullable;
+} {
+ if (!ctx.builtinSkillSyncMgr) {
+ throw {
+ code: "builtin_skills_unavailable",
+ message: "Built-in skill sync is not configured",
+ };
+ }
+}
+
+export function broadcastSkillLibraryChanged(
+ ctx: CommandContext,
+ payload: Record
+): void {
+ if (typeof ctx.broadcaster.broadcast !== "function") {
+ return;
+ }
+
+ ctx.broadcaster.broadcast(Topics.skillLibraryChanged, {
+ ...payload,
+ changedAt: Date.now(),
+ });
+}
+
+export function hasSyncChanges(result: {
+ libraryEntries?: unknown[];
+ mounted?: unknown[];
+ removed?: unknown[];
+}): boolean {
+ return (
+ (result.removed?.length ?? 0) > 0 ||
+ (result.mounted?.length ?? 0) > 0 ||
+ (result.libraryEntries?.length ?? 0) > 0
+ );
+}
+
+export async function listTargets(ctx: CommandContext) {
+ requireSkillTargetSupport(ctx);
+ requireSkillHealthSupport(ctx);
+
+ const health = await ctx.skillHealthMgr.listTargetHealth();
+ return buildAgentSkillTargets({
+ providers: ctx.providerRegistry,
+ resolvedSkillDirByProviderId: Object.fromEntries(
+ ctx.providerRegistry.map((provider) => [provider.id, provider.skillMountDirectories?.[0]])
+ ),
+ mountCountsByProviderId: ctx.skillMountRepo.countsByProviderId(),
+ targetHealthByProviderId: health,
+ });
+}
+
+function parseVersionParts(version: string): number[] {
+ return version
+ .trim()
+ .replace(/^v(?=\d)/i, "")
+ .split(".")
+ .map((segment) => {
+ const match = segment.match(/^(\d+)/);
+ return match ? Number.parseInt(match[1]!, 10) : 0;
+ });
+}
+
+export function compareVersions(left: string, right: string): number {
+ const leftParts = parseVersionParts(left);
+ const rightParts = parseVersionParts(right);
+ const length = Math.max(leftParts.length, rightParts.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const leftValue = leftParts[index] ?? 0;
+ const rightValue = rightParts[index] ?? 0;
+ if (leftValue > rightValue) {
+ return 1;
+ }
+ if (leftValue < rightValue) {
+ return -1;
+ }
+ }
+
+ return 0;
+}
+
+export async function checkSkillHubVersion(
+ entry: SkillLibraryEntry,
+ ctx: CommandContext & {
+ skillsHubClient: NonNullable;
+ }
+): Promise {
+ try {
+ const remote = await ctx.skillsHubClient.info(entry.slug);
+ const latestVersion = remote.version?.trim();
+ if (!latestVersion) {
+ return {
+ slug: entry.slug,
+ currentVersion: entry.version,
+ status: "unknown",
+ };
+ }
+
+ return {
+ slug: entry.slug,
+ currentVersion: entry.version,
+ latestVersion,
+ status: compareVersions(latestVersion, entry.version) > 0 ? "update_available" : "up_to_date",
+ };
+ } catch (error) {
+ return {
+ slug: entry.slug,
+ currentVersion: entry.version,
+ status: "error",
+ error: error instanceof Error ? error.message : "Version check failed",
+ };
+ }
+}
diff --git a/packages/server/src/commands/ui-actions.ts b/packages/server/src/commands/ui-actions.ts
new file mode 100644
index 000000000..81149b457
--- /dev/null
+++ b/packages/server/src/commands/ui-actions.ts
@@ -0,0 +1,99 @@
+import {
+ createUiActionDispatchResult,
+ createUiActionEvent,
+ DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ listUiActionCapabilities,
+ normalizeUiActionDispatchRequest,
+ resolveUiActionWorkspaceId,
+ Topics,
+} from "@coder-studio/core";
+import { z } from "zod";
+import { registerCommand } from "../ws/dispatch.js";
+
+const uiActionIntentSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("editor.openFile"),
+ workspaceId: z.string().optional(),
+ path: z.string(),
+ line: z.number().int().optional(),
+ column: z.number().int().optional(),
+ target: z
+ .union([z.literal("active"), z.literal("newPane"), z.object({ paneId: z.string() })])
+ .optional(),
+ }),
+ z.object({
+ type: z.literal("editor.closeFile"),
+ workspaceId: z.string().optional(),
+ path: z.string(),
+ }),
+ z.object({
+ type: z.literal("browser.openUrl"),
+ workspaceId: z.string().optional(),
+ url: z.string(),
+ target: z.union([z.literal("preview"), z.literal("external")]).optional(),
+ }),
+ z.object({
+ type: z.literal("browser.closeUrl"),
+ workspaceId: z.string().optional(),
+ url: z.string(),
+ }),
+ z.object({
+ type: z.literal("workspace.focus"),
+ workspaceId: z.string(),
+ }),
+ z.object({
+ type: z.literal("panel.show"),
+ workspaceId: z.string().optional(),
+ panel: z.enum(["terminal", "explorer", "search", "git", "skills", "agentInstructions"]),
+ }),
+ z.object({
+ type: z.literal("command.run"),
+ commandId: z.enum(["quickOpen.open", "commandPalette.open"]),
+ args: z.record(z.string(), z.unknown()).optional(),
+ }),
+]);
+
+const uiActionDispatchSchema = z.object({
+ workspaceId: z.string().optional(),
+ intent: uiActionIntentSchema,
+ requestId: z.string().optional(),
+ source: z
+ .object({
+ kind: z.enum(["agent", "user", "system"]),
+ sessionId: z.string().optional(),
+ providerId: z.string().optional(),
+ })
+ .optional(),
+});
+
+registerCommand(
+ "uiAction.capabilities",
+ z.object({
+ permissions: z.array(z.string()).optional(),
+ }),
+ async (args) => ({
+ version: 1,
+ actions: listUiActionCapabilities({
+ permissions: args.permissions ?? DEFAULT_AGENT_AUTOMATION_PERMISSIONS,
+ }),
+ })
+);
+
+registerCommand("uiAction.dispatch", uiActionDispatchSchema, async (args, ctx) => {
+ const request = normalizeUiActionDispatchRequest({
+ intent: args.intent,
+ requestId: args.requestId,
+ source: args.source,
+ });
+ const workspaceId = resolveUiActionWorkspaceId(request, args.workspaceId);
+ const event = createUiActionEvent({
+ request,
+ workspaceId,
+ dispatchedAt: Date.now(),
+ });
+ const topic = Topics.workspaceUiAction(workspaceId);
+
+ ctx.broadcaster.broadcast(topic, event);
+
+ return createUiActionDispatchResult(event);
+});
diff --git a/packages/server/src/commands/workspace-extension-state.ts b/packages/server/src/commands/workspace-extension-state.ts
deleted file mode 100644
index fc430c9ab..000000000
--- a/packages/server/src/commands/workspace-extension-state.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { WORKSPACE_LOG_LEVELS, WORKSPACE_STATUS_PILL_STATES } from "@coder-studio/core";
-import { z } from "zod";
-import type { CommandContext } from "../ws/dispatch.js";
-import { registerCommand } from "../ws/dispatch.js";
-
-function requireWorkspaceExtensionStateService(
- ctx: CommandContext
-): asserts ctx is CommandContext & {
- workspaceExtensionStateService: NonNullable;
-} {
- if (!ctx.workspaceExtensionStateService) {
- throw {
- code: "workspace_extension_state_unavailable",
- message: "Workspace extension state is not configured",
- };
- }
-}
-
-const workspaceIdSchema = z.string().trim().min(1);
-const keySchema = z.string().trim().min(1);
-const optionalTextSchema = z.string().trim().min(1).optional();
-
-registerCommand(
- "workspace.extensionState.list",
- z.object({
- workspaceId: workspaceIdSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.get(args.workspaceId);
- }
-);
-
-registerCommand(
- "workspace.extensionState.statusPills.set",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema,
- label: z.string().trim().min(1),
- state: z.enum(WORKSPACE_STATUS_PILL_STATES),
- detail: optionalTextSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.setStatusPill(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.statusPills.list",
- z.object({
- workspaceId: workspaceIdSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.get(args.workspaceId).statusPills;
- }
-);
-
-registerCommand(
- "workspace.extensionState.statusPills.clear",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.clearStatusPill(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.progress.set",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema,
- label: z.string().trim().min(1),
- value: z.number().finite().optional(),
- max: z.number().finite().positive().optional(),
- detail: optionalTextSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.setProgress(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.progress.list",
- z.object({
- workspaceId: workspaceIdSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.get(args.workspaceId).progress;
- }
-);
-
-registerCommand(
- "workspace.extensionState.progress.clear",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.clearProgress(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.logs.append",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema,
- level: z.enum(WORKSPACE_LOG_LEVELS).default("info"),
- message: z.string().trim().min(1),
- timestamp: z.number().finite().optional(),
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.appendLog(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.logs.list",
- z.object({
- workspaceId: workspaceIdSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.get(args.workspaceId).logs;
- }
-);
-
-registerCommand(
- "workspace.extensionState.logs.clear",
- z.object({
- workspaceId: workspaceIdSchema,
- key: keySchema.optional(),
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.clearLog(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.quickActions.set",
- z.object({
- workspaceId: workspaceIdSchema,
- id: keySchema,
- label: z.string().trim().min(1),
- command: z.string().trim().min(1),
- description: optionalTextSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.setQuickAction(args);
- }
-);
-
-registerCommand(
- "workspace.extensionState.quickActions.list",
- z.object({
- workspaceId: workspaceIdSchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.get(args.workspaceId).quickActions;
- }
-);
-
-registerCommand(
- "workspace.extensionState.quickActions.clear",
- z.object({
- workspaceId: workspaceIdSchema,
- id: keySchema,
- }),
- async (args, ctx) => {
- requireWorkspaceExtensionStateService(ctx);
- return ctx.workspaceExtensionStateService.clearQuickAction(args);
- }
-);
diff --git a/packages/server/src/commands/workspace.ts b/packages/server/src/commands/workspace.ts
index d3b789498..55a3b7529 100644
--- a/packages/server/src/commands/workspace.ts
+++ b/packages/server/src/commands/workspace.ts
@@ -91,6 +91,29 @@ const workspacePaneNodeSchema: z.ZodType = z.lazy(() =>
])
);
+const MAX_BROWSER_VIEWPORT_DIMENSION = 4096;
+
+const workspaceEditorTabSchema = z.union([
+ z
+ .object({
+ kind: z.literal("file"),
+ path: z.string(),
+ })
+ .strict(),
+ z
+ .object({
+ kind: z.literal("browser"),
+ id: z.string(),
+ url: z.string().nullable(),
+ devicePreset: z.enum(["desktop", "iphone-14", "pixel-7", "custom"]),
+ viewportWidth: z.number().int().positive().max(MAX_BROWSER_VIEWPORT_DIMENSION).nullable(),
+ viewportHeight: z.number().int().positive().max(MAX_BROWSER_VIEWPORT_DIMENSION).nullable(),
+ orientation: z.enum(["portrait", "landscape"]),
+ userAgentMode: z.enum(["desktop", "mobile"]),
+ })
+ .strict(),
+]);
+
// workspace.list
registerCommand("workspace.list", z.object({}), async (_args, ctx) => {
return ctx.workspaceMgr.list();
@@ -224,12 +247,16 @@ registerCommand(
leftPanelWidth: z.number(),
bottomPanelHeight: z.number(),
focusMode: z.boolean(),
+ editorViewVisible: z.boolean().optional(),
activeSessionId: z.string().optional(),
agentInstructionsExpanded: z.boolean().optional(),
fileTreeExpandedDirs: z.array(z.string()).optional(),
paneLayout: workspacePaneNodeSchema.optional(),
openEditorPaths: z.array(z.string()).optional(),
activeEditorPath: z.string().nullable().optional(),
+ openEditorTabs: z.array(workspaceEditorTabSchema).optional(),
+ activeEditorTab: workspaceEditorTabSchema.nullable().optional(),
+ devBrowserTargetUrl: z.string().nullable().optional(),
}),
}),
async (args, ctx) => {
diff --git a/packages/server/src/dev-browser/proxy-headers.test.ts b/packages/server/src/dev-browser/proxy-headers.test.ts
new file mode 100644
index 000000000..c591c2abf
--- /dev/null
+++ b/packages/server/src/dev-browser/proxy-headers.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildProxyWebSocketRequestOptions,
+ filterProxyRequestHeaders,
+ filterProxyResponseHeaders,
+ rewriteProxyLocationHeader,
+ rewriteProxyUrlReference,
+} from "./proxy-headers.js";
+
+describe("dev browser proxy headers", () => {
+ it("strips hop-by-hop request headers and sets the target host", () => {
+ expect(
+ Object.fromEntries(
+ filterProxyRequestHeaders(
+ {
+ connection: "upgrade",
+ host: "coder.example",
+ upgrade: "websocket",
+ cookie: "coder_studio_auth=secret",
+ accept: "text/html",
+ },
+ "127.0.0.1:8000"
+ )
+ )
+ ).toEqual({
+ accept: "text/html",
+ host: "127.0.0.1:8000",
+ });
+ });
+
+ it("strips unsafe response headers", () => {
+ const headers = new Headers({
+ "content-type": "text/html",
+ "content-length": "200",
+ "content-encoding": "gzip",
+ connection: "keep-alive",
+ "set-cookie": "sid=abc",
+ });
+
+ expect(Object.fromEntries(filterProxyResponseHeaders(headers))).toEqual({
+ "content-type": "text/html",
+ });
+ });
+
+ it("strips websocket transport headers and preserves subprotocols", () => {
+ expect(
+ buildProxyWebSocketRequestOptions({
+ connection: "Upgrade",
+ upgrade: "websocket",
+ host: "coder.example",
+ "sec-websocket-key": "secret",
+ "sec-websocket-version": "13",
+ "sec-websocket-extensions": "permessage-deflate",
+ "sec-websocket-protocol": "json, superjson",
+ cookie: "coder_studio_auth=secret",
+ authorization: "Bearer secret",
+ origin: "https://coder.example",
+ "x-trace-id": "trace-1",
+ })
+ ).toEqual({
+ headers: {
+ "x-trace-id": "trace-1",
+ },
+ protocols: ["json", "superjson"],
+ });
+ });
+
+ it("overrides websocket user-agent when provided", () => {
+ expect(
+ buildProxyWebSocketRequestOptions(
+ {
+ "user-agent": "Coder Studio Browser",
+ "x-trace-id": "trace-1",
+ },
+ {
+ userAgent: "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125.0.0.0 Mobile",
+ }
+ )
+ ).toEqual({
+ headers: {
+ "user-agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125.0.0.0 Mobile",
+ "x-trace-id": "trace-1",
+ },
+ });
+ });
+
+ it("rewrites loopback redirect locations into browser proxy paths", () => {
+ expect(
+ rewriteProxyLocationHeader("http://localhost:8000/dashboard?tab=1", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/dashboard?tab=1");
+
+ expect(
+ rewriteProxyLocationHeader("/login", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/login");
+
+ expect(
+ rewriteProxyLocationHeader("//localhost:8000/dashboard?tab=1", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/dashboard?tab=1");
+ });
+
+ it("does not rewrite external protocol-relative redirect locations", () => {
+ expect(
+ rewriteProxyLocationHeader("//example.com/dashboard", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("//example.com/dashboard");
+ });
+
+ it("rewrites root-relative and matching loopback URL references", () => {
+ expect(
+ rewriteProxyUrlReference("/assets/app.css", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/assets/app.css");
+
+ expect(
+ rewriteProxyUrlReference("http://localhost:8000/images/logo.png", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("/dev-browser/session/dev_1/proxy/images/logo.png");
+
+ expect(
+ rewriteProxyUrlReference("./relative.svg", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("./relative.svg");
+
+ expect(
+ rewriteProxyUrlReference("http://localhost:3000/images/logo.png", {
+ browserProxyBase: "/dev-browser/session/dev_1/proxy",
+ targetOrigin: "http://127.0.0.1:8000",
+ port: 8000,
+ })
+ ).toBe("http://localhost:3000/images/logo.png");
+ });
+});
diff --git a/packages/server/src/dev-browser/proxy-headers.ts b/packages/server/src/dev-browser/proxy-headers.ts
new file mode 100644
index 000000000..732d78c06
--- /dev/null
+++ b/packages/server/src/dev-browser/proxy-headers.ts
@@ -0,0 +1,218 @@
+import type { IncomingHttpHeaders } from "node:http";
+
+const HOP_BY_HOP_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+const BLOCKED_REQUEST_HEADERS = new Set(["cookie", "authorization", "origin"]);
+const BLOCKED_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "set-cookie"]);
+const BLOCKED_WEBSOCKET_REQUEST_HEADERS = new Set([
+ ...BLOCKED_REQUEST_HEADERS,
+ "connection",
+ "upgrade",
+ "host",
+ "sec-websocket-key",
+ "sec-websocket-version",
+ "sec-websocket-extensions",
+]);
+
+function normalizeHeaderValue(value: string | string[] | undefined): string | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ return Array.isArray(value) ? value.join(", ") : value;
+}
+
+function appendHeader(target: Headers, key: string, value: string | string[] | undefined): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ target.append(key, entry);
+ }
+ return;
+ }
+
+ target.set(key, value);
+}
+
+export function filterProxyRequestHeaders(
+ headers: IncomingHttpHeaders,
+ targetHost: string,
+ options: { userAgent?: string } = {}
+): Headers {
+ const filtered = new Headers();
+
+ for (const [rawKey, value] of Object.entries(headers)) {
+ const key = rawKey.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_REQUEST_HEADERS.has(key) || key === "host") {
+ continue;
+ }
+
+ appendHeader(filtered, key, value);
+ }
+
+ filtered.set("host", targetHost);
+ if (options.userAgent) {
+ filtered.set("user-agent", options.userAgent);
+ }
+ return filtered;
+}
+
+export function buildProxyWebSocketRequestOptions(headers: IncomingHttpHeaders): {
+ headers: Record;
+ protocols?: string[];
+};
+export function buildProxyWebSocketRequestOptions(
+ headers: IncomingHttpHeaders,
+ options: { userAgent?: string }
+): {
+ headers: Record;
+ protocols?: string[];
+};
+export function buildProxyWebSocketRequestOptions(
+ headers: IncomingHttpHeaders,
+ options: { userAgent?: string } = {}
+): {
+ headers: Record;
+ protocols?: string[];
+} {
+ const filteredHeaders: Record = {};
+ let protocols: string[] | undefined;
+
+ for (const [rawKey, value] of Object.entries(headers)) {
+ const key = rawKey.toLowerCase();
+
+ if (key === "sec-websocket-protocol") {
+ const normalized = normalizeHeaderValue(value);
+ if (normalized) {
+ const parsedProtocols = normalized
+ .split(",")
+ .map((protocol) => protocol.trim())
+ .filter(Boolean);
+
+ if (parsedProtocols.length > 0) {
+ protocols = parsedProtocols;
+ }
+ }
+ continue;
+ }
+
+ if (BLOCKED_WEBSOCKET_REQUEST_HEADERS.has(key)) {
+ continue;
+ }
+
+ const normalized = normalizeHeaderValue(value);
+ if (normalized !== undefined) {
+ filteredHeaders[key] = normalized;
+ }
+ }
+
+ if (options.userAgent) {
+ filteredHeaders["user-agent"] = options.userAgent;
+ }
+
+ return protocols ? { headers: filteredHeaders, protocols } : { headers: filteredHeaders };
+}
+
+export function filterProxyResponseHeaders(headers: Headers): Headers {
+ const filtered = new Headers();
+
+ headers.forEach((value, rawKey) => {
+ const key = rawKey.toLowerCase();
+ if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_RESPONSE_HEADERS.has(key)) {
+ return;
+ }
+
+ filtered.set(key, value);
+ });
+
+ return filtered;
+}
+
+function isLoopbackHostname(hostname: string): boolean {
+ return (
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "::1" ||
+ hostname === "[::1]"
+ );
+}
+
+function effectivePort(url: URL): number {
+ if (url.port) {
+ return Number(url.port);
+ }
+ return url.protocol === "http:" ? 80 : 443;
+}
+
+function toProxiedLoopbackUrl(
+ parsed: URL,
+ input: { browserProxyBase: string; port: number; targetOrigin: string }
+): string | null {
+ const isLoopback =
+ parsed.protocol === "http:" &&
+ effectivePort(parsed) === input.port &&
+ isLoopbackHostname(parsed.hostname);
+
+ if (!isLoopback) {
+ return null;
+ }
+
+ return `${input.browserProxyBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
+}
+
+export function rewriteProxyUrlReference(
+ value: string,
+ input: { browserProxyBase: string; port: number; targetOrigin: string }
+): string {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ return value;
+ }
+
+ if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
+ return `${input.browserProxyBase}${trimmed}`;
+ }
+
+ if (!/^(?:[a-zA-Z][a-zA-Z\d+.-]*:|\/\/)/.test(trimmed)) {
+ return value;
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed, input.targetOrigin);
+ } catch {
+ return value;
+ }
+
+ return toProxiedLoopbackUrl(parsed, input) ?? value;
+}
+
+export function rewriteProxyLocationHeader(
+ location: string,
+ input: { browserProxyBase: string; port: number; targetOrigin: string }
+): string {
+ if (location.startsWith("/") && !location.startsWith("//")) {
+ return `${input.browserProxyBase}${location}`;
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(location, input.targetOrigin);
+ } catch {
+ return location;
+ }
+
+ return toProxiedLoopbackUrl(parsed, input) ?? location;
+}
diff --git a/packages/server/src/dev-browser/session-store.test.ts b/packages/server/src/dev-browser/session-store.test.ts
new file mode 100644
index 000000000..4ec0f67a2
--- /dev/null
+++ b/packages/server/src/dev-browser/session-store.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { DevBrowserSessionStore } from "./session-store.js";
+import type { DevBrowserTarget } from "./target-url.js";
+
+function target(overrides: Partial = {}): DevBrowserTarget {
+ return {
+ connectHost: "127.0.0.1",
+ displayUrl: "http://localhost:8000/app",
+ originalHost: "localhost",
+ port: 8000,
+ targetHash: "",
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app",
+ ...overrides,
+ };
+}
+
+describe("DevBrowserSessionStore", () => {
+ it("creates and reads short-lived sessions", () => {
+ let now = 1_000;
+ const store = new DevBrowserSessionStore({ now: () => now, ttlMs: 10_000 });
+ const session = store.create(target());
+
+ expect(session.id).toMatch(/^dev_/);
+ expect(session.createdAt).toBe(1_000);
+ expect(session.expiresAt).toBe(11_000);
+ expect(store.get(session.id)).toMatchObject({
+ id: session.id,
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app",
+ });
+
+ now = 2_000;
+ expect(store.get(session.id)?.lastAccessedAt).toBe(2_000);
+ });
+
+ it("expires inactive sessions", () => {
+ let now = 1_000;
+ const store = new DevBrowserSessionStore({ now: () => now, ttlMs: 500 });
+ const session = store.create(target());
+
+ now = 1_501;
+
+ expect(store.get(session.id)).toBeNull();
+ });
+
+ it("deletes sessions explicitly", () => {
+ const store = new DevBrowserSessionStore({ now: () => 1_000, ttlMs: 10_000 });
+ const session = store.create(target());
+
+ expect(store.delete(session.id)).toBe(true);
+ expect(store.get(session.id)).toBeNull();
+ });
+});
diff --git a/packages/server/src/dev-browser/session-store.ts b/packages/server/src/dev-browser/session-store.ts
new file mode 100644
index 000000000..536c4a4bb
--- /dev/null
+++ b/packages/server/src/dev-browser/session-store.ts
@@ -0,0 +1,75 @@
+import { randomUUID } from "node:crypto";
+import type { DevBrowserTarget } from "./target-url.js";
+
+export interface DevBrowserSession extends DevBrowserTarget {
+ createdAt: number;
+ expiresAt: number;
+ id: string;
+ lastAccessedAt: number;
+ preserveStudioPlatformPaths?: boolean;
+ userAgent?: string;
+}
+
+export interface DevBrowserSessionStoreOptions {
+ now?: () => number;
+ ttlMs?: number;
+}
+
+export interface CreateDevBrowserSessionInput extends DevBrowserTarget {
+ userAgent?: string;
+}
+
+const DEFAULT_TTL_MS = 30 * 60 * 1_000;
+
+function cloneSession(session: DevBrowserSession): DevBrowserSession {
+ return { ...session };
+}
+
+export class DevBrowserSessionStore {
+ readonly #now: () => number;
+ readonly #ttlMs: number;
+ readonly #sessions = new Map();
+
+ constructor(options: DevBrowserSessionStoreOptions = {}) {
+ this.#now = options.now ?? (() => Date.now());
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
+ }
+
+ create(target: CreateDevBrowserSessionInput): DevBrowserSession {
+ const now = this.#now();
+ const session: DevBrowserSession = {
+ ...target,
+ id: `dev_${randomUUID()}`,
+ createdAt: now,
+ lastAccessedAt: now,
+ expiresAt: now + this.#ttlMs,
+ };
+ this.#sessions.set(session.id, session);
+ return cloneSession(session);
+ }
+
+ get(id: string): DevBrowserSession | null {
+ const session = this.#sessions.get(id);
+ if (!session) {
+ return null;
+ }
+
+ const now = this.#now();
+ if (session.expiresAt <= now) {
+ this.#sessions.delete(id);
+ return null;
+ }
+
+ const nextSession: DevBrowserSession = {
+ ...session,
+ lastAccessedAt: now,
+ expiresAt: now + this.#ttlMs,
+ };
+ this.#sessions.set(id, nextSession);
+ return cloneSession(nextSession);
+ }
+
+ delete(id: string): boolean {
+ return this.#sessions.delete(id);
+ }
+}
diff --git a/packages/server/src/dev-browser/target-url.test.ts b/packages/server/src/dev-browser/target-url.test.ts
new file mode 100644
index 000000000..eb75c38b6
--- /dev/null
+++ b/packages/server/src/dev-browser/target-url.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { DevBrowserTargetUrlError, parseDevBrowserTargetUrl } from "./target-url.js";
+
+describe("parseDevBrowserTargetUrl", () => {
+ it("accepts loopback HTTP URLs with explicit ports", () => {
+ expect(parseDevBrowserTargetUrl("http://localhost:8000/app?draft=1#top")).toMatchObject({
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/app?draft=1",
+ targetHash: "#top",
+ connectHost: "127.0.0.1",
+ port: 8000,
+ });
+
+ expect(parseDevBrowserTargetUrl("http://127.0.0.1:5173/")).toMatchObject({
+ targetOrigin: "http://127.0.0.1:5173",
+ targetPath: "/",
+ targetHash: "",
+ connectHost: "127.0.0.1",
+ port: 5173,
+ });
+
+ expect(parseDevBrowserTargetUrl("http://[::1]:3000/docs")).toMatchObject({
+ targetOrigin: "http://[::1]:3000",
+ targetPath: "/docs",
+ targetHash: "",
+ connectHost: "::1",
+ port: 3000,
+ });
+
+ expect(parseDevBrowserTargetUrl("http://localhost:80/")).toMatchObject({
+ targetOrigin: "http://127.0.0.1:80",
+ targetPath: "/",
+ targetHash: "",
+ connectHost: "127.0.0.1",
+ port: 80,
+ });
+ });
+
+ it("normalizes manual input without an explicit protocol to HTTP", () => {
+ expect(parseDevBrowserTargetUrl("localhost:8000")).toMatchObject({
+ displayUrl: "http://localhost:8000/",
+ targetOrigin: "http://127.0.0.1:8000",
+ targetPath: "/",
+ });
+ });
+
+ it("rejects non-loopback and unsafe targets", () => {
+ const invalidInputs = [
+ "https://localhost:8000",
+ "http://localhost",
+ "http://localhost:0",
+ "http://localhost:8000@evil.test",
+ "http://user:pass@localhost:8000",
+ "http://192.168.1.20:8000",
+ "http://example.com:8000",
+ "http://127.1:8000",
+ "http://2130706433:8000",
+ "http://0x7f000001:8000",
+ "http://[0:0:0:0:0:0:0:1]:8000",
+ "file:///tmp/index.html",
+ "",
+ ];
+
+ for (const input of invalidInputs) {
+ expect(() => parseDevBrowserTargetUrl(input), input).toThrow(DevBrowserTargetUrlError);
+ }
+ });
+});
diff --git a/packages/server/src/dev-browser/target-url.ts b/packages/server/src/dev-browser/target-url.ts
new file mode 100644
index 000000000..834f1b6e9
--- /dev/null
+++ b/packages/server/src/dev-browser/target-url.ts
@@ -0,0 +1,108 @@
+export class DevBrowserTargetUrlError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "DevBrowserTargetUrlError";
+ }
+}
+
+export interface DevBrowserTarget {
+ connectHost: "127.0.0.1" | "::1";
+ displayUrl: string;
+ originalHost: "localhost" | "127.0.0.1" | "[::1]";
+ port: number;
+ targetHash: string;
+ targetOrigin: string;
+ targetPath: string;
+}
+
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+interface RawTargetAuthority {
+ host: "localhost" | "127.0.0.1" | "[::1]";
+ port: number;
+}
+
+function withDefaultProtocol(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ throw new DevBrowserTargetUrlError("empty_url");
+ }
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
+}
+
+function parsePort(portText: string): number {
+ if (!portText) {
+ throw new DevBrowserTargetUrlError("missing_port");
+ }
+
+ const port = Number(portText);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new DevBrowserTargetUrlError("invalid_port");
+ }
+
+ return port;
+}
+
+function parseRawAuthority(input: string): RawTargetAuthority {
+ const authority = input.slice("http://".length).split(/[/?#]/, 1)[0] ?? "";
+
+ if (authority.includes("@")) {
+ throw new DevBrowserTargetUrlError("credentials_not_allowed");
+ }
+
+ let match: RegExpExecArray | null;
+ if (authority.startsWith("[")) {
+ match = /^(\[[^\]]+\]):(\d+)$/.exec(authority);
+ } else {
+ match = /^([^:]+):(\d+)$/.exec(authority);
+ }
+
+ if (!match) {
+ throw new DevBrowserTargetUrlError("missing_port");
+ }
+
+ const host = match[1];
+ const portText = match[2];
+ if (host === undefined || portText === undefined) {
+ throw new DevBrowserTargetUrlError("invalid_url");
+ }
+
+ if (!LOOPBACK_HOSTS.has(host)) {
+ throw new DevBrowserTargetUrlError("host_not_allowed");
+ }
+
+ return {
+ host: host as "localhost" | "127.0.0.1" | "[::1]",
+ port: parsePort(portText),
+ };
+}
+
+export function parseDevBrowserTargetUrl(input: string): DevBrowserTarget {
+ const normalizedInput = withDefaultProtocol(input);
+
+ let url: URL;
+ try {
+ url = new URL(normalizedInput);
+ } catch {
+ throw new DevBrowserTargetUrlError("invalid_url");
+ }
+
+ if (url.protocol !== "http:") {
+ throw new DevBrowserTargetUrlError("unsupported_protocol");
+ }
+
+ const { host, port } = parseRawAuthority(normalizedInput);
+ const connectHost = host === "[::1]" ? "::1" : "127.0.0.1";
+ const targetOrigin = connectHost === "::1" ? `http://[::1]:${port}` : `http://127.0.0.1:${port}`;
+ const targetPath = `${url.pathname || "/"}${url.search}`;
+
+ return {
+ connectHost,
+ displayUrl: url.href,
+ originalHost: host,
+ port,
+ targetHash: url.hash,
+ targetOrigin,
+ targetPath,
+ };
+}
diff --git a/packages/server/src/extension-state/workspace-extension-state-service.ts b/packages/server/src/extension-state/workspace-extension-state-service.ts
deleted file mode 100644
index d900e1610..000000000
--- a/packages/server/src/extension-state/workspace-extension-state-service.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import type {
- WorkspaceExtensionStateView,
- WorkspaceLogEntryView,
- WorkspaceProgressView,
- WorkspaceQuickActionView,
- WorkspaceStatusPillView,
-} from "@coder-studio/core";
-import type { EventBus } from "../bus/event-bus.js";
-import type { WorkspaceExtensionStateRepo } from "../storage/repositories/workspace-extension-state-repo.js";
-
-export interface WorkspaceExtensionStateServiceOptions {
- repo: WorkspaceExtensionStateRepo;
- eventBus: EventBus;
- now?: () => number;
-}
-
-export type SetWorkspaceStatusPillInput = Omit & {
- workspaceId: string;
-};
-
-export interface ClearWorkspaceStatusPillInput {
- workspaceId: string;
- key: string;
-}
-
-export type SetWorkspaceProgressInput = Omit & {
- workspaceId: string;
-};
-
-export interface ClearWorkspaceProgressInput {
- workspaceId: string;
- key: string;
-}
-
-export type AppendWorkspaceLogInput = Omit & {
- workspaceId: string;
- timestamp?: number;
-};
-
-export interface ClearWorkspaceLogInput {
- workspaceId: string;
- key?: string;
-}
-
-export type SetWorkspaceQuickActionInput = WorkspaceQuickActionView & {
- workspaceId: string;
-};
-
-export interface ClearWorkspaceQuickActionInput {
- workspaceId: string;
- id: string;
-}
-
-function upsertBy(items: T[], predicate: (item: T) => boolean, next: T): T[] {
- const existingIndex = items.findIndex(predicate);
- if (existingIndex === -1) {
- return [...items, next];
- }
-
- const updated = [...items];
- updated[existingIndex] = next;
- return updated;
-}
-
-export class WorkspaceExtensionStateService {
- private readonly repo: WorkspaceExtensionStateRepo;
- private readonly eventBus: EventBus;
- private readonly now: () => number;
-
- constructor(input: WorkspaceExtensionStateServiceOptions) {
- this.repo = input.repo;
- this.eventBus = input.eventBus;
- this.now = input.now ?? (() => Date.now());
- }
-
- get(workspaceId: string): WorkspaceExtensionStateView {
- return this.repo.get(workspaceId);
- }
-
- setStatusPill(input: SetWorkspaceStatusPillInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- const next = this.saveAndEmit({
- ...state,
- statusPills: upsertBy(state.statusPills, (item) => item.key === input.key, {
- key: input.key,
- label: input.label,
- state: input.state,
- detail: input.detail,
- updatedAt: timestamp,
- }),
- updatedAt: timestamp,
- });
- return next;
- }
-
- clearStatusPill(input: ClearWorkspaceStatusPillInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- statusPills: state.statusPills.filter((item) => item.key !== input.key),
- updatedAt: timestamp,
- });
- }
-
- setProgress(input: SetWorkspaceProgressInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- progress: upsertBy(state.progress, (item) => item.key === input.key, {
- key: input.key,
- label: input.label,
- value: input.value,
- max: input.max,
- detail: input.detail,
- updatedAt: timestamp,
- }),
- updatedAt: timestamp,
- });
- }
-
- clearProgress(input: ClearWorkspaceProgressInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- progress: state.progress.filter((item) => item.key !== input.key),
- updatedAt: timestamp,
- });
- }
-
- appendLog(input: AppendWorkspaceLogInput): WorkspaceExtensionStateView {
- const timestamp = input.timestamp ?? this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- logs: [
- ...state.logs,
- {
- key: input.key,
- level: input.level,
- message: input.message,
- timestamp,
- },
- ],
- updatedAt: timestamp,
- });
- }
-
- clearLog(input: ClearWorkspaceLogInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- logs: input.key ? state.logs.filter((item) => item.key !== input.key) : [],
- updatedAt: timestamp,
- });
- }
-
- setQuickAction(input: SetWorkspaceQuickActionInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- quickActions: upsertBy(state.quickActions, (item) => item.id === input.id, {
- id: input.id,
- label: input.label,
- command: input.command,
- description: input.description,
- }),
- updatedAt: timestamp,
- });
- }
-
- clearQuickAction(input: ClearWorkspaceQuickActionInput): WorkspaceExtensionStateView {
- const timestamp = this.now();
- const state = this.repo.get(input.workspaceId);
- return this.saveAndEmit({
- ...state,
- quickActions: state.quickActions.filter((item) => item.id !== input.id),
- updatedAt: timestamp,
- });
- }
-
- private saveAndEmit(state: WorkspaceExtensionStateView): WorkspaceExtensionStateView {
- const saved = this.repo.save(state);
- this.eventBus.emit({
- type: "workspace.extension_state.changed",
- workspaceId: saved.workspaceId,
- state: saved,
- });
- return saved;
- }
-}
diff --git a/packages/server/src/preview/session-store.test.ts b/packages/server/src/preview/session-store.test.ts
index 7eb154987..c3a3823cf 100644
--- a/packages/server/src/preview/session-store.test.ts
+++ b/packages/server/src/preview/session-store.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { PreviewSessionStore } from "./session-store.js";
describe("PreviewSessionStore", () => {
- it("creates sessions at revision 1 and increments revisions on update", () => {
+ it("defaults HTML sessions to allow scripts and increments revisions on update", () => {
const store = new PreviewSessionStore();
const created = store.create({
@@ -13,7 +13,7 @@ describe("PreviewSessionStore", () => {
});
expect(created.revision).toBe(1);
- expect(created.allowScripts).toBe(false);
+ expect(created.allowScripts).toBe(true);
const updated = store.update(created.id, {
content: "two ",
@@ -26,6 +26,37 @@ describe("PreviewSessionStore", () => {
});
});
+ it("keeps markdown scripts disabled and honors explicit HTML script overrides", () => {
+ const store = new PreviewSessionStore();
+
+ const markdown = store.create({
+ workspaceId: "ws-1",
+ entryPath: "README.md",
+ kind: "markdown",
+ content: "# hi",
+ });
+ const markdownWithScriptsRequested = store.create({
+ workspaceId: "ws-1",
+ entryPath: "docs/guide/intro.md",
+ kind: "markdown",
+ content: "# hi",
+ allowScripts: true,
+ });
+ const htmlWithScriptsDisabled = store.create({
+ workspaceId: "ws-1",
+ entryPath: "docs/guide/index.html",
+ kind: "html",
+ content: "hi ",
+ allowScripts: false,
+ });
+ const updatedMarkdown = store.update(markdown.id, { allowScripts: true });
+
+ expect(markdown.allowScripts).toBe(false);
+ expect(markdownWithScriptsRequested.allowScripts).toBe(false);
+ expect(updatedMarkdown?.allowScripts).toBe(false);
+ expect(htmlWithScriptsDisabled.allowScripts).toBe(false);
+ });
+
it("deletes sessions and treats missing ids as misses", () => {
const store = new PreviewSessionStore();
const created = store.create({
diff --git a/packages/server/src/preview/session-store.ts b/packages/server/src/preview/session-store.ts
index 6e2c7cdd4..3b09ac91a 100644
--- a/packages/server/src/preview/session-store.ts
+++ b/packages/server/src/preview/session-store.ts
@@ -30,6 +30,10 @@ function cloneRecord(record: PreviewSessionRecord): PreviewSessionRecord {
return { ...record };
}
+function resolveAllowScripts(kind: PreviewKind, allowScripts: boolean | undefined): boolean {
+ return kind === "html" && (allowScripts ?? true);
+}
+
export class PreviewSessionStore {
#sessions = new Map();
@@ -42,7 +46,7 @@ export class PreviewSessionStore {
content: input.content,
revision: 1,
updatedAt: Date.now(),
- allowScripts: input.allowScripts ?? false,
+ allowScripts: resolveAllowScripts(input.kind, input.allowScripts),
};
this.#sessions.set(record.id, cloneRecord(record));
@@ -63,7 +67,7 @@ export class PreviewSessionStore {
const next: PreviewSessionRecord = {
...current,
content: patch.content ?? current.content,
- allowScripts: patch.allowScripts ?? current.allowScripts,
+ allowScripts: resolveAllowScripts(current.kind, patch.allowScripts ?? current.allowScripts),
revision: current.revision + 1,
updatedAt: Date.now(),
};
diff --git a/packages/server/src/provider-runtime/command-runner.ts b/packages/server/src/provider-runtime/command-runner.ts
index 6df0bcbf0..ffc01b384 100644
--- a/packages/server/src/provider-runtime/command-runner.ts
+++ b/packages/server/src/provider-runtime/command-runner.ts
@@ -1,11 +1,17 @@
import { spawn } from "node:child_process";
-import { shouldUseShellForCommand } from "@coder-studio/utils";
+import {
+ type HeadlessSpawnCommand,
+ prepareHeadlessSpawnCommand,
+ shouldUseShellForCommand,
+} from "@coder-studio/utils";
export type CommandRunnerOptions = {
windowsHide?: boolean;
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
+ /** When set, long prompts are delivered via stdin instead of argv. */
+ prompt?: string;
};
export interface CommandRunnerResult {
@@ -24,15 +30,31 @@ export async function runCommandAsString(
args: string[],
options?: CommandRunnerOptions
): Promise {
+ const baseCommand: HeadlessSpawnCommand = {
+ argv: [file, ...args],
+ cwd: options?.cwd,
+ };
+ const prepared = options?.prompt
+ ? prepareHeadlessSpawnCommand(baseCommand, options.prompt)
+ : baseCommand;
+
return new Promise((resolve, reject) => {
- const child = spawn(file, args, {
- cwd: options?.cwd,
+ const stdio: ["pipe" | "ignore", "pipe", "pipe"] =
+ prepared.stdin !== undefined ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"];
+
+ const child = spawn(prepared.argv[0]!, prepared.argv.slice(1), {
+ cwd: prepared.cwd ?? options?.cwd,
env: options?.env,
- shell: shouldUseShellForCommand(file, process.platform),
- stdio: ["ignore", "pipe", "pipe"],
+ shell: shouldUseShellForCommand(prepared.argv[0]!, process.platform),
+ stdio,
windowsHide: options?.windowsHide ?? true,
});
+ if (prepared.stdin !== undefined && child.stdin) {
+ child.stdin.on("error", () => {});
+ child.stdin.end(prepared.stdin);
+ }
+
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let settled = false;
diff --git a/packages/server/src/routes/dev-browser.test.ts b/packages/server/src/routes/dev-browser.test.ts
new file mode 100644
index 000000000..b8d4cd114
--- /dev/null
+++ b/packages/server/src/routes/dev-browser.test.ts
@@ -0,0 +1,729 @@
+import {
+ type AddressInfo,
+ createServer as createNetServer,
+ type Socket as NetSocket,
+} from "node:net";
+import websocket from "@fastify/websocket";
+import Fastify from "fastify";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { WebSocket as ClientWebSocket, type RawData, type WebSocket as WsWebSocket } from "ws";
+import { DevBrowserSessionStore } from "../dev-browser/session-store.js";
+import { registerDevBrowserRoutes } from "./dev-browser.js";
+
+function waitForMessage(socket: WsWebSocket): Promise<{ data: RawData; isBinary: boolean }> {
+ return new Promise((resolve, reject) => {
+ const handleMessage = (data: RawData, isBinary: boolean) => {
+ cleanup();
+ resolve({ data, isBinary });
+ };
+ const handleError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+ const handleClose = (code: number, reason: Buffer) => {
+ cleanup();
+ reject(new Error(`socket closed before message: ${code} ${reason.toString()}`));
+ };
+ const cleanup = () => {
+ socket.off("message", handleMessage);
+ socket.off("error", handleError);
+ socket.off("close", handleClose);
+ };
+
+ socket.on("message", handleMessage);
+ socket.on("error", handleError);
+ socket.on("close", handleClose);
+ });
+}
+
+function waitForClose(socket: WsWebSocket): Promise<{ code: number; reason: string }> {
+ return new Promise((resolve, reject) => {
+ const handleClose = (code: number, reason: Buffer) => {
+ cleanup();
+ resolve({ code, reason: reason.toString() });
+ };
+ const handleError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+ const cleanup = () => {
+ socket.off("close", handleClose);
+ socket.off("error", handleError);
+ };
+
+ socket.on("close", handleClose);
+ socket.on("error", handleError);
+ });
+}
+
+async function waitForExpectation(
+ check: () => void,
+ timeoutMs = 1000,
+ intervalMs = 20
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ try {
+ check();
+ return;
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ }
+
+ check();
+}
+
+describe("dev browser routes", () => {
+ let target: ReturnType;
+ let app: ReturnType;
+ let targetOrigin: string;
+ let targetPort: number;
+ let observedUpstreamClose: { code: number; reason: string } | null;
+ let observedHttpUserAgent: string | undefined;
+ let observedWsUserAgent: string | undefined;
+
+ beforeEach(async () => {
+ observedUpstreamClose = null;
+ observedHttpUserAgent = undefined;
+ observedWsUserAgent = undefined;
+ target = Fastify({ logger: false });
+ await target.register(websocket, {
+ options: {
+ handleProtocols: (protocols, request) => {
+ if (request.url?.startsWith("/ws-protocol")) {
+ return protocols.has("superjson") ? "superjson" : protocols.values().next().value;
+ }
+ return protocols.values().next().value;
+ },
+ },
+ });
+ target.addContentTypeParser(
+ "application/x-www-form-urlencoded",
+ { parseAs: "buffer" },
+ (_request, body, done) => {
+ done(null, body);
+ }
+ );
+ target.get("/app/", async (_request, reply) =>
+ reply
+ .type("text/html")
+ .send(
+ 'docs '
+ )
+ );
+ target.get("/vite/", async (_request, reply) =>
+ reply.type("text/html").send(`
+
+
+
+
+
+
+
+
+
+`)
+ );
+ target.get("/studio/", async (_request, reply) =>
+ reply.type("text/html").send(`
+
+
+
+
+ Coder Studio
+
+
+
+
+
+`)
+ );
+ target.get("/assets/app.css", async (_request, reply) =>
+ reply
+ .type("text/css")
+ .send(
+ `body{background-image:url("/images/bg.png")} .logo{background-image:url("http://localhost:${targetPort}/images/abs.png")} .icon{background-image:url(./icon.svg)}`
+ )
+ );
+ target.get("/@vite/client", async (_request, reply) =>
+ reply
+ .type("application/javascript")
+ .send('import "/src/vite-setup.ts"; export const base = "/";')
+ );
+ target.get("/assets/app.js", async (_request, reply) =>
+ reply.type("application/javascript").send("window.loaded = true;")
+ );
+ target.get("/observe-user-agent", async (request) => {
+ observedHttpUserAgent = request.headers["user-agent"];
+ return { userAgent: observedHttpUserAgent ?? null };
+ });
+ target.get("/ws", { websocket: true }, (socket) => {
+ socket.on("message", (message, isBinary) => {
+ socket.send(message, { binary: isBinary });
+ });
+ });
+ target.get("/ws-observe-user-agent", { websocket: true }, (socket, request) => {
+ observedWsUserAgent = request.headers["user-agent"];
+ socket.send(`user-agent:${observedWsUserAgent ?? ""}`);
+ });
+ target.get("/ws-protocol", { websocket: true }, (socket) => {
+ socket.send(`protocol:${socket.protocol}`);
+ socket.on("message", (message, isBinary) => {
+ socket.send(message, { binary: isBinary });
+ });
+ });
+ target.get("/ws-close", { websocket: true }, (socket) => {
+ socket.close(4001, "upstream_done");
+ });
+ target.get("/ws-observe-close", { websocket: true }, (socket) => {
+ socket.send("ready");
+ socket.on("close", (code, reason) => {
+ observedUpstreamClose = {
+ code,
+ reason: reason.toString(),
+ };
+ });
+ });
+ target.get("/src/main.tsx", async (_request, reply) =>
+ reply
+ .type("application/javascript")
+ .send(`import React from "/node_modules/.vite/deps/react.js?v=1";
+import App from "/src/App.tsx";
+const lazy = () => import("/src/lazy.tsx");
+console.log(React, App, lazy);`)
+ );
+ target.post("/api/echo", async (request) => request.body);
+ target.post("/api/form", async (request) => ({
+ body: Buffer.isBuffer(request.body)
+ ? request.body.toString("utf-8")
+ : String(request.body ?? ""),
+ contentType: request.headers["content-type"],
+ }));
+ target.get("/redirect", async (_request, reply) => reply.redirect("/app/"));
+ target.get("/*", async (request) => ({ url: request.url }));
+ await target.listen({ host: "127.0.0.1", port: 0 });
+
+ const address = target.server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("target listen failed");
+ }
+ targetPort = address.port;
+ targetOrigin = `http://127.0.0.1:${address.port}`;
+
+ app = Fastify({ logger: false });
+ await app.register(websocket);
+ registerDevBrowserRoutes(app, {
+ sessions: new DevBrowserSessionStore(),
+ });
+ await app.ready();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ await target.close();
+ });
+
+ async function createSession(
+ path = "/app/",
+ options: {
+ userAgent?: string;
+ } = {}
+ ) {
+ const response = await app.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: `${targetOrigin}${path}`, ...options },
+ });
+ expect(response.statusCode).toBe(200);
+ return response.json() as {
+ browserProxyBase: string;
+ browserUrl: string;
+ id: string;
+ targetOrigin: string;
+ };
+ }
+
+ it("creates sessions for loopback targets", async () => {
+ const created = await createSession();
+
+ expect(created.id).toMatch(/^dev_/);
+ expect(created.browserUrl).toBe(`/dev-browser/session/${created.id}/`);
+ expect(created.browserProxyBase).toBe(`/dev-browser/session/${created.id}/proxy`);
+ expect(created.targetOrigin).toBe(targetOrigin);
+ });
+
+ it("rejects invalid session targets", async () => {
+ const response = await app.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: "http://example.com:8000" },
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.json()).toMatchObject({ error: "invalid_dev_browser_target" });
+ });
+
+ it("serves shell HTML that registers the service worker", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: created.browserUrl,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("/dev-browser-sw.js");
+ expect(response.body).toContain(created.browserProxyBase);
+ expect(response.body).toContain(
+ "window.location.replace(session.browserProxyBase + session.targetPath + session.targetHash)"
+ );
+ expect(response.body).toContain(
+ "window.location.replace(session.browserProxyBase + session.targetPath + session.targetHash)"
+ );
+ });
+
+ it("proxies HTML and injects a websocket failure warning bootstrap without blocking page load", async () => {
+ const userAgent =
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1";
+ const created = await createSession("/app/", { userAgent });
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/app/`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("Coder Studio dev browser could not connect to a WebSocket");
+ expect(response.body).toContain("coder-studio-dev-browser-websocket-warning");
+ expect(response.body).toContain("function toProxyWebSocketUrl(value, base)");
+ expect(response.body).toContain(
+ "const proxyUrl = resolvedUrl ?? toProxyWebSocketUrl(value, window.location.href);"
+ );
+ expect(response.body).toContain("function isCoderStudioPlatformPath(pathname) {");
+ expect(response.body).toContain('pathname === "/healthz"');
+ expect(response.body).toContain('pathname === "/ws"');
+ expect(response.body).toContain('pathname.startsWith("/auth/")');
+ expect(response.body).toContain('pathname.startsWith("/api/")');
+ expect(response.body).toContain("const currentPort =");
+ expect(response.body).toContain("const samePortAsStudio = currentPort === targetPort;");
+ const httpProxyStart = response.body.indexOf("function shouldProxyUrl(url) {");
+ const httpProxyEnd = response.body.indexOf("function shouldProxyWebSocketUrl(url) {");
+ const httpProxyBody = response.body.slice(httpProxyStart, httpProxyEnd);
+ expect(httpProxyBody).toContain(
+ "if ((samePortAsStudio || preserveStudioPlatformPaths) && isCoderStudioPlatformPath(url.pathname)) {"
+ );
+ const webSocketProxyStart = response.body.indexOf("function shouldProxyWebSocketUrl(url) {");
+ const webSocketProxyEnd = response.body.indexOf("function toProxyUrl(value, base) {");
+ const webSocketProxyBody = response.body.slice(webSocketProxyStart, webSocketProxyEnd);
+ expect(webSocketProxyBody).toContain("if (url.host === window.location.host) {");
+ expect(webSocketProxyBody).toContain('return !url.pathname.startsWith("/dev-browser/");');
+ expect(response.body).toContain('window.location.protocol === "https:" ? "wss:" : "ws:"');
+ expect(response.body).toContain("const navigatorOverrides = {");
+ expect(response.body).toContain("userAgent: navigatorUserAgent");
+ expect(response.body).toContain(userAgent);
+ expect(response.body).toContain("platform: /iPhone|iPad|iPod/.test(navigatorUserAgent)");
+ expect(response.body).toContain("maxTouchPoints: /Mobile|Android|iPhone|iPad|iPod/.test(");
+ expect(response.body).toContain("Object.defineProperty(window.navigator, key, {");
+ expect(response.body).not.toContain("throw new Error");
+ expect(response.body).toContain(
+ ``
+ );
+ expect(response.body).toContain(
+ ` `
+ );
+ expect(response.body).toContain(` `);
+ expect(response.body).toContain(`docs `);
+ expect(response.body).toContain(
+ ``
+ );
+ });
+
+ it("rewrites css url references for root-relative and loopback absolute assets", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/assets/app.css`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/css");
+ expect(response.body).toContain(`url("${created.browserProxyBase}/images/bg.png")`);
+ expect(response.body).toContain(`url("${created.browserProxyBase}/images/abs.png")`);
+ expect(response.body).toContain("url(./icon.svg)");
+ });
+
+ it("rewrites inline module imports in html for fallback mode", async () => {
+ const created = await createSession("/vite/");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/vite/`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain(
+ `import { injectIntoGlobalHook } from "${created.browserProxyBase}/@react-refresh";`
+ );
+ expect(response.body).toContain(
+ ``
+ );
+ expect(response.body).toContain(
+ ``
+ );
+ expect(response.body.indexOf("function toProxyWebSocketUrl(value, base)")).toBeGreaterThan(-1);
+ expect(response.body.indexOf("function toProxyWebSocketUrl(value, base)")).toBeLessThan(
+ response.body.indexOf(
+ ``
+ )
+ );
+ });
+
+ it("preserves coder studio platform paths in fallback mode for cross-port studio previews", async () => {
+ const created = await createSession("/studio/");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/studio/`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("preserveStudioPlatformPaths");
+ expect(response.body).toContain(
+ "if ((samePortAsStudio || preserveStudioPlatformPaths) && isCoderStudioPlatformPath(url.pathname)) {"
+ );
+ expect(response.body).toContain("function toPreservedStudioWebSocketUrl(value, base) {");
+ expect(response.body).toContain(
+ 'if (!preserveStudioPlatformPaths || !loopbackHosts.has(parsed.hostname) || parsed.pathname !== "/ws") {'
+ );
+ expect(response.body).toContain(
+ "const resolvedUrl = toPreservedStudioWebSocketUrl(value, window.location.href);"
+ );
+ expect(response.body).toContain(
+ "const proxyUrl = resolvedUrl ?? toProxyWebSocketUrl(value, window.location.href);"
+ );
+ });
+
+ it("keeps the visible history path on the target route in fallback mode", async () => {
+ const created = await createSession("/vite/");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/vite/`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toContain('const visiblePath = session.targetPath || "/";');
+ expect(response.body).toContain(
+ 'nativeReplaceState(window.history.state, "", visiblePath + window.location.hash);'
+ );
+ expect(response.body).not.toContain("coderStudioDevBrowserPushState");
+ expect(response.body).not.toContain("coderStudioDevBrowserReplaceState");
+ });
+
+ it("rewrites absolute esm imports inside javascript module responses", async () => {
+ const created = await createSession("/vite/");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/src/main.tsx`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("javascript");
+ expect(response.body).toContain(
+ `import React from "${created.browserProxyBase}/node_modules/.vite/deps/react.js?v=1";`
+ );
+ expect(response.body).toContain(`import App from "${created.browserProxyBase}/src/App.tsx";`);
+ expect(response.body).toContain(`import("${created.browserProxyBase}/src/lazy.tsx")`);
+ });
+
+ it("proxies static assets and JSON posts", async () => {
+ const created = await createSession();
+ const asset = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/assets/app.js`,
+ });
+ const post = await app.inject({
+ method: "POST",
+ url: `${created.browserProxyBase}/api/echo`,
+ headers: { "content-type": "application/json" },
+ payload: { ok: true },
+ });
+
+ expect(asset.statusCode).toBe(200);
+ expect(asset.headers["content-type"]).toContain("javascript");
+ expect(asset.body).toContain("window.loaded");
+ expect(post.statusCode).toBe(200);
+ expect(post.json()).toEqual({ ok: true });
+ });
+
+ it("proxies non-JSON request bodies without rewriting them", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "POST",
+ url: `${created.browserProxyBase}/api/form`,
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ payload: "name=a%2Bb&ok=1",
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({
+ body: "name=a%2Bb&ok=1",
+ contentType: "application/x-www-form-urlencoded",
+ });
+ });
+
+ it("forwards the session user-agent on proxied http requests", async () => {
+ const userAgent =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36";
+ const created = await createSession("/observe-user-agent", { userAgent });
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/observe-user-agent`,
+ headers: {
+ "user-agent": "Coder Studio Test Browser",
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({ userAgent });
+ expect(observedHttpUserAgent).toBe(userAgent);
+ });
+
+ it("keeps protocol-relative-looking proxy paths on the session target", async () => {
+ const created = await createSession();
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}//127.0.0.1:1/escape`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({ url: "//127.0.0.1:1/escape" });
+ });
+
+ it("rewrites loopback redirects to browser proxy paths", async () => {
+ const created = await createSession("/redirect");
+ const response = await app.inject({
+ method: "GET",
+ url: `${created.browserProxyBase}/redirect`,
+ });
+
+ expect(response.statusCode).toBeGreaterThanOrEqual(300);
+ expect(response.statusCode).toBeLessThan(400);
+ expect(response.headers.location).toBe(`${created.browserProxyBase}/app/`);
+ });
+
+ it("proxies websocket text and binary frames", async () => {
+ const created = await createSession();
+ const socket = await app.injectWS(`${created.browserProxyBase}/ws`);
+ const textResponsePromise = waitForMessage(socket);
+ socket.send("hello over proxy");
+ const textResponse = await textResponsePromise;
+
+ expect(textResponse.isBinary).toBe(false);
+ expect(textResponse.data.toString()).toBe("hello over proxy");
+
+ const binaryPayload = Buffer.from([0x00, 0x01, 0x02, 0x03]);
+ const binaryResponsePromise = waitForMessage(socket);
+ socket.send(binaryPayload, { binary: true });
+ const binaryResponse = await binaryResponsePromise;
+
+ expect(binaryResponse.isBinary).toBe(true);
+ expect(Buffer.from(binaryResponse.data)).toEqual(binaryPayload);
+
+ socket.close();
+ await waitForClose(socket);
+ });
+
+ it("forwards the session user-agent on proxied websocket upgrades", async () => {
+ const userAgent =
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/125.0.0.0 Mobile Safari/537.36";
+ const created = await createSession("/app/", { userAgent });
+ const socket = await app.injectWS(`${created.browserProxyBase}/ws-observe-user-agent`);
+
+ await expect(waitForMessage(socket)).resolves.toEqual({
+ data: Buffer.from(`user-agent:${userAgent}`),
+ isBinary: false,
+ });
+ expect(observedWsUserAgent).toBe(userAgent);
+
+ socket.close();
+ await waitForClose(socket);
+ });
+
+ it("propagates upstream websocket closes", async () => {
+ const created = await createSession();
+ const socket = await app.injectWS(`${created.browserProxyBase}/ws-close`);
+
+ await expect(waitForClose(socket)).resolves.toEqual({
+ code: 4001,
+ reason: "upstream_done",
+ });
+ });
+
+ it("closes the browser websocket when the upstream websocket is unavailable", async () => {
+ const created = await createSession();
+ const socket = await app.injectWS(`${created.browserProxyBase}/missing-ws`);
+
+ await expect(waitForClose(socket)).resolves.toEqual({
+ code: 1014,
+ reason: "dev_browser_target_unavailable",
+ });
+ });
+
+ it("proxies the websocket subprotocol selected by the upstream target", async () => {
+ const created = await createSession();
+ await app.listen({ host: "127.0.0.1", port: 0 });
+ const address = app.server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("proxy app listen failed");
+ }
+
+ const socket = await new Promise((resolve, reject) => {
+ const ws = new ClientWebSocket(
+ `ws://127.0.0.1:${address.port}${created.browserProxyBase}/ws-protocol`,
+ ["json", "superjson"]
+ );
+ const firstMessagePromise = waitForMessage(ws);
+
+ ws.once("open", async () => {
+ try {
+ expect(ws.protocol).toBe("superjson");
+ await expect(firstMessagePromise).resolves.toEqual({
+ data: Buffer.from("protocol:superjson"),
+ isBinary: false,
+ });
+ resolve(ws);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ ws.once("error", reject);
+ ws.once("close", (code, reason) =>
+ reject(new Error(`socket closed before open: ${code} ${reason.toString()}`))
+ );
+ ws.once("unexpected-response", (_request, response) =>
+ reject(new Error(`unexpected response: ${response.statusCode}`))
+ );
+ });
+ socket.close();
+ await waitForClose(socket);
+ });
+
+ it("falls back to the first browser websocket protocol when the upstream target does not override it", async () => {
+ const created = await createSession();
+ await app.listen({ host: "127.0.0.1", port: 0 });
+ const address = app.server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("proxy app listen failed");
+ }
+
+ const socket = await new Promise((resolve, reject) => {
+ const ws = new ClientWebSocket(
+ `ws://127.0.0.1:${address.port}${created.browserProxyBase}/ws`,
+ ["json", "superjson"]
+ );
+
+ ws.once("open", () => {
+ try {
+ expect(ws.protocol).toBe("json");
+ resolve(ws);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ ws.once("error", reject);
+ ws.once("close", (code, reason) =>
+ reject(new Error(`socket closed before open: ${code} ${reason.toString()}`))
+ );
+ ws.once("unexpected-response", (_request, response) =>
+ reject(new Error(`unexpected response: ${response.statusCode}`))
+ );
+ });
+
+ const messagePromise = waitForMessage(socket);
+ socket.send("hello over first-protocol proxy");
+ await expect(messagePromise).resolves.toEqual({
+ data: Buffer.from("hello over first-protocol proxy"),
+ isBinary: false,
+ });
+
+ socket.close();
+ await waitForClose(socket);
+ });
+
+ it("bounds buffered browser frames while the upstream websocket is still connecting", async () => {
+ const pendingSockets = new Set();
+ const stalledServer = createNetServer((socket) => {
+ pendingSockets.add(socket);
+ socket.on("close", () => {
+ pendingSockets.delete(socket);
+ });
+ });
+ await new Promise((resolve, reject) => {
+ stalledServer.once("error", reject);
+ stalledServer.listen(0, "127.0.0.1", () => resolve());
+ });
+
+ try {
+ const address = stalledServer.address();
+ if (!address || typeof address === "string") {
+ throw new Error("stalled websocket server failed to bind");
+ }
+
+ const response = await app.inject({
+ method: "POST",
+ url: "/api/dev-proxy/session",
+ payload: { url: `http://127.0.0.1:${(address as AddressInfo).port}/ws-stall` },
+ });
+ expect(response.statusCode).toBe(200);
+
+ const created = response.json() as {
+ browserProxyBase: string;
+ };
+ const socket = await app.injectWS(`${created.browserProxyBase}/ws-stall`);
+
+ socket.send(Buffer.alloc(70 * 1024, 1), { binary: true });
+
+ await expect(waitForClose(socket)).resolves.toEqual({
+ code: 1009,
+ reason: "dev_browser_websocket_buffer_overflow",
+ });
+ } finally {
+ for (const pendingSocket of pendingSockets) {
+ pendingSocket.destroy();
+ }
+ await new Promise((resolve, reject) => {
+ stalledServer.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ }
+ });
+
+ it("closes the upstream websocket cleanly when the browser socket terminates abnormally", async () => {
+ const created = await createSession();
+ const socket = await app.injectWS(`${created.browserProxyBase}/ws-observe-close`);
+ const readyMessage = await waitForMessage(socket);
+
+ expect(readyMessage.isBinary).toBe(false);
+ expect(readyMessage.data.toString()).toBe("ready");
+
+ socket.terminate();
+
+ await waitForExpectation(() => {
+ expect(observedUpstreamClose).not.toBeNull();
+ });
+ });
+
+ it("rejects websocket upgrades for missing dev-browser sessions", async () => {
+ await expect(app.injectWS("/dev-browser/session/missing/proxy/ws")).rejects.toThrow(
+ "Unexpected server response: 404"
+ );
+ });
+});
diff --git a/packages/server/src/routes/dev-browser.ts b/packages/server/src/routes/dev-browser.ts
new file mode 100644
index 000000000..77038df20
--- /dev/null
+++ b/packages/server/src/routes/dev-browser.ts
@@ -0,0 +1,925 @@
+import type { IncomingMessage } from "node:http";
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import { type RawData, WebSocket as WsWebSocket } from "ws";
+import { z } from "zod";
+import {
+ buildProxyWebSocketRequestOptions,
+ filterProxyRequestHeaders,
+ filterProxyResponseHeaders,
+ rewriteProxyLocationHeader,
+ rewriteProxyUrlReference,
+} from "../dev-browser/proxy-headers.js";
+import type { DevBrowserSession } from "../dev-browser/session-store.js";
+import { DevBrowserSessionStore } from "../dev-browser/session-store.js";
+import { DevBrowserTargetUrlError, parseDevBrowserTargetUrl } from "../dev-browser/target-url.js";
+
+const createSessionSchema = z.object({
+ url: z.string().min(1),
+ userAgent: z.string().min(1).optional(),
+});
+const MAX_PENDING_WEBSOCKET_BYTES = 64 * 1024;
+const preparedProxyWebSockets = new WeakMap();
+
+interface PreparedProxyWebSocket {
+ upstream: WsWebSocket;
+ selectedProtocol: string;
+ browserSocket: WsWebSocket | null;
+ closeBrowser: ((code: number, reason: string) => void) | null;
+ bufferedMessages: Array<{ data: RawData; isBinary: boolean }>;
+ bufferedClose: { code: number; reason: Buffer } | null;
+ bufferedError: boolean;
+ upstreamClosed: boolean;
+ upstreamReceivedMessage: boolean;
+}
+
+function browserProxyBase(id: string): string {
+ return `/dev-browser/session/${id}/proxy`;
+}
+
+function browserUrl(id: string): string {
+ return `/dev-browser/session/${id}/`;
+}
+
+function serializeSession(session: DevBrowserSession) {
+ return {
+ id: session.id,
+ browserUrl: browserUrl(session.id),
+ browserProxyBase: browserProxyBase(session.id),
+ displayUrl: session.displayUrl,
+ targetOrigin: session.targetOrigin,
+ targetPath: session.targetPath,
+ targetHash: session.targetHash,
+ expiresAt: session.expiresAt,
+ ...(session.preserveStudioPlatformPaths ? { preserveStudioPlatformPaths: true } : {}),
+ };
+}
+
+function isCoderStudioHtml(html: string): boolean {
+ return (
+ html.includes("Coder Studio ") ||
+ html.includes('content="Coder Studio - Agent-First Development Environment"')
+ );
+}
+
+function escapeJsonForScript(value: unknown): string {
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
+}
+
+function renderDevBrowserShell(session: DevBrowserSession): string {
+ const payload = serializeSession(session);
+ return `
+
+
+
+
+ Coder Studio Dev Browser
+
+
+ Opening local preview...
+
+
+`;
+}
+
+function createHtmlBootstrap(session: DevBrowserSession): string {
+ const payload = serializeSession(session);
+ return ``;
+}
+
+function rewriteHtmlAttributes(html: string, session: DevBrowserSession): string {
+ const rewrite = (value: string) =>
+ rewriteProxyUrlReference(value, {
+ browserProxyBase: browserProxyBase(session.id),
+ port: session.port,
+ targetOrigin: session.targetOrigin,
+ });
+
+ return html.replace(
+ /\b(?:src|href|action)=("([^"]*)"|'([^']*)')/gi,
+ (match, quoted, doubleQuoted, singleQuoted) => {
+ const quote = quoted[0];
+ const value = doubleQuoted ?? singleQuoted ?? "";
+ const nextValue = rewrite(value);
+ return `${match.slice(0, match.indexOf(quoted))}${quote}${nextValue}${quote}`;
+ }
+ );
+}
+
+function rewriteJavaScriptModuleSpecifiers(source: string, session: DevBrowserSession): string {
+ const rewrite = (value: string) =>
+ rewriteProxyUrlReference(value, {
+ browserProxyBase: browserProxyBase(session.id),
+ port: session.port,
+ targetOrigin: session.targetOrigin,
+ });
+
+ const rewriteSpecifier = (quote: string, value: string) => `${quote}${rewrite(value)}${quote}`;
+
+ return source
+ .replace(
+ /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g,
+ (_match, quote: string, value: string) => `import(${rewriteSpecifier(quote, value)})`
+ )
+ .replace(
+ /\b(?:import|export)\s+(?:[^'"]*?\sfrom\s*)?(['"])([^'"]+)\1/g,
+ (match, quote: string, value: string) =>
+ match.replace(`${quote}${value}${quote}`, rewriteSpecifier(quote, value))
+ )
+ .replace(
+ /\bnew\s+URL\s*\(\s*(['"])([^'"]+)\1(\s*,\s*import\.meta\.url\s*\))/g,
+ (_match, quote: string, value: string, suffix: string) =>
+ `new URL(${rewriteSpecifier(quote, value)}${suffix}`
+ );
+}
+
+function rewriteInlineModuleScripts(html: string, session: DevBrowserSession): string {
+ return html.replace(
+ /`;
+ }
+ );
+}
+
+function injectHtmlBootstrap(html: string, session: DevBrowserSession): string {
+ const bootstrap = createHtmlBootstrap(session);
+ const rewrittenHtml = rewriteInlineModuleScripts(rewriteHtmlAttributes(html, session), session);
+ const headMatch = rewrittenHtml.match(/]*>/i);
+ if (headMatch) {
+ const insertAt = headMatch.index! + headMatch[0].length;
+ return `${rewrittenHtml.slice(0, insertAt)}${bootstrap}${rewrittenHtml.slice(insertAt)}`;
+ }
+
+ const bodyMatch = rewrittenHtml.match(/]*>/i);
+ if (bodyMatch) {
+ const insertAt = bodyMatch.index! + bodyMatch[0].length;
+ return `${rewrittenHtml.slice(0, insertAt)}${bootstrap}${rewrittenHtml.slice(insertAt)}`;
+ }
+
+ return `${bootstrap}${rewrittenHtml}`;
+}
+
+function rewriteCssUrls(css: string, session: DevBrowserSession): string {
+ return css.replace(
+ /url\(\s*(['"]?)([^)'"]+)\1\s*\)/gi,
+ (_match, quote: string, value: string) => {
+ const nextValue = rewriteProxyUrlReference(value, {
+ browserProxyBase: browserProxyBase(session.id),
+ port: session.port,
+ targetOrigin: session.targetOrigin,
+ });
+ return `url(${quote}${nextValue}${quote})`;
+ }
+ );
+}
+
+function bufferToBodyInit(buffer: Buffer): BodyInit {
+ const bytes = new Uint8Array(buffer.byteLength);
+ bytes.set(buffer);
+ return bytes;
+}
+
+function resolveProxyTargetUrl(session: DevBrowserSession, request: FastifyRequest): URL {
+ const incoming = new URL(request.url, "http://coder-studio.local");
+ const prefix = `/dev-browser/session/${session.id}/proxy`;
+ const path = incoming.pathname.startsWith(prefix)
+ ? incoming.pathname.slice(prefix.length) || "/"
+ : "/";
+ const targetUrl = new URL(session.targetOrigin);
+ targetUrl.pathname = path.startsWith("/") ? path : `/${path}`;
+ targetUrl.search = incoming.search;
+ return targetUrl;
+}
+
+function toWebSocketTargetUrl(targetUrl: URL): URL {
+ const upstreamUrl = new URL(targetUrl);
+ upstreamUrl.protocol = upstreamUrl.protocol === "https:" ? "wss:" : "ws:";
+ return upstreamUrl;
+}
+
+function getSessionFromRequest(
+ sessions: DevBrowserSessionStore,
+ request: FastifyRequest
+): DevBrowserSession | null {
+ const { id } = request.params as { id: string };
+ return sessions.get(id) ?? null;
+}
+
+function isValidCloseCode(code: number): boolean {
+ return (
+ code === 1000 ||
+ (code >= 3000 && code <= 4999) ||
+ (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006)
+ );
+}
+
+function rawDataByteLength(data: RawData): number {
+ if (Array.isArray(data)) {
+ return data.reduce((total, chunk) => total + chunk.byteLength, 0);
+ }
+
+ return data.byteLength;
+}
+
+function closeBrowserForUpstreamResult(
+ closeBrowser: (code: number, reason: string) => void,
+ result: { code: number; reason: Buffer },
+ upstreamReceivedMessage: boolean
+): void {
+ if (!upstreamReceivedMessage && result.code === 1005 && result.reason.length === 0) {
+ closeBrowser(1014, "dev_browser_target_unavailable");
+ return;
+ }
+
+ closeBrowser(result.code, result.reason.toString());
+}
+
+async function prepareProxyWebSocket(
+ request: FastifyRequest,
+ session: DevBrowserSession
+): Promise {
+ const targetUrl = toWebSocketTargetUrl(resolveProxyTargetUrl(session, request));
+ const { protocols, ...requestOptions } = buildProxyWebSocketRequestOptions(request.headers, {
+ userAgent: session.userAgent,
+ });
+ if (!protocols || protocols.length === 0) {
+ return null;
+ }
+
+ return await new Promise((resolve, reject) => {
+ const upstream = new WsWebSocket(targetUrl, protocols, requestOptions);
+ const prepared: PreparedProxyWebSocket = {
+ upstream,
+ selectedProtocol: "",
+ browserSocket: null,
+ closeBrowser: null,
+ bufferedMessages: [],
+ bufferedClose: null,
+ bufferedError: false,
+ upstreamClosed: false,
+ upstreamReceivedMessage: false,
+ };
+ let upstreamOpened = false;
+ let settled = false;
+
+ const rejectUnavailable = () => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(new Error("dev_browser_target_unavailable"));
+ };
+
+ upstream.on("message", (data, isBinary) => {
+ prepared.upstreamReceivedMessage = true;
+ if (prepared.browserSocket?.readyState === WsWebSocket.OPEN) {
+ prepared.browserSocket.send(data, { binary: isBinary });
+ return;
+ }
+ prepared.bufferedMessages.push({ data, isBinary });
+ });
+
+ upstream.on("close", (code, reason) => {
+ prepared.upstreamClosed = true;
+ if (!upstreamOpened) {
+ rejectUnavailable();
+ return;
+ }
+ if (!prepared.closeBrowser) {
+ prepared.bufferedClose = { code, reason };
+ return;
+ }
+ closeBrowserForUpstreamResult(
+ prepared.closeBrowser,
+ { code, reason },
+ prepared.upstreamReceivedMessage
+ );
+ });
+
+ upstream.on("error", () => {
+ if (!upstreamOpened) {
+ rejectUnavailable();
+ return;
+ }
+ if (!prepared.closeBrowser) {
+ prepared.bufferedError = true;
+ return;
+ }
+ if (!prepared.upstreamClosed) {
+ prepared.closeBrowser(1011, "dev_browser_websocket_error");
+ }
+ });
+
+ upstream.once("open", () => {
+ upstreamOpened = true;
+ if (settled) {
+ return;
+ }
+ settled = true;
+ prepared.selectedProtocol = upstream.protocol;
+ resolve(prepared);
+ });
+ });
+}
+
+function bindPreparedProxyWebSocket(
+ prepared: PreparedProxyWebSocket,
+ browserSocket: WsWebSocket,
+ closeBrowser: (code: number, reason: string) => void,
+ closeUpstream: () => void
+): void {
+ prepared.browserSocket = browserSocket;
+ prepared.closeBrowser = closeBrowser;
+
+ for (const message of prepared.bufferedMessages.splice(0)) {
+ if (browserSocket.readyState !== WsWebSocket.OPEN) {
+ break;
+ }
+ browserSocket.send(message.data, { binary: message.isBinary });
+ }
+
+ if (prepared.bufferedClose) {
+ closeBrowserForUpstreamResult(
+ closeBrowser,
+ prepared.bufferedClose,
+ prepared.upstreamReceivedMessage
+ );
+ return;
+ }
+
+ if (prepared.bufferedError && !prepared.upstreamClosed) {
+ closeBrowser(1011, "dev_browser_websocket_error");
+ closeUpstream();
+ }
+}
+
+async function proxyRequest(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ session: DevBrowserSession
+) {
+ const targetUrl = resolveProxyTargetUrl(session, request);
+ const targetHeaders = filterProxyRequestHeaders(request.headers, targetUrl.host, {
+ userAgent: session.userAgent,
+ });
+ const method = request.method.toUpperCase();
+ const init: RequestInit = {
+ method,
+ headers: targetHeaders,
+ redirect: "manual",
+ };
+
+ if (method !== "GET" && method !== "HEAD" && request.body !== undefined) {
+ if (Buffer.isBuffer(request.body)) {
+ init.body = bufferToBodyInit(request.body);
+ } else if (typeof request.body === "string") {
+ init.body = request.body;
+ } else {
+ init.body = JSON.stringify(request.body);
+ targetHeaders.set("content-type", "application/json");
+ }
+ }
+
+ let upstream: Response;
+ try {
+ upstream = await fetch(targetUrl, init);
+ } catch {
+ return reply.status(502).send({ error: "dev_browser_target_unavailable" });
+ }
+
+ const responseHeaders = filterProxyResponseHeaders(upstream.headers);
+ const location = upstream.headers.get("location");
+ if (location) {
+ responseHeaders.set(
+ "location",
+ rewriteProxyLocationHeader(location, {
+ browserProxyBase: browserProxyBase(session.id),
+ port: session.port,
+ targetOrigin: session.targetOrigin,
+ })
+ );
+ }
+
+ responseHeaders.forEach((value, key) => reply.header(key, value));
+ reply.status(upstream.status);
+
+ const contentType = upstream.headers.get("content-type") ?? "";
+ if (contentType.includes("text/html")) {
+ const html = await upstream.text();
+ const effectiveSession =
+ isCoderStudioHtml(html) && !session.preserveStudioPlatformPaths
+ ? { ...session, preserveStudioPlatformPaths: true }
+ : session;
+ return reply.type("text/html; charset=utf-8").send(injectHtmlBootstrap(html, effectiveSession));
+ }
+
+ if (contentType.includes("text/css")) {
+ const css = rewriteCssUrls(await upstream.text(), session);
+ return reply.type("text/css; charset=utf-8").send(css);
+ }
+
+ if (contentType.includes("javascript")) {
+ const script = rewriteJavaScriptModuleSpecifiers(await upstream.text(), session);
+ return reply.type("application/javascript; charset=utf-8").send(script);
+ }
+
+ return reply.send(Buffer.from(await upstream.arrayBuffer()));
+}
+
+function proxyWebSocket(
+ request: FastifyRequest,
+ browserSocket: WsWebSocket,
+ session: DevBrowserSession,
+ prepared: PreparedProxyWebSocket | null = null
+) {
+ const targetUrl = prepared ? null : toWebSocketTargetUrl(resolveProxyTargetUrl(session, request));
+ const { protocols, ...requestOptions } = prepared
+ ? { headers: {} }
+ : buildProxyWebSocketRequestOptions(request.headers, { userAgent: session.userAgent });
+ const upstream = prepared
+ ? prepared.upstream
+ : new WsWebSocket(targetUrl!, protocols, requestOptions);
+
+ let upstreamOpened = prepared !== null;
+ let browserClosed = false;
+ let upstreamClosed = prepared?.upstreamClosed ?? false;
+ let upstreamReceivedMessage = prepared?.upstreamReceivedMessage ?? false;
+ let pendingBytes = 0;
+ const pendingMessages: Array<{ data: RawData; isBinary: boolean }> = [];
+
+ const closeBrowser = (code: number, reason: string) => {
+ if (browserClosed) {
+ return;
+ }
+ browserClosed = true;
+ if (isValidCloseCode(code)) {
+ browserSocket.close(code, reason);
+ return;
+ }
+ browserSocket.close();
+ };
+
+ const closeUpstream = (code?: number, reason?: Buffer) => {
+ if (prepared ? prepared.upstreamClosed : upstreamClosed) {
+ return;
+ }
+ if (prepared) {
+ prepared.upstreamClosed = true;
+ } else {
+ upstreamClosed = true;
+ }
+ if (code === undefined || !isValidCloseCode(code)) {
+ upstream.close();
+ return;
+ }
+ upstream.close(code, reason?.toString());
+ };
+
+ browserSocket.on("message", (data, isBinary) => {
+ if (upstream.readyState === WsWebSocket.OPEN) {
+ upstream.send(data, { binary: isBinary });
+ return;
+ }
+ if (upstream.readyState === WsWebSocket.CONNECTING) {
+ pendingBytes += rawDataByteLength(data);
+ if (pendingBytes > MAX_PENDING_WEBSOCKET_BYTES) {
+ pendingMessages.length = 0;
+ closeBrowser(1009, "dev_browser_websocket_buffer_overflow");
+ closeUpstream();
+ return;
+ }
+ pendingMessages.push({ data, isBinary });
+ }
+ });
+
+ browserSocket.on("close", (code, reason) => {
+ closeUpstream(code, reason);
+ });
+
+ browserSocket.on("error", () => {
+ closeUpstream();
+ });
+
+ if (prepared) {
+ bindPreparedProxyWebSocket(prepared, browserSocket, closeBrowser, () => closeUpstream());
+ return;
+ }
+
+ upstream.on("open", () => {
+ upstreamOpened = true;
+ for (const message of pendingMessages.splice(0)) {
+ upstream.send(message.data, { binary: message.isBinary });
+ }
+ pendingBytes = 0;
+ });
+
+ upstream.on("message", (data, isBinary) => {
+ upstreamReceivedMessage = true;
+ if (browserSocket.readyState === WsWebSocket.OPEN) {
+ browserSocket.send(data, { binary: isBinary });
+ }
+ });
+
+ upstream.on("close", (code, reason) => {
+ upstreamClosed = true;
+ if (!upstreamOpened) {
+ closeBrowser(1014, "dev_browser_target_unavailable");
+ return;
+ }
+ closeBrowserForUpstreamResult(closeBrowser, { code, reason }, upstreamReceivedMessage);
+ });
+
+ upstream.on("error", () => {
+ if (!upstreamOpened) {
+ closeBrowser(1014, "dev_browser_target_unavailable");
+ return;
+ }
+ closeBrowser(1011, "dev_browser_websocket_error");
+ });
+}
+
+export function registerDevBrowserRoutes(
+ app: FastifyInstance,
+ deps: { sessions?: DevBrowserSessionStore } = {}
+): void {
+ const sessions = deps.sessions ?? new DevBrowserSessionStore();
+ const existingHandleProtocols = app.websocketServer.options.handleProtocols;
+ app.websocketServer.options.handleProtocols = (protocols, rawRequest) => {
+ const prepared = preparedProxyWebSockets.get(rawRequest);
+ if (prepared) {
+ return prepared.selectedProtocol;
+ }
+ return existingHandleProtocols
+ ? existingHandleProtocols(protocols, rawRequest)
+ : (protocols.values().next().value ?? false);
+ };
+
+ app.post("/api/dev-proxy/session", async (request, reply) => {
+ const parsed = createSessionSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "invalid_dev_browser_payload" });
+ }
+
+ try {
+ const session = sessions.create({
+ ...parseDevBrowserTargetUrl(parsed.data.url),
+ ...(parsed.data.userAgent ? { userAgent: parsed.data.userAgent } : {}),
+ });
+ return reply.send(serializeSession(session));
+ } catch (error) {
+ if (error instanceof DevBrowserTargetUrlError) {
+ return reply.status(400).send({ error: "invalid_dev_browser_target" });
+ }
+ throw error;
+ }
+ });
+
+ app.get("/api/dev-proxy/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ const session = sessions.get(id);
+ return session
+ ? reply.send(serializeSession(session))
+ : reply.status(404).send({ error: "dev_browser_session_not_found" });
+ });
+
+ app.delete("/api/dev-proxy/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ sessions.delete(id);
+ return reply.send({ ok: true });
+ });
+
+ app.get("/dev-browser/session/:id", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ return reply.redirect(`/dev-browser/session/${id}/`);
+ });
+
+ app.get("/dev-browser/session/:id/", async (request, reply) => {
+ const { id } = request.params as { id: string };
+ const session = sessions.get(id);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+ return reply.type("text/html; charset=utf-8").send(renderDevBrowserShell(session));
+ });
+
+ app.register(async (proxyApp) => {
+ proxyApp.removeAllContentTypeParsers();
+ proxyApp.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
+ done(null, body);
+ });
+
+ proxyApp.route({
+ method: "GET",
+ url: "/dev-browser/session/:id/proxy/*",
+ preHandler: async (request, reply) => {
+ preparedProxyWebSockets.delete(request.raw);
+
+ const session = getSessionFromRequest(sessions, request);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+
+ if (request.ws) {
+ try {
+ const prepared = await prepareProxyWebSocket(request, session);
+ if (prepared) {
+ preparedProxyWebSockets.set(request.raw, prepared);
+ }
+ } catch {
+ return reply.status(502).send({
+ error: "dev_browser_target_unavailable",
+ });
+ }
+ }
+ },
+ handler: async (request, reply) => {
+ const session = getSessionFromRequest(sessions, request);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+ return proxyRequest(request, reply, session);
+ },
+ wsHandler: (socket, request) => {
+ const session = getSessionFromRequest(sessions, request);
+ if (!session) {
+ socket.close(1008, "dev_browser_session_not_found");
+ return;
+ }
+ const prepared = preparedProxyWebSockets.get(request.raw) ?? null;
+ preparedProxyWebSockets.delete(request.raw);
+ proxyWebSocket(request, socket, session, prepared);
+ },
+ });
+
+ proxyApp.route({
+ method: ["DELETE", "OPTIONS", "PATCH", "POST", "PUT"],
+ url: "/dev-browser/session/:id/proxy/*",
+ handler: async (request, reply) => {
+ const session = getSessionFromRequest(sessions, request);
+ if (!session) {
+ return reply.status(404).send({ error: "dev_browser_session_not_found" });
+ }
+ return proxyRequest(request, reply, session);
+ },
+ });
+ });
+}
diff --git a/packages/server/src/routes/preview.test.ts b/packages/server/src/routes/preview.test.ts
index c4ef1e3c3..b98dc40f5 100644
--- a/packages/server/src/routes/preview.test.ts
+++ b/packages/server/src/routes/preview.test.ts
@@ -15,6 +15,7 @@ describe("/api/preview/session", () => {
root = join(tmpdir(), `preview-route-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(root, "examples", "demo"), { recursive: true });
await writeFile(join(root, "examples", "demo", "style.css"), "body { color: red; }");
+ await writeFile(join(root, "examples", "demo", "app.js"), "window.previewApp = true;");
await writeFile(
join(root, "examples", "demo", "pixel.png"),
Buffer.from(
@@ -67,18 +68,58 @@ describe("/api/preview/session", () => {
id,
entryPath: "examples/demo/index.html",
kind: "html",
+ allowScripts: true,
revision: 1,
});
expect(entryRes.statusCode).toBe(200);
expect(entryRes.headers["content-type"]).toContain("text/html");
- expect(entryRes.headers["content-security-policy"]).toContain("script-src 'none'");
- expect(entryRes.headers["x-preview-allow-scripts"]).toBe("false");
+ expect(entryRes.headers["content-security-policy"]).toContain(
+ "script-src 'self' 'unsafe-inline'"
+ );
+ expect(entryRes.headers["content-security-policy"]).toContain(
+ "script-src-attr 'unsafe-inline'"
+ );
+ expect(entryRes.headers["x-preview-allow-scripts"]).toBe("true");
expect(entryRes.body).toContain("demo");
expect(assetRes.statusCode).toBe(200);
expect(assetRes.headers["content-type"]).toContain("text/css");
expect(assetRes.body).toContain("color: red");
});
+ it("allows same-origin and inline scripts by default while leaving remote scripts blocked by CSP", async () => {
+ const createRes = await app.inject({
+ method: "POST",
+ url: "/api/preview/session",
+ payload: {
+ workspaceId: "ws-1",
+ entryPath: "examples/demo/index.html",
+ kind: "html",
+ content:
+ 'Run ',
+ },
+ });
+
+ const { id, previewUrl } = createRes.json();
+ const scriptUrl = `/api/preview/session/${id}/examples/demo/app.js`;
+ const entryRes = await app.inject({ method: "GET", url: previewUrl });
+ const scriptRes = await app.inject({ method: "GET", url: scriptUrl });
+
+ expect(entryRes.statusCode).toBe(200);
+ expect(entryRes.headers["x-preview-allow-scripts"]).toBe("true");
+ expect(entryRes.headers["content-security-policy"]).toContain(
+ "script-src 'self' 'unsafe-inline'"
+ );
+ expect(entryRes.headers["content-security-policy"]).toContain(
+ "script-src-attr 'unsafe-inline'"
+ );
+ expect(entryRes.body).toContain(`src="${scriptUrl}"`);
+ expect(entryRes.body).toContain('src="https://example.com/app.js"');
+ expect(entryRes.body).toContain("");
+ expect(entryRes.body).toContain('onclick="window.clicked = true"');
+ expect(scriptRes.statusCode).toBe(200);
+ expect(scriptRes.body).toContain("window.previewApp = true;");
+ });
+
it("rewrites local HTML image sources through the preview asset route", async () => {
const fileUrl = pathToFileURL(join(root, "examples", "demo", "pixel.png")).href;
const createRes = await app.inject({
@@ -222,6 +263,48 @@ describe("/api/preview/session", () => {
expect(entryRes.statusCode).toBe(200);
expect(entryRes.body).toContain("Guide ");
+ expect(entryRes.headers["x-preview-allow-scripts"]).toBe("false");
+ expect(entryRes.headers["content-security-policy"]).toContain("script-src 'none'");
+ });
+
+ it("keeps markdown script execution disabled even when API callers request it", async () => {
+ const createRes = await app.inject({
+ method: "POST",
+ url: "/api/preview/session",
+ payload: {
+ workspaceId: "ws-1",
+ entryPath: "docs/guide/intro.md",
+ kind: "markdown",
+ content: '# Guide\n\n',
+ allowScripts: true,
+ },
+ });
+
+ const { id, previewUrl } = createRes.json();
+ const createdSessionRes = await app.inject({
+ method: "GET",
+ url: `/api/preview/session/${id}`,
+ });
+ const updatedRes = await app.inject({
+ method: "PUT",
+ url: `/api/preview/session/${id}`,
+ payload: { allowScripts: true },
+ });
+ const updatedSessionRes = await app.inject({
+ method: "GET",
+ url: `/api/preview/session/${id}`,
+ });
+ const entryRes = await app.inject({
+ method: "GET",
+ url: previewUrl,
+ });
+
+ expect(createdSessionRes.json()).toMatchObject({ kind: "markdown", allowScripts: false });
+ expect(updatedRes.statusCode).toBe(200);
+ expect(updatedSessionRes.json()).toMatchObject({ kind: "markdown", allowScripts: false });
+ expect(entryRes.statusCode).toBe(200);
+ expect(entryRes.headers["x-preview-allow-scripts"]).toBe("false");
+ expect(entryRes.headers["content-security-policy"]).toContain("script-src 'none'");
});
it("returns 404 when a relative asset is missing", async () => {
diff --git a/packages/server/src/routes/preview.ts b/packages/server/src/routes/preview.ts
index f4d797bbd..65b724538 100644
--- a/packages/server/src/routes/preview.ts
+++ b/packages/server/src/routes/preview.ts
@@ -27,8 +27,19 @@ interface PreviewSessionUpdateBody {
type WorkspaceLookup = { path: string } | null | undefined;
-function getPreviewContentSecurityPolicy(): string {
- return "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'none'; base-uri 'none'; form-action 'none'";
+function getPreviewContentSecurityPolicy(allowScripts: boolean): string {
+ const scriptPolicy = allowScripts
+ ? "script-src 'self' 'unsafe-inline'; script-src-attr 'unsafe-inline'"
+ : "script-src 'none'";
+
+ return `default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; ${scriptPolicy}; base-uri 'none'; form-action 'none'`;
+}
+
+function getEffectivePreviewAllowScripts(session: {
+ kind: PreviewKind;
+ allowScripts: boolean;
+}): boolean {
+ return session.kind === "html" && session.allowScripts;
}
function resolvePreviewAssetWorkspacePath(entryPath: string, rawPath: string): string {
@@ -133,12 +144,13 @@ export function registerPreviewRoutes(
sessionId: session.id,
workspaceRootPath: workspace.path,
});
- const contentSecurityPolicy = getPreviewContentSecurityPolicy();
+ const allowScripts = getEffectivePreviewAllowScripts(session);
+ const contentSecurityPolicy = getPreviewContentSecurityPolicy(allowScripts);
const response = reply
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-store")
- .header("X-Preview-Allow-Scripts", String(session.allowScripts));
+ .header("X-Preview-Allow-Scripts", String(allowScripts));
if (contentSecurityPolicy) {
response.header("Content-Security-Policy", contentSecurityPolicy);
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index ee3e5c35b..9c8cd861e 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -27,7 +27,6 @@ import {
resolveConfiguredStateDir,
type ServerConfigInput,
} from "./config.js";
-import { WorkspaceExtensionStateService } from "./extension-state/workspace-extension-state-service.js";
import { AutoFetchScheduler } from "./git/auto-fetch.js";
import { LspManager } from "./lsp/manager.js";
import { LspToolInstallManager } from "./lsp-tools/install-manager.js";
@@ -42,7 +41,10 @@ import { runCommandAsString } from "./provider-runtime/command-runner.js";
import { buildCustomProviderDefinition } from "./provider-runtime/custom-provider.js";
import { createE2EProviderMockOverrides } from "./provider-runtime/e2e-provider-mock.js";
import { ProviderInstallManager } from "./provider-runtime/install-manager.js";
-import type { RuntimeStatusDeps } from "./provider-runtime/runtime-status.js";
+import {
+ buildProviderRuntimeStatus,
+ type RuntimeStatusDeps,
+} from "./provider-runtime/runtime-status.js";
import { SessionManager } from "./session/manager.js";
import { SessionAnalysisRunner } from "./session-analysis/runner.js";
import { SessionAnalysisService } from "./session-analysis/service.js";
@@ -56,6 +58,7 @@ import { AppearanceAssetRepo } from "./storage/repositories/appearance-asset-rep
import { AuthLoginBlockRepo } from "./storage/repositories/auth-login-block-repo.js";
import { AuthSessionRepo } from "./storage/repositories/auth-session-repo.js";
import { CustomProviderRepo } from "./storage/repositories/custom-provider-repo.js";
+import { MemoryRepo } from "./storage/repositories/memory-repo.js";
import { ProviderConfigRepo } from "./storage/repositories/provider-config-repo.js";
import { SessionAnalysisRepo } from "./storage/repositories/session-analysis-repo.js";
import { SessionMetadataRepo } from "./storage/repositories/session-metadata-repo.js";
@@ -68,7 +71,6 @@ import { SupervisorRepo } from "./storage/repositories/supervisor-repo.js";
import { TerminalRepo } from "./storage/repositories/terminal-repo.js";
import { UpdateStateRepo } from "./storage/repositories/update-state-repo.js";
import { WorkAnalysisRepo } from "./storage/repositories/work-analysis-repo.js";
-import { WorkspaceExtensionStateRepo } from "./storage/repositories/workspace-extension-state-repo.js";
import { WorkspaceRepo } from "./storage/repositories/workspace-repo.js";
import { SupervisorManager } from "./supervisor/manager.js";
import * as targetStore from "./supervisor/target-store.js";
@@ -192,6 +194,20 @@ export async function createServer(
const providerConfigRepo = new ProviderConfigRepo({
filePath: join(stateRoot, "state", "provider-configs.json"),
});
+ const customProviderRepo = new CustomProviderRepo({
+ filePath: join(stateRoot, "state", "custom-providers.json"),
+ });
+ let activeProviderRegistry = [
+ ...providerRegistry,
+ ...customProviderRepo.list().map((config) => buildCustomProviderDefinition(config)),
+ ];
+ const providerMockOverrides = createE2EProviderMockOverrides();
+ const providerRuntimeDeps: RuntimeStatusDeps = providerMockOverrides
+ ? {
+ commandExists: providerMockOverrides.commandExists,
+ runCommand: providerMockOverrides.runCommand,
+ }
+ : {};
const skillLibraryRepo = new SkillLibraryRepo({
filePath: join(stateRoot, "state", "skills", "library-index.json"),
localSkillRoots: resolveDefaultLocalSkillRoots(),
@@ -204,32 +220,33 @@ export async function createServer(
});
const skillsHubClient = new SkillsHubClient({ runCommand: runCommandAsString });
const skillLibraryRoot = join(stateRoot, "state", "skills", "library");
- const skillInstallMgr = new SkillInstallManager({
- skillsHubClient,
- skillLibraryRepo,
- libraryRoot: skillLibraryRoot,
- });
const skillMountMgr = new SkillMountManager({
getProviderRegistry: () => activeProviderRegistry,
skillLibraryRepo,
skillMountRepo,
});
+ const skillInstallMgr = new SkillInstallManager({
+ skillsHubClient,
+ skillLibraryRepo,
+ libraryRoot: skillLibraryRoot,
+ skillMountMgr,
+ getInstalledSkillTargetProviderIds: async () => {
+ const runtimeStatus = await buildProviderRuntimeStatus(
+ activeProviderRegistry,
+ providerRuntimeDeps
+ );
+ return Object.values(runtimeStatus.providers)
+ .filter((provider) => provider.available && provider.supportsSkillsMount)
+ .map((provider) => provider.providerId);
+ },
+ });
const skillHealthMgr = new SkillHealthManager({
getProviderRegistry: () => activeProviderRegistry,
skillLibraryRepo,
});
- const customProviderRepo = new CustomProviderRepo({
- filePath: join(stateRoot, "state", "custom-providers.json"),
- });
const workspaceRepo = new WorkspaceRepo({
filePath: join(stateRoot, "state", "workspaces.json"),
});
- const workspaceExtensionStateService = new WorkspaceExtensionStateService({
- repo: new WorkspaceExtensionStateRepo({
- workspaceRepo,
- }),
- eventBus,
- });
const sessionMetadataRepo = new SessionMetadataRepo({
workspaceRepo,
});
@@ -240,13 +257,12 @@ export async function createServer(
filePath: join(stateRoot, "state", "work-analysis.sqlite"),
legacyJsonFilePath: join(stateRoot, "state", "work-analysis.json"),
});
- let activeProviderRegistry = [
- ...providerRegistry,
- ...customProviderRepo.list().map((config) => buildCustomProviderDefinition(config)),
- ];
const automationAuditLog = new AutomationAuditLog({
filePath: join(stateRoot, "state", "automation-audit.jsonl"),
});
+ const memoryRepo = new MemoryRepo({
+ rootDir: join(stateRoot, "state", "memory", "workspaces"),
+ });
const builtinSkillSyncMgr = new BuiltinSkillSyncManager({
builtinRoot: join(stateRoot, "state", "skills", "builtin"),
getProviderRegistry: () => activeProviderRegistry,
@@ -324,6 +340,7 @@ export async function createServer(
taskMgr.clearWorkspace(workspaceId);
await terminalMgr.closeForWorkspace(workspaceId);
sessionMgr.deleteEndedForWorkspace(workspaceId);
+ memoryRepo.removeWorkspace(workspaceId);
for (const session of persistedSessions) {
sessionRepo.delete(session.id);
@@ -372,13 +389,6 @@ export async function createServer(
wsHub.setLogger(app.log);
workspaceMgr.setLogger(app.log);
- const providerMockOverrides = createE2EProviderMockOverrides();
- const providerRuntimeDeps: RuntimeStatusDeps = providerMockOverrides
- ? {
- commandExists: providerMockOverrides.commandExists,
- runCommand: providerMockOverrides.runCommand,
- }
- : {};
agentInstructionPublisher = new AgentInstructionsPublisher({
workspaceMgr,
getProviderRegistry: () => activeProviderRegistry,
@@ -525,7 +535,7 @@ export async function createServer(
skillMountRepo,
builtinSkillSyncMgr,
automationAuditLog,
- workspaceExtensionStateService,
+ memoryRepo,
stateRoot,
agentInstructionPublisher,
};
diff --git a/packages/server/src/skills/builtin/definitions/coder-studio-memory.ts b/packages/server/src/skills/builtin/definitions/coder-studio-memory.ts
new file mode 100644
index 000000000..4dd3edb4e
--- /dev/null
+++ b/packages/server/src/skills/builtin/definitions/coder-studio-memory.ts
@@ -0,0 +1,72 @@
+import type { BuiltinSkillDefinition } from "./types.js";
+
+const CONTENT = [
+ "---",
+ "name: coder-studio-memory",
+ "description: Read and write durable Coder Studio workspace memory on demand.",
+ "---",
+ "",
+ "# Coder Studio Memory",
+ "",
+ "Use this skill when you need to better understand the project or workspace context, look up durable debugging information, check important project rules, or leave stable follow-up context for future sessions.",
+ "",
+ "## Read On Demand",
+ "",
+ "Do not assume memory is injected at startup. Read workspace memory when you need to understand the project or context better, recover debugging information, verify commands or conventions, or review durable notes before making changes.",
+ "",
+ "Prefer targeted reads before broad reads:",
+ "",
+ "```bash",
+ "coder-studio memory search architecture --workspace --json",
+ "coder-studio memory list --workspace --type project --json",
+ "coder-studio memory get --workspace --json",
+ "```",
+ "",
+ "If `CODER_STUDIO_WORKSPACE_ID` is set, you can omit `--workspace`:",
+ "",
+ "```bash",
+ "coder-studio memory search testing --json",
+ "```",
+ "",
+ "## Write Stable Context",
+ "",
+ "Write memory when you learn something durable that should help a future session, such as a confirmed project rule, a stable debugging conclusion, a reusable command, a repository convention, a technical constraint, a real follow-up todo, or an important feature constraint.",
+ "",
+ "Avoid transient scratch notes, temporary debugging observations, secrets, credentials, or anything the user would not expect to persist.",
+ "",
+ "Use `feature` for product behavior, `todo` for pending work, `bugfix` for defects, `project` for repository-operating knowledge, and `note` only as a fallback.",
+ "",
+ "Choose the narrowest type that matches the memory:",
+ "",
+ "- `feature`: stable facts about what the product does or should do.",
+ "- `todo`: durable follow-up work that should remain visible later.",
+ "- `bugfix`: confirmed defects, causes, or fixes that matter beyond the current turn.",
+ "- `project`: repository-operating knowledge such as commands, conventions, directory expectations, or technical constraints.",
+ "- `note`: only when the memory is durable but does not fit the other four types cleanly.",
+ "",
+ "Write direct, searchable content that will still make sense in a later session.",
+ "",
+ "```bash",
+ 'coder-studio memory add --workspace --type project --content "This workspace uses pnpm for package scripts." --skill coder-studio-memory --json',
+ "```",
+ "",
+ "Update or delete memory when it becomes stale:",
+ "",
+ "```bash",
+ 'coder-studio memory update --workspace --content "Updated durable project context." --json',
+ "coder-studio memory delete --workspace --json",
+ "```",
+ "",
+ "Users can inspect, edit, and delete workspace memory from the Memory side panel.",
+ "",
+].join("\n");
+
+export const CODER_STUDIO_MEMORY_SKILL: BuiltinSkillDefinition = {
+ slug: "coder-studio-memory",
+ displayName: "Coder Studio Memory",
+ description: "Read and write durable Coder Studio workspace memory on demand.",
+ version: "1.0.0",
+ defaultEnabled: true,
+ autoMountInMvp: true,
+ content: CONTENT,
+};
diff --git a/packages/server/src/skills/builtin/definitions/coder-studio-open.ts b/packages/server/src/skills/builtin/definitions/coder-studio-open.ts
new file mode 100644
index 000000000..868a90e6e
--- /dev/null
+++ b/packages/server/src/skills/builtin/definitions/coder-studio-open.ts
@@ -0,0 +1,34 @@
+import type { BuiltinSkillDefinition } from "./types.js";
+
+const CONTENT = [
+ "---",
+ "name: coder-studio-open",
+ "description: Use when an agent running inside Coder Studio needs to open or close a workspace file or localhost URL for the user.",
+ "---",
+ "",
+ "# Coder Studio Open",
+ "",
+ "Use this only to open or close a workspace file in Coder Studio's editor or a localhost URL in Coder Studio's browser for the user.",
+ "",
+ "Run one of:",
+ "",
+ "coder-studio ui open-file --path [--line N] [--column N] [--workspace ] --json",
+ "coder-studio ui close-file --path [--workspace ] --json",
+ "coder-studio ui open-url --url http://127.0.0.1:5173 [--workspace ] --json",
+ "coder-studio ui close-url --url http://127.0.0.1:5173 [--workspace ] --json",
+ "",
+ "If the CLI cannot find the running Coder Studio server, pass `--api-url ` or set `CODER_STUDIO_API_URL`.",
+ "",
+ "Use workspace-relative file paths only; do not use absolute paths or `..` segments. URLs must be localhost `http` or `https` URLs. Close commands only close a matching file path or current browser URL. `accepted: true` means the request was dispatched, not that the frontend has finished rendering it.",
+ "",
+].join("\n");
+
+export const CODER_STUDIO_OPEN_SKILL: BuiltinSkillDefinition = {
+ slug: "coder-studio-open",
+ displayName: "Coder Studio Open",
+ description: "Open workspace files and localhost URLs in Coder Studio.",
+ version: "1.0.0",
+ defaultEnabled: true,
+ autoMountInMvp: true,
+ content: CONTENT,
+};
diff --git a/packages/server/src/skills/builtin/definitions/index.ts b/packages/server/src/skills/builtin/definitions/index.ts
new file mode 100644
index 000000000..8aeb71453
--- /dev/null
+++ b/packages/server/src/skills/builtin/definitions/index.ts
@@ -0,0 +1,8 @@
+import { CODER_STUDIO_MEMORY_SKILL } from "./coder-studio-memory.js";
+import { CODER_STUDIO_OPEN_SKILL } from "./coder-studio-open.js";
+
+export { CODER_STUDIO_MEMORY_SKILL } from "./coder-studio-memory.js";
+export { CODER_STUDIO_OPEN_SKILL } from "./coder-studio-open.js";
+export type { BuiltinSkillDefinition } from "./types.js";
+
+export const BUILTIN_SKILLS = [CODER_STUDIO_OPEN_SKILL, CODER_STUDIO_MEMORY_SKILL];
diff --git a/packages/server/src/skills/builtin/definitions/types.ts b/packages/server/src/skills/builtin/definitions/types.ts
new file mode 100644
index 000000000..6518189a1
--- /dev/null
+++ b/packages/server/src/skills/builtin/definitions/types.ts
@@ -0,0 +1,9 @@
+export interface BuiltinSkillDefinition {
+ slug: string;
+ displayName: string;
+ description: string;
+ version: string;
+ defaultEnabled: boolean;
+ autoMountInMvp: boolean;
+ content: string;
+}
diff --git a/packages/server/src/skills/builtin/materialize.ts b/packages/server/src/skills/builtin/materialize.ts
index 5cff36614..bd0e99ba4 100644
--- a/packages/server/src/skills/builtin/materialize.ts
+++ b/packages/server/src/skills/builtin/materialize.ts
@@ -1,20 +1,22 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { SkillLibraryEntry } from "@coder-studio/core";
-import { BUILTIN_SKILLS } from "./registry.js";
+import { BUILTIN_SKILLS, type BuiltinSkillDefinition } from "./definitions/index.js";
export interface MaterializeBuiltinSkillsInput {
builtinRoot: string;
now?: () => number;
+ skills?: readonly BuiltinSkillDefinition[];
}
export async function materializeBuiltinSkills(
input: MaterializeBuiltinSkillsInput
): Promise {
const now = input.now?.() ?? Date.now();
+ const skills = input.skills ?? BUILTIN_SKILLS;
const entries: SkillLibraryEntry[] = [];
- for (const skill of BUILTIN_SKILLS) {
+ for (const skill of skills) {
const libraryPath = join(input.builtinRoot, skill.slug);
await mkdir(libraryPath, { recursive: true });
await writeFile(join(libraryPath, "SKILL.md"), `${skill.content.trimEnd()}\n`, "utf8");
diff --git a/packages/server/src/skills/builtin/mount-preferences.ts b/packages/server/src/skills/builtin/mount-preferences.ts
new file mode 100644
index 000000000..9dbbc5066
--- /dev/null
+++ b/packages/server/src/skills/builtin/mount-preferences.ts
@@ -0,0 +1,87 @@
+import type { SettingsRepo } from "../../storage/repositories/settings-repo.js";
+
+const DISABLED_MOUNTS_SETTING_KEY = "skills.builtin.disabledMounts";
+const ENABLED_MOUNTS_SETTING_KEY = "skills.builtin.enabledMounts";
+
+export interface BuiltinSkillMountDecision {
+ shouldMount: boolean;
+ reason?: "disabled" | "not_mvp_auto";
+}
+
+export class BuiltinSkillMountPreferences {
+ constructor(private readonly settingsRepo: SettingsRepo) {}
+
+ getMountDecision(
+ providerId: string,
+ skillSlug: string,
+ autoMountInMvp: boolean
+ ): BuiltinSkillMountDecision {
+ const key = mountPreferenceKey(providerId, skillSlug);
+ const disabled = this.readPreferenceMap(DISABLED_MOUNTS_SETTING_KEY);
+ if (disabled[key]) {
+ return { shouldMount: false, reason: "disabled" };
+ }
+
+ const enabled = this.readPreferenceMap(ENABLED_MOUNTS_SETTING_KEY);
+ if (!autoMountInMvp && !enabled[key]) {
+ return { shouldMount: false, reason: "not_mvp_auto" };
+ }
+
+ return { shouldMount: true };
+ }
+
+ setMountEnabled(providerId: string, skillSlug: string, enabled: boolean): void {
+ const disabledMounts = this.readPreferenceMap(DISABLED_MOUNTS_SETTING_KEY);
+ const enabledMounts = this.readPreferenceMap(ENABLED_MOUNTS_SETTING_KEY);
+ const key = mountPreferenceKey(providerId, skillSlug);
+
+ if (enabled) {
+ delete disabledMounts[key];
+ enabledMounts[key] = true;
+ } else {
+ disabledMounts[key] = true;
+ delete enabledMounts[key];
+ }
+
+ this.settingsRepo.set(DISABLED_MOUNTS_SETTING_KEY, disabledMounts);
+ this.settingsRepo.set(ENABLED_MOUNTS_SETTING_KEY, enabledMounts);
+ }
+
+ isMountDisabled(providerId: string, skillSlug: string): boolean {
+ return Boolean(
+ this.readPreferenceMap(DISABLED_MOUNTS_SETTING_KEY)[mountPreferenceKey(providerId, skillSlug)]
+ );
+ }
+
+ removeSkill(skillSlug: string): void {
+ this.removeSkillFromSetting(DISABLED_MOUNTS_SETTING_KEY, skillSlug);
+ this.removeSkillFromSetting(ENABLED_MOUNTS_SETTING_KEY, skillSlug);
+ }
+
+ private removeSkillFromSetting(settingKey: string, skillSlug: string): void {
+ const settings = this.readPreferenceMap(settingKey);
+ const next = Object.fromEntries(
+ Object.entries(settings).filter(([key]) => !key.endsWith(`:${skillSlug}`))
+ ) as Record;
+
+ if (Object.keys(next).length !== Object.keys(settings).length) {
+ this.settingsRepo.set(settingKey, next);
+ }
+ }
+
+ private readPreferenceMap(settingKey: string): Record {
+ const raw = this.settingsRepo.get>(settingKey);
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return {};
+ }
+
+ return Object.fromEntries(Object.entries(raw).filter(([, value]) => value === true)) as Record<
+ string,
+ true
+ >;
+ }
+}
+
+function mountPreferenceKey(providerId: string, skillSlug: string): string {
+ return `${providerId}:${skillSlug}`;
+}
diff --git a/packages/server/src/skills/builtin/registry.ts b/packages/server/src/skills/builtin/registry.ts
index df0f82e08..49e69a7d3 100644
--- a/packages/server/src/skills/builtin/registry.ts
+++ b/packages/server/src/skills/builtin/registry.ts
@@ -1,93 +1,6 @@
-export interface BuiltinSkillDefinition {
- slug: string;
- displayName: string;
- description: string;
- version: string;
- defaultEnabled: boolean;
- autoMountInMvp: boolean;
- content: string;
-}
-
-const AUTOMATION_SKILL = `---
-name: coder-studio-automation
-description: Use when running inside Coder Studio and you need workspace, session, terminal, Git, or automation discovery.
----
-
-# Coder Studio Automation
-
-When CODER_STUDIO=1 is present:
-
-1. Run \`coder-studio identify --json\` to inspect current context.
-2. Run \`coder-studio capabilities --json\` to discover supported commands.
-3. Prefer commands with \`--json\`.
-4. Use current workspace and session IDs from identify instead of guessing.
-5. Do not run destructive commands unless the user explicitly asked.
-6. If a command returns approval_required, explain what approval is needed and wait.
-`;
-
-const BROWSER_VERIFICATION_SKILL = `---
-name: coder-studio-browser-verification
-description: Use after frontend, UI, CSS, route, form, or browser-visible changes to verify the app in Coder Studio's browser automation surface.
----
-
-# Browser Verification
-
-For browser-visible changes:
-
-1. Use \`coder-studio identify --json\`.
-2. Use \`coder-studio capabilities --json\` and find browser commands.
-3. Start the dev server in a terminal when needed.
-4. Open the local URL in a Coder Studio browser surface.
-5. Wait for the expected text or selector.
-6. Capture a screenshot.
-7. Read console errors.
-8. Report visible issues and fix them before final response.
-
-If browser capabilities are not available, say so and use the best available local verification.
-`;
-
-const REVIEW_SKILL = `---
-name: coder-studio-review
-description: Use before finishing a coding task in Coder Studio to inspect Git changes, tests, and residual risk.
----
-
-# Coder Studio Review
-
-Before final response after code edits:
-
-1. Run \`coder-studio identify --json\`.
-2. Use capabilities to find Git and terminal commands.
-3. Inspect Git status and diff.
-4. Run relevant tests when practical.
-5. Report files changed, verification run, and any remaining risk.
-`;
-
-export const BUILTIN_SKILLS: BuiltinSkillDefinition[] = [
- {
- slug: "coder-studio-automation",
- displayName: "Coder Studio Automation",
- description: "Teach agents to identify Coder Studio context and discover automation commands.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: true,
- content: AUTOMATION_SKILL,
- },
- {
- slug: "coder-studio-browser-verification",
- displayName: "Coder Studio Browser Verification",
- description: "Teach agents to verify browser-visible changes through Coder Studio automation.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: false,
- content: BROWSER_VERIFICATION_SKILL,
- },
- {
- slug: "coder-studio-review",
- displayName: "Coder Studio Review",
- description: "Teach agents to review Git changes, tests, and residual risk before finishing.",
- version: "1.0.0",
- defaultEnabled: true,
- autoMountInMvp: true,
- content: REVIEW_SKILL,
- },
-];
+export {
+ BUILTIN_SKILLS,
+ CODER_STUDIO_MEMORY_SKILL,
+ CODER_STUDIO_OPEN_SKILL,
+} from "./definitions/index.js";
+export type { BuiltinSkillDefinition } from "./definitions/types.js";
diff --git a/packages/server/src/skills/builtin/stale-cleanup.ts b/packages/server/src/skills/builtin/stale-cleanup.ts
new file mode 100644
index 000000000..9ec055b33
--- /dev/null
+++ b/packages/server/src/skills/builtin/stale-cleanup.ts
@@ -0,0 +1,292 @@
+import type { Dirent } from "node:fs";
+import { lstat, readdir, readFile, readlink, rm } from "node:fs/promises";
+import { dirname, isAbsolute, join, relative, resolve } from "node:path";
+import type { ProviderDefinition, SkillLibraryEntry } from "@coder-studio/core";
+import type { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js";
+import type { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js";
+import type { SkillMountManager } from "../mount-manager.js";
+import { BuiltinSkillMountPreferences } from "./mount-preferences.js";
+
+export interface RemoveStaleBuiltinSkillsInput {
+ builtinRoot: string;
+ currentEntries: SkillLibraryEntry[];
+ libraryRepo: SkillLibraryRepo;
+ mountRepo: SkillMountRepo;
+ mountManager: SkillMountManager;
+ getProviderRegistry: () => ProviderDefinition[];
+ preferences: BuiltinSkillMountPreferences;
+}
+
+export interface RemovedBuiltinSkillEntry {
+ skillSlug: string;
+ unmountedProviderIds: string[];
+}
+
+interface StaleLocalBuiltinArtifact {
+ libraryPath: string;
+ targetPath: string;
+}
+
+export async function removeStaleBuiltinSkills(
+ input: RemoveStaleBuiltinSkillsInput
+): Promise {
+ const currentSlugs = new Set(input.currentEntries.map((entry) => entry.slug));
+ const libraryEntries = input.libraryRepo.list();
+ const staleEntriesBySlug = new Map(
+ libraryEntries
+ .filter((entry) => entry.source === "builtin" && !currentSlugs.has(entry.slug))
+ .map((entry) => [entry.slug, entry])
+ );
+ const staleTargetPathsBySlug = new Map>();
+ const staleArtifactPaths = await listStaleBuiltinArtifactPaths(input.builtinRoot, currentSlugs);
+
+ for (const [skillSlug, libraryPath] of staleArtifactPaths) {
+ if (staleEntriesBySlug.has(skillSlug)) {
+ continue;
+ }
+ staleEntriesBySlug.set(skillSlug, {
+ slug: skillSlug,
+ displayName: skillSlug,
+ version: "stale",
+ source: "builtin",
+ libraryPath,
+ installState: "installed",
+ installedAt: 0,
+ updatedAt: 0,
+ });
+ }
+
+ const staleLocalBuiltinArtifacts = await listStaleLocalBuiltinArtifacts(
+ libraryEntries,
+ currentSlugs
+ );
+ for (const [skillSlug, artifact] of staleLocalBuiltinArtifacts) {
+ if (!staleEntriesBySlug.has(skillSlug)) {
+ staleEntriesBySlug.set(skillSlug, {
+ slug: skillSlug,
+ displayName: skillSlug,
+ version: "stale",
+ source: "builtin",
+ libraryPath: artifact.libraryPath,
+ installState: "installed",
+ installedAt: 0,
+ updatedAt: 0,
+ });
+ }
+
+ let targetPaths = staleTargetPathsBySlug.get(skillSlug);
+ if (!targetPaths) {
+ targetPaths = new Set();
+ staleTargetPathsBySlug.set(skillSlug, targetPaths);
+ }
+ targetPaths.add(artifact.targetPath);
+ }
+
+ const removed: RemovedBuiltinSkillEntry[] = [];
+
+ for (const entry of staleEntriesBySlug.values()) {
+ const mounts = input.mountRepo.listBySkillSlug(entry.slug);
+ const unmountedProviderIds = new Set();
+
+ for (const mount of mounts) {
+ await input.mountManager.unmount(mount.providerId, mount.skillSlug).catch(() => {
+ input.mountRepo.delete(mount.providerId, mount.skillSlug);
+ });
+ unmountedProviderIds.add(mount.providerId);
+ }
+
+ for (const provider of input.getProviderRegistry()) {
+ for (const skillDir of provider.skillMountDirectories ?? []) {
+ const targetPath = join(skillDir, entry.slug);
+ const removedTarget = await removeStaleBuiltinTarget(
+ targetPath,
+ input.builtinRoot,
+ entry.libraryPath
+ );
+ if (removedTarget) {
+ unmountedProviderIds.add(provider.id);
+ }
+ }
+ }
+
+ for (const targetPath of staleTargetPathsBySlug.get(entry.slug) ?? []) {
+ await removeStaleBuiltinTarget(targetPath, input.builtinRoot, entry.libraryPath);
+ }
+
+ input.mountRepo.deleteBySkillSlug(entry.slug);
+ input.libraryRepo.delete(entry.slug);
+ if (isWithinDirectory(input.builtinRoot, entry.libraryPath)) {
+ await rm(entry.libraryPath, { recursive: true, force: true }).catch(() => undefined);
+ }
+ input.preferences.removeSkill(entry.slug);
+ removed.push({ skillSlug: entry.slug, unmountedProviderIds: [...unmountedProviderIds] });
+ }
+
+ return removed;
+}
+
+function isWithinDirectory(parent: string, child: string): boolean {
+ const relativePath = relative(resolve(parent), resolve(child));
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
+}
+
+function isWithinDirectoryOrSame(parent: string, child: string): boolean {
+ const relativePath = relative(resolve(parent), resolve(child));
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
+}
+
+async function listStaleBuiltinArtifactPaths(
+ builtinRoot: string,
+ currentSlugs: Set
+): Promise> {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(builtinRoot, { withFileTypes: true });
+ } catch {
+ return new Map();
+ }
+
+ const artifacts = new Map();
+ for (const entry of entries) {
+ if ((!entry.isDirectory() && !entry.isSymbolicLink()) || currentSlugs.has(entry.name)) {
+ continue;
+ }
+
+ const libraryPath = join(builtinRoot, entry.name);
+ if (await hasSkillMarkdown(libraryPath)) {
+ artifacts.set(entry.name, libraryPath);
+ }
+ }
+
+ return artifacts;
+}
+
+async function listStaleLocalBuiltinArtifacts(
+ libraryEntries: SkillLibraryEntry[],
+ currentSlugs: Set
+): Promise> {
+ const artifacts = new Map();
+ for (const entry of libraryEntries) {
+ if (entry.source !== "local" || currentSlugs.has(entry.slug)) {
+ continue;
+ }
+
+ const libraryPath = await resolveLocalBuiltinArtifactPath(entry.libraryPath, entry.slug);
+ if (libraryPath) {
+ artifacts.set(entry.slug, {
+ libraryPath,
+ targetPath: entry.libraryPath,
+ });
+ }
+ }
+
+ return artifacts;
+}
+
+async function resolveLocalBuiltinArtifactPath(
+ targetPath: string,
+ skillSlug: string
+): Promise {
+ let stat: Awaited