Skip to content

dsh-temp-workspace

Verified

@yezack/dsh-temp-workspace · v0.2.4 · MIT · Web UI

DSH web client plugin: a temporary workspace (临时工作区) — every conversation it contains is deleted at the next Harness restart (immediately or after a delay), optionally behind a confirm dialog. Temp workspaces are named after their creation moment and grou

Install

dsh plugin add @yezack/dsh-temp-workspace

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Readme

@yezack/dsh-temp-workspace

一个 DSH web 客户端插件,提供 临时工作区(临时工作区) 功能。

点击空白新会话页「选择工作区」旁的 新建临时工作区 chip,即可创建一个一次性工作区(以创建时刻命名,如 2026-10-16 09:45)并打开一个全新的对话。它会出现在「工作区」列表里一个可收起的 临时工作区 分组中,因此不会和你的正式项目混在一起。下次 Harness 重启时,这个插件会删除该工作区下的全部对话以及工作区本身——临时工作区不留任何痕迹。

它是什么

  • 宿主半边(lib/index.js)—— 一个 Cordis 插件,注入 webServer / workspaceRegistry / sessionPersistence,注册受浏览器信任围墙保护的 /temp-workspace/api 路由,并在启动时驱动清理逻辑。dsh 0.1.x 与 0.2+ 都能跑:信任判定在 0.2+ 取自 connection.allowsRemoteAuthorities,旧版回落到只有 0.1.x 才有的 webRuntime.trustedHosts —— webRuntime 刻意不列为注入依赖,因为 dsh 0.2 移除了它, 而注入不满足会让整个插件不激活。
  • 浏览器半边(src/client.js → lib/client.js)—— 在原生「工作区」列表内渲染可收起的 临时工作区 分组(数据来自本插件自己的路由,以及 workspaces / uiWorkspace / sessions 三个客户端服务);在空白新会话页挂一个 新建临时工作区 chip; 注册一张 设置 → 插件 卡片;并在设置开启时于启动阶段弹确认对话框。装了 dsh-better-workspace 时不注入侧栏分组,改用它的 / 名称分组(见下文)。

「临时工作区」分组

工作区
  ▤ 临时工作区   (2)          ← 一个可收起的分组行,固定排在列表最前
      📁 2026-10-16 09:45      ← 一个临时工作区
          会话 A
          会话 B
      📁 2026-10-16 10:12
          会话 C
  📁 某个项目
  📁 未分组
  • 分组固定是列表的第一行,并且做了负缩进(padding-left: 0),因此看起来是下面那些工作区行的父级,而不是又一个工作区;它的子行仍用树的正常缩进(24px / 40px)。
  • 它前面不是工作区行那种文件夹图标,而是一个状态图标:闭合的收纳盒=已收拢,打开的收纳盒=已展开。一个图标同时承担"这是临时工作区分组"和"当前展开/收拢状态",不再另设旋转小三角。
  • 点击整行即可收起/展开分组;该状态会跨页面刷新记住(浏览器本地 localStorage)。
  • 悬停某个临时工作区行,会出现 永久保留、新建会话、删除 三个操作;分组行自己的 + 用于再建一个临时工作区。
  • 点击会话即可像原生行一样打开它。

与 dsh-better-workspace 共存

dsh-better-workspace 接管侧边栏的工作区树,并用工作区标题里的 / 生成虚拟分组(web/前端 归到 web 组)。检测到它(客户端启动清单里有 dsh-better-workspace,另以 .bw-tree 作兜底探测)时,本插件改用它的分组机制、不再往侧边栏注入自己的 UI:

  • 临时工作区标题改为 临时工作区/yyyyMMddHHmmss(如 临时工作区/20261021175513),better-workspace 会把它们自动收进同一个 临时工作区 虚拟分组;秒级时间戳本身就能区分同一天内的多次创建,同一秒内仍撞名时才会补 -2、-3…… 后缀。
  • 侧栏注入全部关闭:不再绘制本插件的「临时工作区」分组、不再隐藏原生临时行、也不再每 4 秒轮询列表。分组/重命名/删除/排序都用 better-workspace 自己的界面;删除仍会走本插件的清理钩子(会话 + 目录 + 标记一并清掉),重命名走 registry 的 rename(与 better-workspace 自己的改名同一通道)。
  • 空白新会话页的 新建临时工作区 chip、启动确认弹窗、设置卡片都不受影响。
  • 前提:better-workspace 的 视图选项 → 工作区分组 需设为 磁盘 + 名称 或 仅名称(/ 才会被当作分组);它默认的 磁盘目录 不解析 /,此时只会平铺显示 临时工作区/20261021175513。

