Skip to content
dsh.fish
Bundle

dsh-codespace-workspace

Use a GitHub Codespace directly as a DSH workspace: the agent reads, writes and runs commands inside the cloud Codespace.

Source
Ckarefulon
stars
1 stars
License
MIT
Updated
Updated 7 days ago

Readme

# dsh-codespace-workspace

把 **GitHub Codespace 直接当作一个 DSH 工作区**使用。本地 DSH 连接云端
Codespace,Agent 在 Codespace 里读写文件、执行命令。

这不是一个通用的 SSH 远程连接插件:Codespace 就是工作区本身。它不往 DSH 的
SSH 主机列表里塞任何东西,因此不会和其他云工作区插件冲突。

```
┌─────────────── 本地 DSH ───────────────┐        ┌────── GitHub Codespace ──────┐
│  侧边栏工作区行(云朵图标)              │        │                              │
│    ▶️ / ⏸️ / 🕐  ← 启停 + 自动暂停倒计时 │  SSH   │  /workspaces/<repo>          │
│                                        │ ─────► │    read / write / edit       │
│  Agent 会话(cwd = 占位目录)           │        │    glob / grep / bash        │
└────────────────────────────────────────┘        └──────────────────────────────┘
```

## 功能

### 设置页:Codespace

在 DSH 设置里新增一栏 **Codespace**:

| 设置项 | 说明 |
|---|---|
| GitHub 用户名 | 用于校验与展示;留空则从 token 读取登录名 |
| 默认分支 | 新建 Codespace 时使用,默认 `main` |
| 自动暂停等待时间(分钟) | 一轮对话结束后开始倒计时,默认 30 |
| SSH 密钥(可选) | 仅保存路径;值永不回显,只显示「已设置 / 未设置」 |
| 始终显示云端工作区按钮 | 开关,默认关;开启后按钮不再跟随 hover 隐藏 |
| 自动初始化空仓库 | 开关,默认开;空仓库先提交一个 README 再建 Codespace |
| README 初始内容 | 可选;留空则创建空 README |

设置通过 DSH 标准设置存储(`Config` + `.volatile()` + settings 服务)持久化,
写在 profile 的 `cordis.patch.yml` 里,不占用 `localStorage`,升级不丢。

### 工作区列表

- 新建云端工作区的按钮放在**工作区分组标题栏的图标行里**,与 DSH 原有的图标
  同地位,紧挨在「添加工作区」的**左边**——不是另做一个独立控件。
- 云端工作区**沿用原工作区的文件夹图标位置**,把那里的文件夹字形换成**云朵**:位置、
  尺寸、颜色,以及悬停时「让位给展开箭头」的行为都和原来一致,行首始终只有一个图标。
  云朵固定不变,不随 Codespace 状态变化。
- 操作按钮位于**倒数第三个**——在三个点菜单和新建对话按钮之前。
- 显示逻辑跟随宿主默认行为:不 hover 时隐藏;打开「始终显示」后常显。
- **正在使用的工作区,云朵和宿主的文件夹图标一样变蓝**:宿主给「拥有当前会话」的那个工作区
  的图标槽加 `folderActive`,云朵就排在那个槽里,**继承**它的颜色,不自己定颜色。
- 按钮状态(四个图标都是**照着宿主图标手绘的 SVG**,不是 emoji;转圈用的是宿主自己的
  `IconLoadingOutline` 圆弧,不是 CSS 方块环):

| 状态 | 图标 | 悬停提示 | 点击 |
|---|---|---|---|
| 已停止 | 播放 | 启动 Codespace | 启动 |
| 运行中 | 暂停 | 暂停 Codespace | 暂停 |
| 倒计时中 | 闹钟 | 还剩 X 分钟自动暂停 | 取消倒计时 |
| 启动/暂停中 | 转圈 | 正在切换状态… | 不可点 |

**转圈只表示「状态正在变化」**,不表示「Agent 正在干活」——后者会让按钮整个回合都在转。

**双击时钟图标 = 立即暂停。**

工作区行的标题用**仓库短名**(`owner/name` 里的 `name`)——Codespace 自己的名字是机器生成的
(`psychic-goggles-7v9974jwpwr5fpqxq`),看不出是哪个项目。标题装不下的两件事放在 hover
卡片里:那一行「工作区位置」显示 **`owner/name · Codespace 名字`**。

**在 Codespace 会话里发消息时,会先等 Codespace 跑起来**:如果它已经自动暂停(或还在启动),
这一轮会等到 `Available` 才真正发给模型,而不是让每个工具调用都报连接错误。

### 新建云端工作区

只有一个窗口,分步显示,不弹一堆窗:

1. 拉取 GitHub 仓库列表,选择仓库
2. 检查该仓库的 Codespace:多个 → 选择;一个 → 直接用;没有 → 创建
3. 创建前检查 prebuild,**三态**而非二态:
   - 有 → 「该仓库已配置预构建。」
   - 明确没有 → 「未配置预构建,创建可能较慢」,给出 `取消` / `直接创建`
   - **读不出来** → 「无法确认预构建状态,创建可能需要 1–3 分钟」,不给按钮
     (没有可决定的事)。这一态是刻意保留的:把插件自己读取失败说成「你仓库没配
     预构建」,是在陈述一件从未被证实的事实。
4. 空仓库 → 自动提交一个空 README,然后继续创建
5. 机器规格可选,默认最小
6. 启动失败 → 窗口内报错,可重试

### 生命周期

- **DSH 启动时**:不自动启动任何 Codespace。检查上次遗留的运行中 Codespace 并
  提示用户(带「停止」按钮),但不自动停。
- **退出 DSH 时**:尝试自动关闭所有本插件管理的 Codespace。
- **自动暂停**:一轮对话结束后开始倒计时;倒计时期间用户发新消息 → 取消倒计时,
  等 Agent 干完活重新计时;点时钟 → 取消;双击时钟 → 立即暂停。
- **发消息前保证机器在跑**:挂 `agent/pre-step`(Agent 循环在向模型要 step 之前会
  **await** 这个 waterfall),发现 Codespace 没运行就先启动、等它变成 `Available`
  再放行。启动失败或超时不会把这一轮卡死——照常放行,让模型看到真实的连接错误。
  等待期间你点「停止」/发送中断会立刻结束等待(转发的是这一轮的 abort signal)。

### Git

Agent 拥有完整的 `git` 命令执行能力。**插件不干预 Git 操作,不封装 push 逻辑。**
只在创建会话时向系统提示词注入一条规则,告诉 Agent 这是云端 Codespace 工作区、
改动完成后请 `git push` 回仓库——不需要每次提醒。

