# @wjj-8283/dsh-temp-workspace

一个 DSH web **客户端插件**，提供 **临时工作区（临时工作区）** 功能。

点击侧边栏「添加工作区」(+)右侧的小沙漏图标，即可创建一个一次性工作区并打开一个全新的对话。下次 Harness 重启时，这个插件会**删除该工作区下的全部对话以及工作区本身**——临时工作区不留任何痕迹。

## 它是什么

- **宿主半边**（`lib/index.js`）—— 一个 Cordis 插件，注入
  `webServer` / `workspaceRegistry` / `sessionPersistence`（可信授权列表改为
  按需读取 web app 的 `webStartup` 服务，不作为硬依赖），注册受浏览器信任围墙保护的
  `/temp-workspace/api` 路由，并在启动时驱动清理逻辑。
- **浏览器半边**（`src/client.js` → `lib/client.js`）—— 在侧边栏「+ 添加工作区」
  按钮旁注入一个小图标；点击后调用宿主路由，并在新建的工作区里打开一个新对话。
  它还注册了一个 **设置 → 临时工作区** 独立页面，并在设置开启时于启动阶段弹确认对话框。

## 安装方式

### 从 npm 安装（预构建，推荐）

该包已发布到 npm，`lib/` **为预构建产物**，安装无需构建、可跳过 pnpm 的 `allowBuilds` 授权——一条命令搞定：

```sh
dsh plugin --profile web add @wjj-8283/dsh-temp-workspace
```

`dsh plugin … add` 会把该 spec 转发给 profile 目录里的 pnpm，并代为合并 `dsh.profile.bundles`。因为清单声明了 `dsh.bundle.patch`，包会自动加入 Loader 层栈，下次启动 `dsh web` 时宿主把它组合为 Loader 条目、注册 `/temp-workspace/api` 路由，客户端插件（含设置页面）进入 `__DSH_BOOT__`。

### 从 GitHub 安装

直接从 GitHub 安装，使用 `dsh plugin` 转发器（在 profile 目录里 pnpm clone + `add`，并自动合并 bundle 层）：

```sh
# 启动某个 profile，例如 web 应用
dsh plugin --profile web add github:wjj-8283/dsh-temp-workspace
```

因为清单声明了 `dsh.bundle.patch`，add 会把该包自动追加到 `dsh.profile.bundles`，下次启动 `dsh web` 时宿主就把它组合为 Loader 条目、注册 `/temp-workspace/api` 路由，客户端插件（含设置页面）进入 `__DSH_BOOT__`。

若想自己驱动 pnpm，可在 profile 目录里执行同一 spec：

```sh
cd "$DSH_HOME/profiles/web"
pnpm add github:wjj-8283/dsh-temp-workspace
```

要固定到某个分支/标签/提交，按 pnpm 的 git ref 语法追加：

```sh
dsh plugin --profile web add github:wjj-8283/dsh-temp-workspace#<ref>
```