新建会话

  • 点「新建会话」进入空白新会话页,不再自动绑定工作区。 原生行为是:侧边栏的「新建会话」按钮(以及 session.new 快捷键)调用不带参数的 uiWorkspace.startSession(),于是按"当前会话所在工作区 → 最近活跃工作区"自动绑定。本插件把这不带参数的调用改成清空主面板(即空白新会话页),让你在那里自己挑工作区。带明确 workspaceId 的调用仍保持原生行为(本插件自己的临时工作区行就依赖它)。该拦截以 属性访问器 的形式安装,而不是一次性赋值:其它插件稍后包装同一个 startSession 时(例如 @linxin666/dsh-client-ui-git-graph 的 auto-isolation 会先自行解析出工作区、再以显式 id 转发),setter 会捕获这次替换并把策略重新包在最外层,因此"不带参数"的语义不会再被旁路。同时第二个参数原样透传:startSession(void 0, { agentPreset }) 这类带草稿选项的调用保持原生含义。
  • 该页面控制行里多了一个 新建临时工作区 chip,就在原生 选择工作区 与 Agent 预设 chip 旁边(度量完全一致:高 28px、内边距 8px、圆角 8px、字号 13px/500)。点它即创建一个临时工作区并在其中打开对话 —— 与「临时工作区」分组行的 + 同一动作。
  • 一旦为这次新会话选中了工作区,该 chip 就消失(选择已经做完了)。状态直接取自原生 chip:它的 aria-label 恒为"选择工作区"这个行动号召,而可见文案在选中后变成工作区名 —— 所以不比较任何语言字符串。

安装方式

从 npm 安装(预构建,推荐)

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

dsh plugin --profile web add @yezack/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 层):

# 启动某个 profile,例如 web 应用
dsh plugin --profile web add github:yezack/dsh-temp-workspace

因为清单声明了 dsh.bundle.patch,add 会把该包自动追加到 dsh.profile.bundles,下次启动 dsh web 时宿主就把它组合为 Loader 条目、注册 /temp-workspace/api 路由、设置卡片进入 __DSH_BOOT__。

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

cd "$DSH_HOME/profiles/web"
pnpm add github:yezack/dsh-temp-workspace

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

dsh plugin --profile web add github:yezack/dsh-temp-workspace#<ref>

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

设置 → 插件

插件在 设置 → 插件 注册一张卡片(以设置命名空间 dsh-temp-workspace 为键, 持久化在 ~/.dsh/settings.yaml):

设置 取值 默认 含义
deleteMode immediate / delayed immediate 下次启动时何时删除临时工作区
deleteDelay 秒 3600 delayed 模式下删除前的宽限期
confirmBeforeDelete 开 / 关 开 删除前是否先询问用户
  • confirmBeforeDelete 开:宿主启动时绝不自动删除,而是挂起这些工作区, 浏览器弹出确认对话框,提供两个选项:
    • 删除 —— 清空被挂起的工作区(会话 + 注册 + 目录)。
    • 临时保留 —— 本次不动它们,但仍然是临时:它们还在标记里,会在之后的 启动时再次(按设置)被确认/清理。
    • 未作答时工作区原样保留到下次启动。