### 多工作区

每个云端工作区独立控制自己的 Codespace 启停,互不干扰。

### 删除 Codespace

在三个点菜单里,与「删除工作区」**分开**。点击后弹确认框,确认后删除。

## 前置条件

| 依赖 | 必需 | 说明 |
|---|---|---|
| `git` | 是 | Agent 用它 push 回仓库 |
| `ssh` | 是 | 连接 Codespace 的数据通道 |
| `gh`(GitHub CLI) | 推荐 | 用于 `gh codespace ssh`;**未安装时插件仍可用**,改用你在设置里配置的 SSH 别名 |
| GitHub token | 是 | 控制面(REST API)。取值顺序:设置 → `GITHUB_TOKEN` → `gh auth token` |

> **本机现状**:`gh` 未安装,`git` 与 `ssh` 已安装。插件会在设置页明确显示这一
> 状态,并在缺少 `gh` 时回退到 SSH 别名,而不是直接失败。

所需 GitHub 权限范围:`codespace`、`repo`。

## 安装

```powershell
# 1. 装进当前 profile(只改写 profile 的 package.json dependencies 与 lockfile)
dsh plugin --profile desktop add dsh-codespace-workspace

# 2. 把包名登记进 dsh.profile.bundles,否则不会被挂载
#    编辑 %USERPROFILE%\.dsh\profiles\desktop\package.json
#    在 dsh.profile.bundles 数组里加入 "dsh-codespace-workspace"

# 3. 重启 DSH(插件文件改动不会热生效)
```

也可以从本地克隆装(改代码时用这个):

```powershell
git clone <this-repo> dsh-codespace-workspace
dsh plugin --profile desktop add "link:<绝对路径>\dsh-codespace-workspace"
```