> 构建后的客户端 bundle（`lib/client.js`）已提交在仓库里，所以 git 安装不需要构建步骤。要改它，克隆仓库后用 `node build.mjs --watch`（见[开发循环](#开发循环)），或改为从本地 checkout 安装（见[接入 profile](#接入-profile)）。

## 设置 → 临时工作区

插件在 **设置导航** 里注册了自己的**独立页面**（`settings.section` 插槽，id 为
`dsh-temp-workspace`），页面里是下面三个设置项。取值存放在 profile patch 中 Loader 条目
`dsh-temp-workspace` 的 `config` 里（dsh 0.1.7 起旧的 `settings.plugin.item` 卡片插槽与
`~/.dsh/settings.yaml` 均已移除）：

| 设置 | 取值 | 默认 | 含义 |
| --- | --- | --- | --- |
| `deleteMode` | `immediate` / `delayed` | `immediate` | 下次启动时何时删除临时工作区 |
| `deleteDelay` | 秒 | `3600` | `delayed` 模式下删除前的宽限期 |
| `confirmBeforeDelete` | 开 / 关 | `开` | 删除前是否先询问用户 |

- `confirmBeforeDelete` **开**：宿主启动时绝不自动删除，而是挂起这些工作区，
  浏览器弹出**确认对话框**，提供两个选项：
  - **删除** —— 清空被挂起的工作区（会话 + 注册 + 目录）。
  - **临时保留** —— 本次不动它们，但**仍然是临时**：它们还在标记里，会在之后的
    启动时再次（按设置）被确认/清理。
  - 未作答时工作区原样保留到下次启动。

**永久保留**不在启动确认弹窗里——它有**自己的按钮**。侧边栏里每个临时工作区行
（标题「临时工作区」）在其「＋」(New Session) 旁边有一个**图钉**按钮。点击后选取一个
目标文件夹（系统目录选择器：桌面端有 `__DSH_DIRECTORY_PICKER__` 桥时直接用它，否则
dsh 0.2.x 走 `uiWorkspace.pickDirectory()`，0.1.x 走 `ctx.workspaces.pickDirectory()`）。临时工作区的文件会
**直接移进该文件夹**（不会额外创建「临时工作区」子文件夹），该文件夹本身成为永久工作区，
并用**其文件夹名作为工作区标题**。同时**迁移对话**：把每个会话日志头行的 `cwd` 改写为该
文件夹路径，并把日志目录移到新 `cwd` 的 projectKey 槽位。迁移进行期间会显示一个
**「正在迁移工作区…」提示**（没有进度条，只提醒不要中途退出 Harness；10 秒后会给出关闭入口）。
成功后弹出的对话框只说明**如何手动重启**——没有「立即重启」按钮，因为桌面端无法重启自己的
宿主进程：

- Web 版：在终端里退出 `dsh` 进程，然后重新打开；
- 桌面版：彻底退出桌面端再重新打开
  - Windows：右键托盘区的 DSH 图标 → 退出；
  - macOS：直接按 Command+Q，或右键 Dock 栏里的图标 → 退出。
- `confirmBeforeDelete` **关**：自动删除——启动时立即执行，或在 `deleteMode = 'delayed'`
  时等待 `deleteDelay` 秒后执行。

## 清理是如何工作的

DSH **没有公开的「删除会话日志」API**——持久化层是 append-only。所以临时工作区的删除分为四步：

0. **先拆在线会话** —— 如果宿主进程里还活着该工作区的会话（确认弹窗、延迟自动删除、或只刷新了页面而宿主并未真正重启时，删除都会在仍持有这些会话的宿主里运行），会尽力拆除它们：
   先取消 agent 并等它回到空闲（`whenIdle`，镜像 agent loop 自身的销毁顺序），再**先把待写内容落盘**
   （`sessions.flush`），最后从会话存储中摘除该条目（发出 `session/disposed`，客户端立即关闭该对话）。
   **先 flush 再摘除对多会话工作区至关重要**：摘除会触发 `session/disposed`，持久化协调器随之做一次
   "退休刷新"（retire → flush → initFor），若会话还有未落盘的写入，这次刷新会在目录被删除后把日志
   重新物化出来——那些会话就会变成不可读取的 Ungrouped 残留。先 flush 后，退休刷新无内容可写，日志
   只会被删除一次。
0b. **归档冷会话** —— 本轮宿主里从未被打开（未 attach）的会话只有磁盘日志，删日志后浏览器收不到
   `session-removed` 事件，会在侧边栏残留成不可读取的 Ungrouped 条目。`archiveColdSessions` 在删日志
   **之前**把它们逐一归档（`workspaceRegistry.archiveSession`）：归档集变化会推送
   `host/archived-sessions-changed`，侧边栏（分组、扁平列表、搜索）都会过滤归档 id，残留条目立即消失。
   归档留下的 archived-id 记录由启动清扫回收。
1. **再删会话** —— 工作区所属的每个会话（其头行 `cwd` 等于工作区路径）通过
   `ctx.sessionPersistence.locate` 定位，并用 `fs.rm` 删除其磁盘目录。
2. **删注册** —— `ctx.workspaceRegistry.delete(id)` 删除工作区记录（本身不动日志/目录）。
3. **删目录** —— 删除临时目录（位于 `<dsh-home>/temp-workspaces/<uuid>`）。

插件用 `<dsh-home>/temp-workspaces/state.json` 记录临时工作区（持久化标记
`{ workspaceId, path, createdAt }`）。删除由该标记里的 `path` 驱动，所以重启前已通过
UI 删除注册记录的临时工作区**仍会清理残留目录**（修复了「残留文件夹」bug）——目录以
标记路径（而非注册记录）为准。

**零残留**：删除时还会清理会话的**投影缓存行**（`session_projcache` 域，位于
`~/.dsh/storages/session_projcache.json`）与旧布局遗留的 per-session 缓存文件。
启动时还有一次**孤儿残留清扫**（`pruneOrphanTempResidue`）：凡 `cwd` 位于临时根目录下、
且不再被标记引用的投影缓存行，连同其 archived-id 记录与遗留缓存文件一并删除。这些行
永远不会把幽灵对话显示出来（身份校验后读不到），但"不留痕迹"就是不留痕迹；用户对真实
会话的主动归档不会被触碰。

## 目录结构

```
temp-workspace-plugin/
  package.json       name, dsh.bundle.patch + dsh.client, exports
  cordis.patch.yml   把该插件插入为宿主 Loader 条目
  lib/index.js       节点半边 —— /temp-workspace/api 路由 + 设置 + 启动清理
  src/client.js      浏览器半边源码（图标 + 设置页面 + 确认弹窗）
  lib/client.js      构建后的浏览器半边（生成文件，勿改）
  build.mjs          把 src/client.js 包装 -> lib/client.js（HMR watch）
```

## 开发循环

```sh
node build.mjs --watch
```

`lib/client.js` 是 client-modules 提供、dsh-client-hmr 轮询的**唯一文件**；编辑
`src/client.js` 会热更新界面（无需刷新页面）。**宿主半边改动**（`lib/index.js`）需要重启
`dsh web`。

## 接入 profile

```sh
# 本地 checkout / clone（任意绝对或相对路径）
dsh plugin --profile web add /path/to/dsh-temp-workspace
```

这会把它加入 `dsh.profile.bundles`。下次启动 `dsh web` 时宿主组合该行、
注册 `/temp-workspace/api` 路由，客户端插件（含设置页面）进入 `__DSH_BOOT__`，并运行启动清理/确认流程。

## API（`/temp-workspace/api/<method>`）

读取用 `GET`，变更用 `POST`。

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `POST create` | `{ workspace, created: true }` | mkdir + `workspaceRegistry.create` + 标记 |
| `GET list` | `{ entries }` | 当前标记为临时的 `[{ workspaceId, path, createdAt }]` |
| `POST delete` | `{ ok: true }` | 立即删除一个临时工作区（会话 + 注册 + 目录） |
| `GET/POST config` | `{ ok, value }` | 读取设置，或 `POST {…}` 写入补丁 |
| `POST configReset` | `{ ok, value }` | 恢复所有设置为默认 |
| `GET pending` | `{ ok, value }` | 被挂起的工作区 + `confirmBeforeDelete` + `deleteAt` |
| `POST confirm` | `{ ok, deleted }` | 用户确认——删除被挂起的集合 |
| `POST keep` | `{ ok, kept }` | 临时保留——不动作（仍为临时） |
| `POST permanentKeep` | `{ ok, moved }` | `{ target, workspaceId? }` —— 把 `target`（所选文件夹）直接作为新工作区并去临时化；带 `workspaceId` 时只移动该工作区（省略则移动全部被挂起的） |
| `POST restart` | `{ restarting, pid, helperPid }` | 真正的宿主重启（仅限同源 loopback）。**界面已不再调用**——桌面端无法重启自己的宿主，永久保留后的对话框改为说明手动重启步骤 |

## 说明 / 局限

- 侧边栏工作区浏览器**没有**给头部动作用的 slot，所以图标是用 `MutationObserver`
  注入 DOM 的（与 `dsh-workspace-auto-approval` 同款写法）。若上游侧边栏布局改变，
  图标可能需要相应调整位置。
- 「添加工作区」按钮只在目录流槽位被占用时才渲染，因此临时工作区图标也只在此时出现。
- 清理时直接删除会话日志目录。**在线**会话会在删除时尽力拆除（取消 agent + 归档 + 从会话存储
  摘除），因此即使删除发生在仍持有这些会话的宿主进程里（确认弹窗、延迟删除、页面刷新），对话也会
  立即从界面消失；启动时没有在线会话，正是清理运行的时候。
- 临时工作区的目录位于 DSH home（`~/.dsh/temp-workspaces`），不在你的常规项目文件夹里。
- 浏览器插件没有通用的「询问用户」API，因此确认弹窗由插件自绘（轻量内联样式遮罩），
  通过自己的 `react-dom/client` root 渲染（不依赖 primitives 的 CSS-module 作用域，所以必定显示）。
- **永久保留**需要目录选择器：优先桌面端的 `__DSH_DIRECTORY_PICKER__` 桥，其次
  `uiWorkspace.pickDirectory()`（0.2.x），最后 `ctx.workspaces.pickDirectory()`（0.1.x）。
  这里**刻意不用** `ctx.remote.directoryPicker`——命名空间化的 remote 需要单独声明
  `inject`，否则 cordis 会抛 `cannot get property "remote.directoryPicker" without inject`。
  都不可用时插件会弹窗明确告知，而不是留下一个点了没反应的按钮。
- 对话迁移只改写每个会话日志的**头帧**（`.jsonl.zstd` 是一串独立压缩的 Zstandard 帧
  连接而成——帧 0 是带 `cwd` 的头行，其余是事件批次）。头帧的 `cwd` 被改写，每个事件帧
  逐字节保留，因此不会丢失任何对话内容。dsh 0.2.x 的日志文件名带格式代次
  （`session.v4.jsonl.zstd`），同目录下还留着更早的代次；迁移会带上该会话目录里的**每一个
  代次**，而帧结构无法解析的日志会被原样拷贝而不是重写。
- DSH 会话头中的 `cwd` 不可变，故采用改写方式。DSH 工作区注册表的「会话→工作区分组」
  （`bootstrap`）**只在第一次启动时运行一次**；之后的启动只会重建 id→cwd 索引，不会把
  迁移后的会话加进新工作区的 `sessionIds`。为避免迁移的对话最终变成**未分组**，插件在
  移动时记录一条持久化的 **pending-attach** 列表，并在下次启动时把这些迁移会话重新挂载到
  新工作区——这也是永久保留后客户端提示重启的原因。

## 不准备修复的Bug

- 受到Harness的限制，永久保留后对话有概率变成`Ungrouped`，已经尽力处理了，但是仍然有概率出问题
- 创建临时工作区后永久保留相关按钮不一定会立即出现，但是肯定会在一段时间后（2-3s后）出现的，不准备修复
- 受到Harness的限制，迁移过程完成后对话的名称有概率变成工作区的名称，点击后又变回原来的名称，不影响使用，不计划修复，自己改一下名应该就可以处理这个问题

## 界面语言

所有界面文案（图标按钮、行内“永久保留”按钮、设置页面、确认/重启弹窗）都提供**中文与英文**
两套字典，并跟随 DSH 的激活语言自动切换（`ctx.locale` + `locale.subscribe`），语言切换时
已注入的按钮文案也会刷新。