永久保留不在启动确认弹窗里——它有自己的按钮。侧边栏里每个临时工作区行 (标题「临时工作区」)在其「+」(New Session) 旁边有一个图钉按钮。点击后选取一个 目标文件夹(通过 ctx.uiWorkspace.pickDirectory() 的系统目录选择器)。临时工作区的文件会 直接移进该文件夹(不会额外创建「临时工作区」子文件夹),该文件夹本身成为永久工作区, 并用其文件夹名作为工作区标题。同时迁移对话:把每个会话日志头行的 cwd 改写为该 文件夹路径,并把日志目录移到新 cwd 的 projectKey 槽位。成功后弹出一个不可关闭的 对话框,带 「立即重启」 按钮——它触发真正的宿主级重启(一个脱离的辅助进程按原 DSH 启动命令重新拉起,机制与 dsh-market 更新插件时一致),让注册表重新索引、迁移的对话 出现在新工作区下。

  • confirmBeforeDelete 关:自动删除——启动时立即执行,或在 deleteMode = 'delayed' 时等待 deleteDelay 秒后执行。

清理是如何工作的

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

  1. 先拆在线会话 —— 如果宿主进程里还活着该工作区的会话(确认弹窗、延迟自动删除、或只刷新了页面而宿主并未真正重启时,删除都会在仍持有这些会话的宿主里运行),会尽力拆除它们: 先取消 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 记录由启动清扫回收。
  2. 再删会话 —— 工作区所属的每个会话(其头行 cwd 等于工作区路径)通过 ctx.sessionPersistence.locate 定位,并用 fs.rm 删除其磁盘目录。
  3. 删注册 —— ctx.workspaceRegistry.delete(id) 删除工作区记录(本身不动日志/目录)。
  4. 删目录 —— 删除临时目录(位于 <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      浏览器半边源码(临时工作区分组/chip + 设置卡片 + 确认弹窗)
  lib/client.js      构建后的浏览器半边(生成文件,勿改)
  build.mjs          把 src/client.js 包装 -> lib/client.js(HMR watch)

开发循环

node build.mjs --watch

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

接入 profile

# 本地 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,空白新会话页的控制行又是三个 single slot 的组合,所以 临时工作区 分组与 新建临时工作区 chip 都是用 MutationObserver 注入 DOM 的(与 dsh-workspace-auto-approval 同款写法)。占用其中任何一个 slot 都会把系统自带 UI 顶掉。临时工作区的原生行用 display:none 隐藏 ——从不移动,本插件自己的子树也只会被追加为行尾子节点(React 不会引用它), 因此原生树的协调过程完全不受影响。若上游侧边栏或首屏布局改变,这些位置可能需要相应调整。
  • 原生工作区搜索仍可能搜到临时工作区的对话;被重组的只是列表行。
  • 清理时直接删除会话日志目录。在线会话会在删除时尽力拆除(取消 agent + 归档 + 从会话存储 摘除),因此即使删除发生在仍持有这些会话的宿主进程里(确认弹窗、延迟删除、页面刷新),对话也会 立即从界面消失;启动时没有在线会话,正是清理运行的时候。
  • 临时工作区的目录位于 DSH home(~/.dsh/temp-workspaces),不在你的常规项目文件夹里。
  • 浏览器插件没有通用的「询问用户」API,因此确认弹窗由插件自绘(轻量内联样式遮罩), 通过自己的 react-dom/client root 渲染(不依赖 primitives 的 CSS-module 作用域,所以必定显示)。
  • 永久保留需要 native 目录选择器能力(ctx.uiWorkspace.pickDirectory()); 在远程/非 native 部署下该选择器不可用,按钮会被禁用。
  • 对话迁移只改写每个会话日志的头帧(.jsonl.zstd 是一串独立压缩的 Zstandard 帧 连接而成——帧 0 是带 cwd 的头行,其余是事件批次)。头帧的 cwd 被改写,每个事件帧 逐字节保留,因此不会丢失任何对话内容。
  • DSH 会话头中的 cwd 不可变,故采用改写方式。DSH 工作区注册表的「会话→工作区分组」 (bootstrap)只在第一次启动时运行一次;之后的启动只会重建 id→cwd 索引,不会把 迁移后的会话加进新工作区的 sessionIds。为避免迁移的对话最终变成未分组,插件在 移动时记录一条持久化的 pending-attach 列表,并在下次启动时把这些迁移会话重新挂载到 新工作区——这也是永久保留后客户端提示重启的原因。

不准备修复的Bug

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

界面语言

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