`dsh plugin add` 只改 `dependencies` 与 `pnpm-lock.yaml`,**不会**登记
`dsh.profile.bundles`,第 3 步必须手工做。它会报一条 peer dependency 警告
(`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 由宿主提供),可以忽略。

装完可以确认插件已被解析并导入。注意 `desktop` profile 由 Electron 应用独占管理,
CLI 对它只允许 `dsh plugin`,跑 `--dump-config-schema` 会报
`profile "desktop" is managed exclusively by the Electron application`。所以要用一个
临时 profile 来验证(下面的 `cscheck` 只是举例,验证完删掉即可):

```powershell
# 建一个只含 dsh-base 的临时 profile
dsh plugin --profile cscheck add "@deepseek-ai/dsh-base@0.2.0-rc.2"
dsh plugin --profile cscheck add "link:<绝对路径>\dsh-codespace-workspace"
# 手工把 "dsh-codespace-workspace" 加进该 profile 的 dsh.profile.bundles
# 然后 dump:它会真的 import() 每个插件的 main,并用其 Config 生成 JSON Schema
dsh --profile cscheck --dump-config-schema | Select-String dsh-codespace-workspace
```

输出里应出现 `"const":"dsh-codespace-workspace"` 指向一个 `config<N>` 定义,
该定义包含全部 8 个设置字段、正确的默认值、`"volatile":true`,以及
`githubToken` 上的 `"role":"secret"`。这证明模块能加载、`Config` 合法。

确认无误后重启 DSH,设置页就会出现 **Codespace**。

## 架构

```
lib/
  shared.js              Host/Client 之间的冻结契约(动作名、状态映射、脱敏)
  index.js               Host 入口:apply() / Config / inject / 装配
  host/
    github.js            GitHub REST 客户端 + token 解析
    gh.js                gh CLI 探测与调用(缺失时优雅降级)
    codespaces.js        生命周期、机器规格、prebuild、空仓库、自动暂停计时器
    transport.js         远程执行与文件读写(base64,无字符串插值)
    remote-tools.js      为 Codespace 会话注册远程工具(agent.ctx 作用域)
    session.js           agent/created、提示词注入、倒计时、退出停止
    routes.js            RPC 路由
  client.js              浏览器半边:设置页 + DOM 增强(标题栏按钮 / 行 / 菜单)+ 多步窗口
locale/{en,zh}.json      文案
test/
  check.mjs              Node 侧自检(不需要浏览器、token、gh)
  shutdown.mjs           「退出即停」的可靠性自检
  e2e.mjs                载体端到端:真实 webserver + 真实 connection + 真实签名 cookie
  serve.mjs              浏览器自检的静态服务器
  browser/               浏览器自检:index.html + harness.js + react-lite.js
```

### 关键设计决策

1. **占位目录**:Codespace 工作区在 DSH 自己的 registry 里登记为一个真实存在的
   本地目录 `<DSH_HOME>/codespaces/<name>/`(`dsh-workspace` 要求路径存在且可
   `realpath`)。会话 `cwd` 就是这个路径,DSH 的工作区/会话机制原样工作。
2. **按 cwd 路由,不替换全局服务**:任何会话只要 `cwd` 落在占位根目录下,就是
   Codespace 会话。其余会话与其他插件完全不受影响。
3. **按 Agent 注册工具**:在 `agent/created` 时,把远程实现注册到 `agent.ctx`,
   用 `tools.restrict()` 屏蔽本地对应工具。这是 DSH 支持的 per-agent 扩展点。
4. **一行提示词**:只在会话创建时注入一条规则,不做每轮提醒。
5. **退出即停**:退出时停止所有本插件管理的 Codespace。挂点是 `ctx.effect` 的
   cleanup —— `runProfile` 的 dispose 会 await 根 fiber 的 `fiber.dispose()`,而
   cordis 会并发 await 每一个 effect disposer(已核对 `fiber.ts` 的 `_unload`)。
   预算很紧:进程 5 秒后强退,崩溃路径只有 2 秒。因此 `stopAll()` 是幂等的、用
   `Promise.allSettled` 并发发出、单个请求 3.5 秒超时、且**绝不 reject**(reject 的
   disposer 会让 `runProfile` 抛 `AggregateError`)。没有停成功的名字写进
   `pending-stop.json`,下次启动的“残留 Codespace”提示会一并列出。

## 验证

四套离线自检(不需要 token、`gh` 或真实 Codespace),加一套需要令牌的联网自检:

```bash
# Node 侧:包清单与打包白名单、Config 默认值、RPC 动作表与 HTTP 守卫、
# 工作区命名、发消息前的就绪检查、shell 引用、文案一致性
node test/check.mjs                # 181 项,全通过

# 退出即停:对着真实 CodespaceManager 跑,只把 fetch 换成桩
node test/shutdown.mjs             # 31 项,全通过

# 载体端到端:起真实的 dsh-host-webserver + dsh-client-connection,
# 用真实签名 cookie 走真实 HTTP
node test/e2e.mjs                  # 全通过(找不到已安装的 Harness 时自动 SKIP)

# 浏览器侧:在真实 DOM 里跑真实 bundle,对着宿主 markup/CSS 的忠实副本
node test/serve.mjs                # 然后打开 http://127.0.0.1:19501/
                                   # 217 项,全通过

# 联网:用 gh 的令牌打真实 GitHub API(无令牌时自动 SKIP)
node test/live.mjs
```

`test/live.mjs` 是唯一会碰网络的一套,它存在的理由很具体:离线套件看不见本机那个
**TLS 拦截**故障——中间人根证书只在 Windows 系统信任库里,Node 的 `fetch` 只信自带
CA 包,于是所有 GitHub API 调用都变成 `TypeError: fetch failed`,而 `gh` 走 Go 读系统
库完全正常。这套测试先量出「直连能不能用」,再断言控制面**两条路都通**,这样回退路径
真的被走到时才算数。

`test/e2e.mjs` 是这套里最有价值的一个:它不驱动桩,而是从已安装的 Harness 里
`import` **真实的** `dsh-host-webserver` 与 `dsh-client-connection`,注册真实的
路由,然后对真实监听的端口发真实 HTTP。它证明的正是桩测不了的三件事:

1. 路由能穿过 DSH 自己的 `/api` 前缀路由到达;
2. **没有会话 cookie 的请求被 harness 拒掉(401)**——即路由没有遮住 fence 和鉴权;
3. 用真实 launch-token 换来的**真实签名 cookie** 能打通,拿到 `{ok:true}`。

它还**当场演示了影子风险**以证明自己有牙齿:故意在同路径注册一个 `webServer`
exact 路由,匿名请求立刻从 401 变成 200(绕过了 fence);撤掉它,401 回来。
所以「匿名必须是 401」这句断言对载体是敏感的——这正是之前那版实现栽的地方。

`test/shutdown.mjs` 覆盖的是「退出即停」这条最重要的可靠性要求——一个没停掉的
Codespace 会按小时计费,而用户看不见也补不回来。它断言的是真正要紧的性质:

- 每个受管 Codespace 都被要求停止,而且是**并发**发出(两个 300ms 的停止必须在
  550ms 内跑完,串行会超)。
- 请求失败、连接被拒、**API 收下连接但永不回复**这三种情况下,disposer 都**必须
  resolve**。reject 的 disposer 会让 `runProfile` 抛 `AggregateError`——那就从
  「有一个 Codespace 没停掉」变成「DSH 退不出去」,是更糟的失败模式。
- 最坏情况仍在 5 秒进程预算之内,且不早于单请求的 3.5 秒预算。
- 幂等:teardown 跑第二次不再发任何请求。
- 没停成功的名字会写进 `pending-stop.json`,下次启动的残留提示还能兜住。
- 没有 token 时不去发请求,而是把名字全部记成 pending。

其中「API 永不回复」这一例用的是**真实 socket**(本地起一个只收不回的
`http` server,把真实 `init`——含 signal——转给真实 `fetch`),不是桩。原因值得记:
Node 里 `AbortSignal.timeout()` 的定时器是 **unref'd** 的,一个「返回永不 settle 的
promise」的桩不持有任何 ref'd handle,进程会直接退出而不是触发超时——用桩测这一条
会得到假结果。已单独核实:真实 socket 下 `api()` 在 1215ms 抛 `E_TIMEOUT`。

浏览器侧覆盖的是 Node 里没法验证的部分:真实属性反射、`:hover` 驱动的 CSS、
`MutationObserver` 合并、portal 到 `document.body` 的菜单。它实际抓到过两个 bug:

- **时钟双击失效**:倒计时行上,第一次点击会立刻发出「取消倒计时」(这是刻意的,
  因为那里双击没有别的含义),于是剩余时间归零;而 `dblclick` 的处理里原本用
  「剩余时间 > 0」做守卫,结果第二次点击被静默丢弃——需求里的「双击时钟 = 立即暂停」
  从来没生效过。守卫已改为判断**状态**而不是剩余时间。
- **运行中单击不生效**:`beginClickWindow` 只清了计时器并重绘,从来没有真正发出
  被延后的那个动作,所以运行中的行单击一次什么都不会发生。现在窗口关闭时会按
  **当前**记录重新推导动作(轮询可能在这期间落地),并丢弃已经进入 busy 的记录。

另外它抓到过一个真实的时序问题:`alwaysShowCloudButton` 原本只在设置页挂载时才
读取,因此全新启动时行增强读到的是 `null`,开关实际上不生效。现在由行增强模块
自己在拿到 owner claim 时读一次设置。

### 真机(真实 GUI)上抓到的 bug

下面这些都不是桩能测出来的,必须对着跑起来的 DSH 才暴露;而且第一个的成因是
**我自己的测试把 bug 写进了断言里**,所以它一直是绿的。

**一、`状态读取失败:Requests must be same-origin.`**

设置页的「状态」卡片一直红着,`GitHub CLI (gh)` 卡在「检查中…」。原因是 RPC
路由上那道**自己手写的** Host/Origin fence 要求请求必须带 `Origin`,而桌面外壳
的载体**两者都不带**(`Origin` 和 Fetch-Metadata 都没有),于是每一发都被 403。
Web GUI 反而正常,因为浏览器会发 `Origin`——这就是它看起来「只坏一半」的原因。

修法不是放宽自己那套,而是**逐字对齐 harness 自己的 `isTrustedApiRequest`**:它
的第一条就是 `if (origin === void 0) return true`。承重的检查是 **Host**,因为
`Host` 是 DNS rebinding 唯一伪造不了的头;`Origin` 与 `Sec-Fetch-Site` 只是补充。
另外 `isLoopbackHostname` 接受 `localhost`、`[::1]` 和**整个 127/8**,不只是
`127.0.0.1`。

对着跑着的宿主复现过(旧代码):不带 `Origin` → 403,带上匹配的 `Origin` → 200。

**二、我自己的路由遮住了 harness 的鉴权(安全相关)**

修好 fence 之后顺手量了一下,发现更值得修的一处:路由原本注册成 `webServer` 的
**exact** 路由,而 webserver 的 `match()` 是**先查 exact 表、再查前缀表**,所以它
比 DSH 自己的 `/api` **前缀**路由先命中,直接把 `/api` 那条路整个遮掉了——而
fence 和浏览器鉴权(cookie)都挂在 `/api` 那条路上。实测(旧代码):

```
POST /api/dsh-codespace-workspace/rpc   (无 cookie,Origin 匹配)-> 200
POST /api/no-such-plugin-xyz            (无 cookie,Origin 匹配)-> 401 unauthorized
```

第二行是 DSH 在拒绝未鉴权调用;第一行说明我们的 exact 路由绕过了它。

正确做法是注册到 `connection.fetch`:DSH 自己的 `/api` 前缀路由会先 `admit(req)`
(Host/Origin fence **加上** cookie 鉴权),再 `bridge()` 派发到插件注册的 Fetch
路由。这样等于白拿 harness 的安全策略,浏览器和桌面外壳还都能用。exact 路由现在
只作为「composition 里根本没有 `connection` 服务」时的兜底保留。

两个细节值得记:

- `connection` 不在本模块的 `inject` 里,直接读 `ready.connection` 会被 cordis
  门禁拦下(`cannot get property ... without inject`)。要用 `ctx.get(name, false)`
  ——cordis 文档里写明的「不经 inject 读取服务」的公开入口。
- 服务加载顺序没有保证,所以载体选择**不能是一次性判断**。`ready.inject(['connection'], ...)`
  是公开的触发点(已实测:服务晚到时它会触发,永不出现时保持沉默且不抛),
  于是晚到的 `connection` 会把兜底路由换掉,而不是让进程一直停在较弱的载体上。

**三、`仓库读取失败:cannot reach the GitHub API (TypeError)`**

`gh` 装好、状态卡全绿之后,仓库列表依然读不出来。原因是**本机有 TLS 拦截**:中间人
根证书只装在 Windows 系统信任库里,而 Node 的 `fetch` 只信**自带**的 CA 包,于是每次
调用都失败——真正的成因藏在 `error.cause.code = UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
而旧代码只把 `error.name` 写进消息,于是用户看到的是一个毫无信息量的 `TypeError`。
`gh` 用 Go 读系统库,同一个请求完全正常,这才造成「令牌来自 gh auth token 却调不通
API」这种一半好的假象。

修法是给控制面加一条**进程内**的回退:`tls.getCACertificates('system')` 取到系统信任库
(本机 225 个证书),失败的那一发改走 `node:https` 请求并带上 `ca`,结果包成真的
`Response`,所以下游的 `status`/`headers.get`/`text()` 全都不用改。**没有**用
`tls.setDefaultCACertificates()`——那会让本插件偷偷放宽整个 host 进程的 TLS 信任,影响到
别的插件;改动留在自己的传输层里。错误消息也改成报 `cause` 的 code,不再是 `TypeError`。

另有 `NODE_OPTIONS=--use-system-ca` 可用(一次修好所有 Node 代码),但它要改环境变量并
重启 DSH,所以只作为备选写在这里。

**四、启动图标的样式和旁边的按钮不一样**

需求里那句「样式要和其他的等价」原来没做到:我的按钮做成了 **16×16**、`radius-xs`、
hover 只变色,而标题栏真正用的 `_9lTDKa_iconButton` 是 **28×28**、`radius-sm`、
`--dsw-alias-label-secondary`,**hover 出现背景**而不是变色。图标本身也偏重:我写了
`stroke-width="1.3"`(那是宿主 `Medium` 档)**并且写在 path 上**,而旁边的加号是
`IconProjectAddOutlineRegular`,即 **1**,且按 primitives 的规范 `stroke-width` 应该放在
`<svg>` 上。

我一开始把 16×16 当成对的,是因为把 `Rows` 模块的 `hIlkoa_iconButton`(工作区行的按钮,
确实是 16×16)错当成了标题栏的类——**浏览器 harness 的夹具也照抄了这个错值**,于是
「launcher 和其它按钮一样大」这条断言一直是绿的。夹具已按真实 CSS 改正,并补上几何、
圆角、颜色、hover 背景、描边宽度、rail 下字形 18px 的断言(浏览器侧从 107 项涨到 114 项)。
云朵路径同时抽成一个常量,React 与 DOM 两条渲染路径共用,避免再次漂移。

**五、图标会「有时候」跑到「新建工作区」按钮的右边**

听起来像时序问题,其实是**确定性**的,只是触发条件不明显:**只有宿主自己的提示框
正开着时才会发生**。原因是 DSH 的 `Tooltip` 在缺省配置下**不是 portal**(工作区标题栏
没传 `portal`),所以气泡是以**兄弟节点**的形式渲染在**同一个 `headerActions` 容器**里
的。我那段「把按钮插到最后一个子元素之前」的代码于是把按钮插到了气泡前面 —— 也就是
「添加工作区」的右边。

更糟的是它**不能自愈**:一旦插错,按钮的 `nextElementSibling` 正是那段代码要去找的
东西,于是每次重扫都「看起来已经就位」。现在改成**记住锚点元素**、永远贴在它前面,
而不是每轮重新猜「最后一个控件」;选锚点只看标签名(`BUTTON`),不看子节点顺序。
浏览器套件补了两条会真的触发它的回归测试:宿主重渲染追加控件、以及一个开着的提示框
气泡。另外我自己那个提示框**挂到 `document.body`**,从根上不再进这个容器。

**六、`typeof` 一个未声明的名字让提示框静默失效**

改提示框时踩了个安静的坑:`attachTooltip` 是模块作用域的函数,而 `doc` 是
`installRowAugmentation` 的**局部变量**。我写了 `typeof doc === 'undefined'` 当守卫 ——
但 `typeof` 对**未声明的标识符**返回 `'undefined'` **而不抛错**,于是这个函数每次都直接
返回空操作:**没有报错、console 干净、看起来一切正常,只是提示框永远不出现**。

现在改成在函数内部解析 `document`(那是真实全局)。这条也写进了记忆:客户端 bundle 里
绝不要用 `typeof someUndeclaredName` 当守卫。

**七、图标补上了右上角的加号,云朵保持原尺寸、只在交叉处断开**

「新建云端工作区」的图标原本是一个单独的云朵,而它旁边宿主自己的按钮是
`IconProjectAddOutlineRegular`(文件夹 + 加号)。现在这个图标是**云朵 + 加号**:加号
**逐字抄**宿主那两条 path。

第一版我把云朵**缩小**塞到左下角、给加号让位。那不对 —— 宿主的做法是主体**保持原尺寸**,
只把轮廓在加号穿过的地方**断开**(它的文件夹就是满尺寸,右上角被切开让加号坐进去)。
现在照这个做:云朵用**未修改的原 path**,套一个 SVG `<mask>`,mask 里先把 16×16 的白底
铺满,再用 `stroke-width:2.6` 的**黑线**沿着加号两条笔画走一遍 —— 于是云朵轮廓在加号
经过的那一段被擦掉,云朵本身的尺寸一点没变。

几个具体取舍:

- 用 mask 而不是手算断点。这条轮廓与加号实际有**两处**相交(竖笔切右瓣于 y≈6.706,横笔
  的**描边带**还会蹭到大弧 x≈9.76 附近),手写子路径就得把这两组交点算准并永久跟着
  path 同步;mask 直接表达「绕开加号」这个意图,不会漂移。
- 裁切笔画用 `stroke-linecap="butt"`:圆头会多伸出半个带宽,在瓣的下端可能咬下一小块
  孤立的碎线。
- 每个图标一个独立 mask id(`maskUnits="userSpaceOnUse"`)。id 是全文档唯一的,两个
  图标共用会让 `url(#id)` 解析到第一个,静默用错 mask。

校验上补了一条**栅格化对照**:克隆同一个图标、只摘掉 cloud path 上的 `mask` 属性,
两张都画到 canvas 上逐像素比对 —— 差值就是被 mask 擦掉的那部分墨。这一点很重要,因为
**结构断言证明不了 mask 真的擦掉了东西**:mask 颜色写错、`maskUnits` 漏了、id 撞了,
都照样渲染成一朵完整的云。我前两版采样框写法都不稳(按「列」判空会被弧线在别的高度上
的墨抵消;按固定矩形采样则会被抗锯齿边缘的零星像素和加号自身的墨干扰),最后换成这种
不依赖几何假设的做法。

**八、hover 提示框换成 DSH 那种**

原来用的是原生 `title`,画出来是操作系统的气泡,和旁边宿主控件的完全不是一回事。现在
照抄宿主 Tooltip 的**渲染结果**:`position:fixed`、真实的 tooltip 设计令牌、
`role="tooltip"`、500ms 悬停延迟(宿主 `delayMs: 500`),并挂在 `document.body` 上。
CSS 全部只引用 `--dsw-alias-tooltip-bg` / `--dsw-static-neutral-bluish-00` 这些主题
变量,不硬编码任何颜色值。

**九、预构建提示原本是二态,把「读不出来」说成了「你没配」**

用户报「仓库明明配了预构建,却仍提示未配置」。查下来那一次**插件是对的** —— 仓库当时
确实没配好。但顺着这条线查,发现了一个真的缺陷,位置和我一开始猜的不一样。

先纠正我自己一个错判:我一度怀疑 `prebuild_availability` 对个人账号根本不填、导致永远
误报。**实测推翻了它** —— 我扫了 15 个仓库共 23 个机型行(含 `microsoft/vscode`、
`python/cpython` 这类确实配了预构建的):

```
null     x23
"ready"  x3      ← 只出现过这两种值
```

**从来没有 `"none"`,也没有 `"in_progress"`**。所以「字段可读但为 `null`」**确实就是
「没有预构建」**,插件的 `false` 判断是站得住的、不需要改。

真正的缺陷在**失败路径**:客户端原来写的是

```js
setPrebuild(data.hasPrebuild === true ? true : false)   // null 被压成 false
.catch(() => setPrebuild(false))                        // 失败也被压成 false
```

于是**只要读取失败,就显示「未配置预构建」** —— 那是把插件自己的故障当成你仓库的事实
来陈述。现在 `hasPrebuild` 的 `true | false | null` **三态一路传到 UI**,`null` 单独
渲染成「无法确认预构建状态,创建可能需要 1–3 分钟」,而且**不给按钮**(没有可决定的事,
给了反而让人以为需要决策)。

顺带查出并修掉两个**既有 bug**:

- **机器步会直接崩溃**。我删掉 `needsNotice` 时漏了「下一步」按钮的 `disabled` 还在引用
  它 —— 一进机器步就 `ReferenceError`。这是浏览器套件当场抓到的。
- **确认后反而说「已配置预构建」**。点过「直接创建」之后,`prebuildAcknowledged` 会让
  渲染落到「该仓库已配置预构建。」那一支 —— 恰好说了与刚刚相反的话。现在确认后不再显示
  任何提示,而不是显示一句假的。

浏览器套件 136 → **147 项**(后续又涨到 217,见下文「十一」「十三」),其中 8 项专测这三态(含
「`null` 绝不能说成未配置」和「确认后绝不能说成已配置」两条否定断言)。

**十、云端会话里本地工具根本没让位 —— 提示词说远程,手上还是本地**

用户报「扫描工作区时环境与流程不一致」。顺着这条线查,抓到了这个插件**最核心那条
承诺**上的一个真缺陷:`installRemoteTools` 从未真正执行过。

成因是开头这三行:

```js
const tools = agentCtx?.tools;
if (tools === undefined) return () => {};   // 静默放弃
```

`agent/created` 那一刻的 `agent.ctx` 是**作用域 ctx**,cordis 对没有写进 `inject` 的
服务本应门禁报错(`cannot get property "x" without inject`)——**但这里偏偏不报**。
`ReflectService.handler.get` 的最后三行是(已核对 `@deepseek-ai/cordis` `lib/index.js:680`):

```js
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false);   // 退化成「不需要 inject」的读法
```

agent 作用域 ctx **没有运行中的 fiber**,于是这次读取降级成 `undefined` 而不是抛错。
于是整个函数变成空操作:**没有报错、console 干净、远程工具一个没注册、本地
`read`/`write`/`edit`/`bash` 全部照旧可用**;而 `session.js` 在同一时刻已经把
「你这个会话操作的是云端 Codespace」写进了系统提示词。两边就是这么对不上的。

DSH 自己的写法在 `dsh-file-reference-local` 里给得很清楚
(`lib/index.js:342`):`agent.ctx.inject(["systemPrompt","tools"], (scope) => …)`。
**等工具服务就绪再安装**是唯一正确的做法,现在也照这个改:

```js
fiber = agentCtx.inject(['tools'], (scope) => {
  const tools = scope.tools            // ← 只能从 scope 拿,不能从 agentCtx 拿
  …
})
```

关键细节是服务要**从 `inject` 回调的 `scope` 参数上取**:只有 `inject` 新建的那个
fiber 的 store 里才有 `tools`,`agentCtx` 上永远没有。收尾用 `fiber.dispose()`。

顺带澄清一处容易混的地方:上文「二」里 `ctx.get(name, false)` 那个「不经 `inject`
读服务」的口子,解决的是**「服务可选、要探测在不在」**的问题;而这里的问题是
**作用域** —— 注册远程工具要的是 agent 作用域里的那个 `tools` 实例,只有 `inject`
新建的那个 fiber 的 store 里有它。我没有去核实 `get` 能不能从 agent ctx 上摸到同一个
实例,也没必要:`inject` 是 DSH 自己的做法,抄它就对了。

`check` 111 → **122 项**,新增的 11 项专测这条接线,覆盖三种情形:工具已就绪、工具
**晚到**、以及根本没有 `agent.ctx`。改完后我把源码退回旧写法反向验证了一次,两项立刻
变红:

```
FAIL when tools arrives, the remote surface is installed -- got "", want "read,write,edit,glob,grep,bash"
FAIL   -> and the local tools are hidden
```

这正是它值得存在的理由:一个「静默空操作」的 bug,只有故意让它失败一次,才能证明测试
真的看得见它。

**十一、行首多了一个图标,状态图标用的是 emoji**

两处都是「看起来能用,但和旁边的东西不是一类」的问题。

**(一)云朵被加在文件夹**旁边**,而不是**替换**它**

需求写的是「替换原来工作区的文件夹图标,样式等相同」,我做成了「在行首再插一个云朵」,
于是行首出现两个图标。

根因不是手滑,是**读错了宿主的 CSS**。`Rows` 模块里三条相关规则是:

```css
.hIlkoa_projectRow .hIlkoa_chevron{display:none}            /* 平时:箭头藏起来 */
.hIlkoa_projectRow:hover .hIlkoa_chevron{display:inline-flex}
.hIlkoa_projectRow:hover .hIlkoa_folder{display:none}       /* 悬停时才轮到文件夹让位 */
```

我当时只看到最后一条,就把它当成了「文件夹在两种状态下都不可见」,于是得出「没有可见的
文件夹可替换」的结论,选择另加一个。**实际上文件夹在平时是看得见的**,它是行的第一个
子元素,悬停时才让位给展开箭头。

更糟的是,这个错误结论被**夹具固化**了:`test/browser/index.html` 里写着一条

```css
.hIlkoa_folder { display: none; }      /* ← 宿主里根本没有这条规则 */
```

于是「文件夹本来就看不见」在测试里成了前提,所有断言都是绿的。这和上文「四」里把
16×16 当成 28×28 是同一类失误:**夹具抄了一个错的模型,测试就成了 bug 的帮凶**。

现在改成把云朵塞进宿主那个 `span.slot.folder`,并用内联 `display:none` 藏掉宿主自己的
svg(原 `style` 值连同标记一起存下,撤销时还原)。好处是位置、尺寸、颜色、以及「悬停让位
给箭头」全部**从宿主 CSS 继承**,我一行都不用复刻。夹具里的伪规则已按真实 CSS 修正。

补充一个实现细节:文件夹在展开/折叠时会换 svg(`IconFolderClose` ↔ `IconFolderOpen`),
React 会把节点换掉,所以每次 `sync()` 都要重新藏一遍「现在这个」;循环里要跳过我自己那个
节点——它也是子元素,第一版没跳过,第二次 `sync()` 就会把云朵自己藏起来。

**(二)状态图标是 emoji**

四个状态原本用 `⏸️`/`🕐`/`⚠️`/`▶️`。问题是 emoji 是**平台字体的彩色字形**,有自己的
光学尺寸和粗细,它和旁边两个控件(`Menu` 的三点、`IconNewChatOutline` 的新建对话)——都是
16px、1px `currentColor` 描边、跟随主题色的 `Icon*OutlineRegular`——放在一起,就是一个
更重、全彩、且每个系统渲染都不同的方块。它连主题色都不跟随。

现在四个图标都**照抄宿主自己的 artwork**(path 逐字复制,只挑用哪个字形):

| 状态 | 抄自 |
|---|---|
| 暂停/停止 | `IconPauseOutlineRegular` |
| 倒计时 | `IconAlarmClockOutlineRegular` |
| 异常 | `IconWarningTriangleOutlineRegular` |
| 启动 | `IconPlayOutlineRegular` |

抽了一个 `iconNode(spec)` 承载公共规范(16px 盒子、`Regular` 权重 = 1 **写在 `<svg>` 上**、
`fill="none"` + `stroke="currentColor"`),但每个字形保留**它自己的** `viewBox` 和线帽设置
——闹钟就是画在 **17×17** 网格上的,warning 三角形要用圆角线帽,这些都不能被"统一"掉。

顺带把**多步窗口右上角的关闭按钮**也一起改了:那里的 `✕` 是同一类问题——用文本字符
当图标。现在换成宿主自己的 `IconCloseOutlineRegular`(两条对角线,逐字照抄)。全库扫过
一遍,`lib/client.js` 里已经**没有任何 emoji 当图标**,只剩两条注释在举例说明旧实现。

浏览器套件 147 → **201 项**。新增的不只是数数,而是**能抓到这两类错误**的断言:
- 云朵必须在 folder 槽**里面**,且 folder 槽仍是行首第一个子元素;
- 宿主的 svg 真的 `display:none`,云朵真的可见;
- **静止状态下整个行只有 1 个图标可见**——这条否定了「加一个在旁边」;
- 承载云朵的 span 正是宿主 `:hover ._folder{display:none}` 规则所命中的那个(悬停行为等价);
- 每个字形逐条比对 `viewBox` / `stroke-width` 在 svg 上 / 每个 path 都是 `currentColor`
  且**都不带** `stroke-width`,外加一条「glyph 文本里不得含 emoji 码点」;
- 关闭按钮是 svg 而不是文本,artwork 与 `IconCloseOutlineRegular` 逐字一致,且整个窗口
  标题区**不得出现任何 emoji 码点**。

两类错误都反向验证过牙齿。把「替换」退回「在行首另加一个」,**6 项变红**,其中最关键的是

```
FAIL exactly one glyph is visible in the resting row — got 2, want 1
```

把关闭按钮退回 `'✕'`,**5 项变红**,最后一条正是那条 emoji 扫描。

**十二、`icon.svg` 没打进安装包,以及工作区名是一串乱码**

**(一)`Plugin metadata ... ENOENT ... icon.svg`**

`package.json` 里有 `"icon": "./icon.svg"`,仓库里也**确实有**这个文件(第一个提交就在),
但 `files` 白名单里**漏了它**。`files` 是 npm/pnpm 的打包白名单,而这个 profile 是从 GitHub
tarball 装的(`github:Ckarefulon/dsh-codespace-workspace`)—— 打包时按白名单过滤,`icon.svg`
就被丢掉了。DSH 读插件元信息时按 `icon` 字段去找它,`lstat` 失败。

关键在于**仓库看起来是好的,只有打包后的副本是坏的**:本地 `git status` 干净、文件就在
根目录,所以怎么读源码都发现不了。这也是为什么现在加了一条自检:把 `main`、`icon`、
每个 `exports` 目标、以及 `dsh.bundle.patch` 都拿去和 `files` 白名单比对,缺一个就红。
`package.json` 本身豁免(npm 无论如何都会打它)。

**(二)工作区名是 `psychic-goggles-7v9974jwpwr5fpqxq`**

那是 Codespace 自己的名字,GitHub 机器生成的,看不出是哪个项目。现在行标题取**仓库短名**
(`owner/name` 里的 `name`),标题装不下的两件事放进 hover 卡片那一行:
**`owner/name · Codespace 名字`**。

三处要一起改,否则会出现「新行对、老行还是乱码」或者「有 locale 服务时对、没有时显示原始
key」:

- `titleForCodespace()`(host 侧)决定新行的标题;
- `listWorkspaces()` 里加了一步**回填**,把已登记记录改成新标题 —— DSH 的 registry 会
  **保留已存在记录的标题**,不回填的话你现有的那一行永远还是旧名字;
- 客户端 `hoverLocationOf()` 拼 hover 那行。

这里还踩到一个自己造的坑:`lib/client.js` 里**另有一份内联的 zh/en 字典**(没有 `locale`
服务时用它,浏览器 harness 就是这种情况)。我只改了 `locale/*.json`,于是浏览器套件里那行
直接显示成了字面量 `row.hoverRepo`。原来的 locale 自检只比对「两个 JSON 文件之间」和
「JSON 与 `t()` 调用之间」,**从没比对过内联字典**——现在补上了,两边键集合必须一致
(`nav` 豁免,那是宿主读的)。

`check` 122 → **147 项**(含 10 项 `titleForCodespace`、11 项打包白名单、2 项内联字典),
浏览器套件 201 → **203 项**。两处都反向验证过牙齿:把 `icon.svg` 从白名单里删掉 → 1 项变红;
把 hover 那行退回只显示标签 → 1 项变红(`got "my-box", want "Ckarefulon/app.ckarefulon.main · my-box"`)。

**十三、云朵不变蓝、转圈停不下来、转圈是个方块,以及发消息不等机器**

四处都是「照理说应该很明显」的问题,四处的原因各不相同。

**(一)正在使用的工作区里,云朵不变蓝**

宿主是这样标记「当前会话所在的工作区」的:给那一行的**文件夹图标槽**加一个
`folderActive` 类,规则是 `.…_folderActive{color:var(--dsw-alias-state-business-primary)}`
—— 也就是变蓝。云朵本来就排在那同一个 span 里、`stroke="currentColor"`,看起来应该自动继承。

问题是**我自己给云朵的容器写了一行 `color: var(--dsw-alias-label-secondary)`**。继承是继承,
但**显式声明会盖掉继承值**,于是云朵永远是灰的。删掉那行就好——位置、尺寸、颜色、悬停让位
全都该由宿主的 CSS 说了算。

这条还牵出一个**夹具的老毛病**(这是第三次了,前两次是 16×16 当 28×28、`:hover` 当无条件):
夹具里 `.hIlkoa_slot`(设 `label-tertiary`)**排在** `.hIlkoa_folderActive` **后面**,
而真实宿主里是反过来的。两个都是单类选择器,同优先级,所以**后写的赢** —— 夹具里的
`folderActive` 根本不起作用,颜色测试一开始是红的。夹具的规则顺序已按宿主改正,并写了注释。

**(二)「正在切换状态…」能转一整个回合**

`isBusy()` 里有一行 `if (record.busy === true) return true`。而 `record.busy` 是
**host 在 `user/message` 时置位、在 `turn/end` 时清除**的 —— 它的意思是「Agent 正在这个
工作区里干活」,可以持续好几分钟。把它接进「该不该转圈」里,按钮就会在整个回合里转,还
配着「正在切换状态…」这句错误的话,而 GitHub 那边的状态其实很明确。现在 `isBusy` 只认两件事:
**我们自己的 RPC 在飞**,或者 **GitHub 说它确实在过渡态**(`Provisioning`/`Starting`/
`ShuttingDown`…)。`record.busy` 保留在契约里,只是不再驱动这个按钮。

**(三)转圈图标是个方块**

原来是一个 CSS 环:`border:2px solid; border-top-color:transparent; border-radius:50%`。
但宿主自己的加载指示器是 **`IconLoadingOutlineRegular`** —— 一段圆圆弧,配一个纯旋转动画
(在 DSH 自己的 bundle 里核对过)。现在照抄它,于是转圈和那四个状态图标、以及旁边宿主的
控件是同一套语言(16px、`currentColor`、1px 描边)。顺带补了 `prefers-reduced-motion`。

**(四)发消息时没有等 Codespace 起来**

Codespace 自动暂停之后,直接在会话里发消息,每个远程工具调用都会撞上连接错误。现在挂
**`agent/pre-step`**:Agent 循环在向模型要 step 之前会 **await** 这个 waterfall
(`dsh-agent-loop/lib/index.js` ≈L911),所以在这里等是真的在推迟这一轮,而不是和它赛跑。
DSH 自己的 `archived-session-gate` 用的是同一个钩子 —— 但它是**拦住**这一轮;这里要的是
**等**,所以拿到结果后照常 `next()`。

几个克制的设计:**本地会话完全不碰**(cwd 判断在最前面);`ensureRunning` 永不抛错,失败就
`{ok:false, reason}` 然后照常放行(否则用户会看到一轮莫名其妙地什么都没发生,而不是一个
明确的连接错误);等待受这一轮的 abort signal 约束,用户中断立刻结束;成功状态记 20 秒,
所以一个回合最多查一次 GitHub,而不是每一步都查。

`check` 147 → **181 项**(33 项新增:`ensureRunning` 的各种路径 + `agent/pre-step` 网关的
行为),浏览器套件 203 → **217 项**。三处都反向验证过牙齿:不启动机器 + 把过渡态当成就绪
→ 6 项变红;给云朵加回颜色 + 让 `busy` 重新驱动转圈 → 8 项变红。

## 已知限制

- **工作区分组标题栏的图标行、以及工作区行,都没有官方插槽**。DSH 把「添加工作区」
  按钮和 `ProjectRowItem` 的操作按钮、文件夹图标全部写死,因此以下四处通过 DOM 增强
  实现(与 `dsh-pet` 处理设置导航图标同一手法):标题栏里的新建按钮、行首云朵、
  行内启停按钮、hover 文案(外加菜单里的「删除 Codespace…」)。做法是全部打上
  `data-dsh-codespace-*` 标记、一个合并的 `MutationObserver`、dispose 时完整撤销,
  并且**不删除任何 React 拥有的节点**:行首的云朵是把自己塞进宿主那个文件夹
  `span.slot` 里,再把宿主自己的 svg 用内联 `display:none` 藏起来(连同原 `style`
  一起登记,撤销时还原)。这样位置、尺寸、颜色和悬停让位全都从宿主的 CSS 继承,
  不需要我自己复刻。宿主改版可能影响它。已核对:客户端运行时的
  插槽目录共 90 个,其中有 `sidebar.workspaces.session.menu.item`,但**没有**工作区行
  的菜单插槽,也没有标题栏图标行的插槽;`sidebar.footer.action` 是真实存在的插槽,
  但它把控件放在侧边栏底部,不符合「与原有三个图标同地位、紧挨添加工作区左侧」的要求。
  标题栏的 `_sectionHeader` / `_headerActions` 两个类名后缀经核对为
  `dsh-client-ui-workspace` 独有,不会误匹配;行首文件夹槽位按 `_folder` 后缀匹配,
  并限定在 `span[class*="_slot"]` 上,避免误伤同模块的 `_folderActive`。
- 标题栏图标行是 `justify-content:flex-end` + `max-width:60px`,而标题栏的控件是
  **28×28**(`_9lTDKa_iconButton`),所以 60px 正好放**两个**(28+4+28)。加进第三个
  控件需要放宽容器宽度,放宽规则带了 `:not([class*="_headerActionsHidden"])`,以免盖掉
  宿主自己在展开搜索框时的收起行为。**注意别把 `Rows` 模块的 `hIlkoa_iconButton`
  (16×16,工作区行的按钮)当成标题栏的类**——两者同后缀不同模块,混淆过一次,见上文
  「四」。另外「添加工作区」按钮只在目录流插槽被占用时才渲染,所以插入位置是「最后一个
  子元素之前」而不是固定下标。窄栏(rail)下宿主把盒子放大到 36px、字形放大到 18px。
- 远程工具以文本方式传输文件(base64),大文件不适合;大仓库建议让 Agent 在
  Codespace 内直接操作。
- `gh` 缺失时依赖用户自行配置 SSH 别名。设置项名为「SSH 密钥」,但**私钥路径无法
  指定主机**,所以该值被当作 `~/.ssh/config` 的主机别名使用;填成路径会得到一条
  说明如何修正的错误,而不是把路径当主机名交给 `ssh`。
- 未在本机实测的部分:**真实 SSH 数据面**(没有已配置的 Codespace 目标),以及
  Codespace 的创建/启停/删除这些会改云端状态的调用。控制面本身已联网实测:
  `test/live.mjs` 用 `gh` 的令牌打真实 GitHub API,`getViewer` / `listRepos` /
  `listCodespaces` 都通(本机 18 个仓库、0 个 Codespace)。命令拼装也已用真实 bash
  验证:20 组逐字节往返 + 5 组注入载荷,均保持为单个 shell 词且无副作用。

  **「退出即停」不在此列**:它的逻辑已按上面的 `test/shutdown.mjs` 实测(并发、
  不 reject、5 秒预算内、幂等、pending 落盘)。仍然未经实测的只有「真实 GitHub
  是否接受这次 stop 调用」这一层——那取决于 token 与账号,不取决于本插件的逻辑。
  另外「宿主在退出时确实会 await 根 effect 的 disposer」这一条是**读 DSH 源码**
  核实的(`runProfile` 的 `dispose` → `fiber.dispose()` → cordis `_unload` 并发
  await 每个 disposer),不是在本机跑一次退出观察到的;本机没有可复现的 Codespace
  目标,无法做端到端退出观察。

## 发布(维护者)

包名 `dsh-codespace-workspace` 已发布到 npm。`package.json` 里写了 `publishConfig`
(`access: public` + `registry: https://registry.npmjs.org/`),所以**直接 `npm publish`
不带任何参数**就会发到正确的地方——即使本机 `~/.npmrc` 指向的是镜像站。

> 注意:这个包曾经带 `"private": true`(那时只从 GitHub 装)。npm 会以 `EPRIVATE`
> 直接拒绝发布,所以该字段已**删除**(不是改成 `false`)。`test/check.mjs` 里有一条
> 断言专门盯着它「必须不存在」,复制回来会立刻变红。

后续版本**自动发布**:推送到 `main` 时 `.github/workflows/publish.yml` 会先跑测试再
`npm publish`——但**只有 `package.json` 里的 `version` 是 registry 上还没有的版本时才发**。
所以发版流程就是两步:

1. 改 `package.json` 的 `version`。
2. 推送到 `main`。

这次运行要么发出这个版本,要么报告「该版本已在线上」并以成功结束。
**没改版本号的推送是 no-op,不是失败**——所以每个要发出去的改动都必须自己带一次版本号
变更;只改文档的提交同样会触发工作流,同样 no-op。

认证用 npm **Trusted Publishing(OIDC)**:GitHub 给这个 job 签一个短时 token,npm 拿它换
一个只对这一个包有效的发布 token。**仓库里没有任何 `NPM_TOKEN` secret。** 工作流刻意
**不用** `actions/setup-node` 的 `registry-url` 输入——它会往 `~/.npmrc` 写
`//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`,而该变量没定义时这个占位符会
原样留下、被 npm 当作 token 发出去。registry 交给 `publishConfig` 就够了。

工作流在发布前会先 `npm install` 那两个 peer(`@deepseek-ai/cordis`、
`@deepseek-ai/schemastery`):本仓库故意不 vendor 它们,而 `test/check.mjs` 是沿着
import 路径往上找的,装到仓库自己的 `node_modules` 里才能跑到真实模块。

<details>
<summary>一次性配置:绑定 Trusted Publisher</summary>

Trusted Publisher **只能绑到 registry 上已存在的包**,所以**首次发布是手工做的**
(在检出目录里跑了一次 `npm publish`),从第二个版本起才轮到工作流接管。在 npmjs.com 上:
包 → Settings → Trusted Publisher → GitHub Actions,填

| 字段 | 值 |
|---|---|
| Repository | `Ckarefulon/dsh-codespace-workspace` |
| Workflow filename | `publish.yml` |
| Environment | *留空* |

文件名必须**逐字一致**——npm 把授权绑在它上面,写错会表现为发布报 `ENEEDAUTH`。

</details>

## 文档

- [`docs/CONTRACT.md`](docs/CONTRACT.md) —— 实现契约:经过核实的 DSH API 事实、
  冻结的 RPC 契约、模块接口、以及每条未经核实项的处理分支。

## 许可

MIT

Install

dsh plugin --profile web add github:Ckarefulon/dsh-codespace-workspace

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source