dsh-scratch-workspace 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/cordis.patch.yml +4 -0
- package/docs/DETAIL.zh.md +29 -0
- package/index.js +87 -0
- package/lib/client.js +256 -0
- package/lib/command.js +110 -0
- package/lib/gc.js +221 -0
- package/lib/isolate.js +164 -0
- package/lib/patch.js +660 -0
- package/lib/state.js +127 -0
- package/lib/tool.js +118 -0
- package/lib/web.js +116 -0
- package/locale/en.json +9 -0
- package/locale/zh-CN.json +9 -0
- package/locale/zh.json +9 -0
- package/package.json +57 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek Harness Community
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">📦 dsh-scratch-workspace</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
DeepSeek Harness (DSH) 会话级沙箱隔离与草稿常态洁净插件
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img alt="DSH Plugin" src="https://img.shields.io/badge/DSH-Plugin-4c6ef5?style=flat-square" />
|
|
9
|
+
<img alt="Node.js 22+" src="https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=node.js&logoColor=white" />
|
|
10
|
+
<img alt="Cordis" src="https://img.shields.io/badge/Cordis-Ready-blueviolet?style=flat-square" />
|
|
11
|
+
<a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/License-MIT-yellow?style=flat-square" /></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<strong>简体中文</strong> · <a href="README_EN.md">English</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
> [!IMPORTANT]
|
|
19
|
+
> **本插件顺应 DSH 原生架构规范,以永久兼容设计解决即兴会话文件踩踏、侧边栏混乱与磁盘残留痛点。**
|
|
20
|
+
>
|
|
21
|
+
> 既保持会话与项目在侧边栏的优雅归集,又为每个即兴会话提供独立的写入沙箱与自动垃圾回收(GC),杜绝版本断层风险。
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 为什么需要它(面向什么问题)
|
|
26
|
+
|
|
27
|
+
在 DeepSeek Harness 日常开发中,同时运行多个会话或使用默认工作区即兴提问时,开发者普遍面临以下三大核心痛点:
|
|
28
|
+
|
|
29
|
+
1. **文件相互踩踏**:原生机制下所有会话共享同一个工作区目录,并行运行的 Agent 会互相覆盖对方产出的代码或临时脚本。
|
|
30
|
+
2. **版本断层红屏**:传统补丁方案(如 `dsh-session-isolation`)强依赖旧版内部私有函数 `sessionPath`,在 DSH 升级到 `0.2.x` 后直接抛错崩溃。
|
|
31
|
+
3. **磁盘垃圾持续蔓延**:即兴会话产生的废弃文件与临时产物长期滞留,缺乏生命周期回收机制。
|
|
32
|
+
|
|
33
|
+
### 方案对比
|
|
34
|
+
|
|
35
|
+
| 评估维度 | 原生 DSH 默认机制 | 传统猴子补丁 (`dsh-session-isolation`) | dsh-scratch-workspace (本插件) |
|
|
36
|
+
| :--- | :--- | :--- | :--- |
|
|
37
|
+
| **会话写隔离** | 共享工作区根目录,易互相踩踏 | 独立 `session-<id>` 子沙箱 | **独立 `session-<id>` 子沙箱,项目只读安全** |
|
|
38
|
+
| **DSH 版本兼容** | 官方原生 | 强依赖 `sessionPath`,0.2.x 升级即红屏 | **永久兼容:自适应 `candidateMatches` 与 `sessionPath`,防认领冲突** |
|
|
39
|
+
| **侧边栏整洁** | 堆叠在单一工作区 | 易因校验失效退化为 Ungrouped 孤儿项 | **常态整洁:会话严格归集于母工作区树下** |
|
|
40
|
+
| **磁盘生命周期** | 无清理机制,废弃文件永久堆积 | 无有效 GC,废弃目录长期滞留 | **双重 GC:销毁即时释放 + 孤儿与空闲自动回收** |
|
|
41
|
+
| **即兴提问体验** | 污染 `default-workspace` | 需手动放置 `.dsh-isolate` 标记 | **默认工作区自动隔离,即用即走** |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 用什么方法(架构与技术原理)
|
|
46
|
+
|
|
47
|
+
```mermaid
|
|
48
|
+
flowchart TD
|
|
49
|
+
User["用户在工作区新建会话"] --> Controller["sessionController.create()"]
|
|
50
|
+
Controller --> Check{"检测是否为隔离工作区或默认工作区"}
|
|
51
|
+
Check -- 否 --> Native["原生创建流程"]
|
|
52
|
+
Check -- 是 --> Alloc["分配专属 session-<id> 沙箱子目录"]
|
|
53
|
+
Alloc --> SafeClaim["安全消除 blank 会话 cwd 认领冲突"]
|
|
54
|
+
SafeClaim --> TempRedirect["微秒级重定向 workspace.path & record"]
|
|
55
|
+
TempRedirect --> Attach["session 与 workspace 建立原生归集"]
|
|
56
|
+
Attach --> CompatIndex["兼容层索引拦截 (candidateMatches / sessionPath)"]
|
|
57
|
+
CompatIndex --> Ready["会话就绪:文件写入局限于子沙箱,侧边栏归属母工程"]
|
|
58
|
+
Ready --> GC["常态 GC 守护:空闲回收 + 会话销毁即时释放"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 核心方法与实现机制
|
|
62
|
+
|
|
63
|
+
1. **物理级写沙箱与项目只读保护**:
|
|
64
|
+
在隔离工作区中为新会话动态创建 `session-<shortId>` 子目录,并重定向会话的写入边界。所有文件写入均收敛于该子目录,项目既有代码和文件对会话只读可用。
|
|
65
|
+
2. **双轨版本自适应(永久兼容)**:
|
|
66
|
+
- 适配 DSH `0.2.x+`:包裹 `candidateMatches(id, path)`,将沙箱路径映射至母工作区。
|
|
67
|
+
- 兼容 DSH `0.1.x`:保留对 `sessionPath(id)` 与 `rememberSessionPath(id, path)` 的安全适配。
|
|
68
|
+
- 彻底防范 `ApiSessionCwdConflict`:当客户端复用带有历史根路径的空白会话时,自动解除路径认领绑定,平滑分配新会话标识。
|
|
69
|
+
3. **磁盘常态化垃圾回收引擎 (Lifecycle GC)**:
|
|
70
|
+
- **实时释放**:监听会话删除事件,实时删除对应的沙箱目录。
|
|
71
|
+
- **空闲回收**:对创建后超过 5 分钟且无实际文件产出的空沙箱进行静默清理。
|
|
72
|
+
- **TTL 孤儿扫描**:后台每 30 分钟轮巡检测已无活动会话对应且超过 24 小时的孤儿目录。
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 达到了什么效果
|
|
77
|
+
|
|
78
|
+
- 🚀 **并行开发零干扰**:多个会话同时对同一项目执行重构、分析或测试,产物互不交叉。
|
|
79
|
+
- 🛡️ **无感平滑升级**:不受 DSH 小版本架构调整影响,插件长久稳定运行。
|
|
80
|
+
- 📂 **侧边栏清爽如初**:所有会话紧密附着于所属工作区,消除孤儿会话打扰。
|
|
81
|
+
- 🧹 **磁盘常态零负担**:废弃临时目录自动清理,保持本地环境轻盈纯净。
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 安装与使用
|
|
86
|
+
|
|
87
|
+
### 方式一:从本地安装(开发调试)
|
|
88
|
+
|
|
89
|
+
在 DeepSeek Harness 所在配置 Profile 中添加本插件依赖(例如 `~/.dsh/profiles/desktop`):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# 1. 链接到你的 DSH Profile
|
|
93
|
+
cd ~/.dsh/profiles/desktop
|
|
94
|
+
pnpm add "link:/Users/midaxia/Documents/Projects/Codex Projects/dsh-plugins/dsh-scratch-workspace"
|
|
95
|
+
|
|
96
|
+
# 2. 将 "dsh-scratch-workspace" 添加到 package.json 中的 dsh.profile.bundles 列表
|
|
97
|
+
|
|
98
|
+
# 3. 重启 DeepSeek Harness
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 方式二:通过 Git URL 安装
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
dsh plugin --profile desktop add https://github.com/MDX-Tom/dsh-scratch-workspace.git
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 开启与控制隔离
|
|
110
|
+
|
|
111
|
+
隔离状态以工作区目录内的 `.dsh-isolate` 标记文件作为持久化依据,设置随目录移动,也可在以下入口灵活控制:
|
|
112
|
+
|
|
113
|
+
1. **对话内 Slash 命令**:
|
|
114
|
+
在聊天输入框键入 `/scratch` 或 `/isolate` 即可调出交互命令,支持:
|
|
115
|
+
- `/scratch status`:查看当前工作区隔离状态与草稿目录信息
|
|
116
|
+
- `/scratch enable`:为当前工作区开启沙箱隔离(写入 `.dsh-isolate`)
|
|
117
|
+
- `/scratch disable`:关闭隔离
|
|
118
|
+
- `/scratch gc`:立即执行磁盘垃圾清理,回收空闲与孤儿会话目录
|
|
119
|
+
2. **默认工作区自动开启**:
|
|
120
|
+
默认工作区 (`default-workspace`) 自动享受沙箱隔离与草稿清理效果,无需任何手动配置。
|
|
121
|
+
3. **Agent 智能调用 (LLM Tool)**:
|
|
122
|
+
模型可自动调用 `session_isolation` 工具查看或切换隔离状态:
|
|
123
|
+
- `action: "enable"`:为当前工作区开启隔离
|
|
124
|
+
- `action: "disable"`:关闭隔离
|
|
125
|
+
- `action: "status"`:查看当前目录隔离状态
|
|
126
|
+
- `action: "gc"`:立即执行磁盘垃圾清理
|
|
127
|
+
4. **插件详情与配置说明**:
|
|
128
|
+
在 DSH 侧边栏点击 **「插件」** → 选择 **dsh-scratch-workspace** 即可查看插件行为与自动清理机制摘要。
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 运行自动化测试
|
|
133
|
+
|
|
134
|
+
本插件内置轻量单元测试,完全基于 Node.js 原生 test runner:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
cd "dsh-scratch-workspace"
|
|
138
|
+
node --test "tests/*.test.js"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
### 插件行为与核心机制
|
|
2
|
+
|
|
3
|
+
**dsh-scratch-workspace** 为 DeepSeek Harness (DSH) 提供会话级沙箱隔离与草稿常态洁净能力:
|
|
4
|
+
|
|
5
|
+
- **子沙箱写隔离**:在已开启隔离的工作区(或默认工作区)中,每个新会话均在专属的 `session-<id>` 子目录中工作。该子目录自动作为会话的文件写入边界,杜绝并行任务相互覆盖代码或测试文件;母工作区文件对所有会话只读可用。
|
|
6
|
+
- **侧边栏常态归集**:会话在侧边栏中始终整洁归属于母工作区,不产生未分组孤儿项。
|
|
7
|
+
- **永久兼容架构**:无缝适配 DSH `0.1.x` 与 `0.2.x+` 的实体宿主索引机制(同时支持 `candidateMatches` 与 `sessionPath`),并平滑处理空白会话认领冲突,杜绝红屏崩溃。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
### 磁盘自动清理机制 (Lifecycle GC)
|
|
12
|
+
|
|
13
|
+
插件内置常态化垃圾回收引擎,杜绝磁盘废弃文件堆积:
|
|
14
|
+
|
|
15
|
+
1. **会话销毁即时清理**:会话归档或删除时,即时释放其绑定的沙箱子目录。
|
|
16
|
+
2. **空闲废弃目录回收**:自动识别超过 5 分钟且无实际产物的空会话目录并静默清理。
|
|
17
|
+
3. **TTL 孤儿目录扫描**:启动与运行中(每 30 分钟)自动轮巡工作区,安全回收已不存在对应会话且超过 24 小时的孤儿目录。
|
|
18
|
+
4. **一键手动触发**:可在对话框中键入 `/scratch gc` 或通过 `session_isolation` 工具随时手动触发即时清理。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### 快捷指令支持 (Slash Commands)
|
|
23
|
+
|
|
24
|
+
在任意会话输入框键入 `/` 即可调出本插件命令:
|
|
25
|
+
- `/scratch status` (或 `/isolate`):查看当前工作区隔离状态与沙箱目录。
|
|
26
|
+
- `/scratch enable`:为当前工作区启用隔离(创建 `.dsh-isolate`)。
|
|
27
|
+
- `/scratch disable`:关闭当前工作区隔离。
|
|
28
|
+
- `/scratch gc`:立即回收空闲与孤儿草稿目录。
|
|
29
|
+
|
package/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-scratch-workspace — Ephemeral Scratch Workspace & Session Isolation for DeepSeek Harness (DSH).
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* 1. Per-session sandbox working directory (session-<id>) within parent workspaces.
|
|
6
|
+
* 2. Permanent multi-version compatibility across DSH 0.1.x and 0.2.x+.
|
|
7
|
+
* 3. Sidebar cleanliness with automatic session grouping under parent workspaces.
|
|
8
|
+
* 4. Disk cleanliness with automatic lifecycle GC for empty and orphan directories.
|
|
9
|
+
* @module dsh-scratch-workspace
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createIsolationState } from './lib/state.js'
|
|
13
|
+
import {
|
|
14
|
+
isolatedSessionsApi,
|
|
15
|
+
patchFs,
|
|
16
|
+
patchRegistry,
|
|
17
|
+
patchSessionCreate,
|
|
18
|
+
patchWorkingDirectory,
|
|
19
|
+
renameSessionDirToTitle,
|
|
20
|
+
} from './lib/patch.js'
|
|
21
|
+
import { isMeaningfulTitle } from './lib/isolate.js'
|
|
22
|
+
import { setupAutoGc } from './lib/gc.js'
|
|
23
|
+
import { registerIsolationTool } from './lib/tool.js'
|
|
24
|
+
import { registerCommands } from './lib/command.js'
|
|
25
|
+
import { registerWebRoutes } from './lib/web.js'
|
|
26
|
+
|
|
27
|
+
export const inject = ['sessionController', 'workspaceRegistry', 'tools', 'commands', 'sessions', 'workingDirectory', 'fs']
|
|
28
|
+
|
|
29
|
+
async function apply(ctx) {
|
|
30
|
+
const iso = createIsolationState(ctx)
|
|
31
|
+
|
|
32
|
+
const unpatchRegistry = patchRegistry(ctx.root?.workspaceRegistry ?? ctx.workspaceRegistry, iso)
|
|
33
|
+
const unpatchCreate = patchSessionCreate(ctx, iso)
|
|
34
|
+
const unpatchWorkingDirectory = patchWorkingDirectory(ctx, iso)
|
|
35
|
+
const unpatchFs = patchFs(ctx, iso)
|
|
36
|
+
|
|
37
|
+
const api = isolatedSessionsApi(ctx, iso)
|
|
38
|
+
const provideIsolation = ctx.provide('isolatedSessions', api)
|
|
39
|
+
const provideScratch = ctx.provide('scratchWorkspace', api)
|
|
40
|
+
|
|
41
|
+
const unregisterTool = registerIsolationTool(ctx, iso)
|
|
42
|
+
const unregisterCommands = registerCommands(ctx, iso, api)
|
|
43
|
+
const stopGc = setupAutoGc(ctx, iso)
|
|
44
|
+
|
|
45
|
+
await iso.scan()
|
|
46
|
+
const stopWatching = ctx.on('domain/changed', (change) => {
|
|
47
|
+
if (change?.domain !== 'workspace') return
|
|
48
|
+
void iso.scan().catch(() => {})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const stopTitleEvents = ctx.on('session/event', async (session, event) => {
|
|
52
|
+
if (event?.type !== 'session/title') return
|
|
53
|
+
const title = event?.data?.title
|
|
54
|
+
if (!isMeaningfulTitle(title)) return
|
|
55
|
+
const sessionId = session?.id
|
|
56
|
+
if (!sessionId) return
|
|
57
|
+
const reg = ctx.root?.workspaceRegistry ?? ctx.workspaceRegistry
|
|
58
|
+
await renameSessionDirToTitle(sessionId, title, iso, reg, ctx, session)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const webServer = ctx.get('webServer', false)
|
|
62
|
+
const offRoutes = webServer === undefined ? undefined : registerWebRoutes(ctx, webServer, iso)
|
|
63
|
+
|
|
64
|
+
ctx.logger?.info?.(
|
|
65
|
+
`dsh-scratch-workspace ready: ${iso.roots().length} isolating director${iso.roots().length === 1 ? 'y' : 'ies'}`,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ctx.effect(() => () => {
|
|
69
|
+
offRoutes?.()
|
|
70
|
+
stopTitleEvents?.()
|
|
71
|
+
stopWatching()
|
|
72
|
+
stopGc()
|
|
73
|
+
unregisterCommands()
|
|
74
|
+
unregisterTool()
|
|
75
|
+
provideScratch()
|
|
76
|
+
provideIsolation()
|
|
77
|
+
unpatchFs()
|
|
78
|
+
unpatchWorkingDirectory()
|
|
79
|
+
unpatchCreate()
|
|
80
|
+
unpatchRegistry()
|
|
81
|
+
iso.clear()
|
|
82
|
+
}, 'dsh-scratch-workspace.patches')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
apply.inject = inject
|
|
86
|
+
|
|
87
|
+
export default apply
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser UI for DSH Settings → Plugins: Workspace Isolation & Disk GC.
|
|
3
|
+
*/
|
|
4
|
+
window.__ModuleLoader__.load({
|
|
5
|
+
id: 'dsh-scratch-workspace',
|
|
6
|
+
factory: (require) => {
|
|
7
|
+
const module = { exports: {} }
|
|
8
|
+
const exports = module.exports
|
|
9
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
10
|
+
|
|
11
|
+
const React = require('react')
|
|
12
|
+
const { useEffect, useState } = React
|
|
13
|
+
|
|
14
|
+
const LIST_ROUTE = '/scratch-workspace/workspaces'
|
|
15
|
+
const TOGGLE_ROUTE = '/scratch-workspace/toggle'
|
|
16
|
+
const GC_ROUTE = '/scratch-workspace/gc'
|
|
17
|
+
|
|
18
|
+
const TEXT = {
|
|
19
|
+
tab: '工作区隔离与清理',
|
|
20
|
+
title: '工作区隔离与磁盘清理',
|
|
21
|
+
intro: '开启隔离后,每个新会话均在独立的 session-<id> 子沙箱中运行,隔离写权限,避免并行会话相互覆盖;'
|
|
22
|
+
+ '项目文件对所有会话完全只读可用。插件内置常态 GC 引擎,自动回收空闲与孤儿会话目录,保持磁盘整洁。',
|
|
23
|
+
empty: '当前没有已注册的工作区。请先在侧边栏打开项目目录。',
|
|
24
|
+
loading: '读取中…',
|
|
25
|
+
retry: '重试',
|
|
26
|
+
on: '已开启',
|
|
27
|
+
off: '未开启',
|
|
28
|
+
enable: '开启隔离',
|
|
29
|
+
disable: '关闭隔离',
|
|
30
|
+
sessions: '会话',
|
|
31
|
+
isolatedCount: (count) => `已开启隔离的工作区:${count}`,
|
|
32
|
+
runGc: '立即清理磁盘孤儿目录',
|
|
33
|
+
gcRunning: '正在清理…',
|
|
34
|
+
gcDone: (count) => `清理完成:已回收 ${count} 个孤儿/废弃目录`,
|
|
35
|
+
failed: (message) => `操作失败:${message}`,
|
|
36
|
+
unavailable: '宿主未响应接口,请确认插件已加载。',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fetchState() {
|
|
40
|
+
const response = await fetch(LIST_ROUTE, { headers: { accept: 'application/json' } })
|
|
41
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
|
42
|
+
return await response.json()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function toggle(workspaceId, enabled) {
|
|
46
|
+
const response = await fetch(TOGGLE_ROUTE, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
49
|
+
body: JSON.stringify({ workspaceId, enabled }),
|
|
50
|
+
})
|
|
51
|
+
const payload = await response.json().catch(() => ({}))
|
|
52
|
+
if (!response.ok) throw new Error(payload?.error ?? `${response.status} ${response.statusText}`)
|
|
53
|
+
return payload
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function triggerGc() {
|
|
57
|
+
const response = await fetch(GC_ROUTE, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
60
|
+
})
|
|
61
|
+
const payload = await response.json().catch(() => ({}))
|
|
62
|
+
if (!response.ok) throw new Error(payload?.error ?? `${response.status} ${response.statusText}`)
|
|
63
|
+
return payload
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const styles = {
|
|
67
|
+
root: { padding: '4px 0 24px', color: 'var(--dsw-alias-label-primary)', fontSize: 13, lineHeight: 1.6 },
|
|
68
|
+
title: { margin: '0 0 6px', fontSize: 14, fontWeight: 600 },
|
|
69
|
+
intro: { margin: '0 0 16px', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 },
|
|
70
|
+
toolbar: {
|
|
71
|
+
display: 'flex',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
justifyContent: 'space-between',
|
|
74
|
+
margin: '0 0 12px',
|
|
75
|
+
padding: '8px 12px',
|
|
76
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
},
|
|
79
|
+
summary: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 },
|
|
80
|
+
row: {
|
|
81
|
+
display: 'flex',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
gap: 12,
|
|
84
|
+
padding: '10px 0',
|
|
85
|
+
borderTop: '0.5px solid var(--dsw-alias-border-l2)',
|
|
86
|
+
},
|
|
87
|
+
main: { minWidth: 0, flex: 1 },
|
|
88
|
+
name: { fontWeight: 500 },
|
|
89
|
+
path: {
|
|
90
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
91
|
+
fontSize: 12,
|
|
92
|
+
overflow: 'hidden',
|
|
93
|
+
textOverflow: 'ellipsis',
|
|
94
|
+
whiteSpace: 'nowrap',
|
|
95
|
+
},
|
|
96
|
+
state: { flex: 'none', color: 'var(--dsw-alias-label-secondary)', fontSize: 12, minWidth: 48, textAlign: 'right' },
|
|
97
|
+
button: {
|
|
98
|
+
flex: 'none',
|
|
99
|
+
font: 'inherit',
|
|
100
|
+
fontSize: 12,
|
|
101
|
+
cursor: 'pointer',
|
|
102
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
103
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
104
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
105
|
+
borderRadius: 8,
|
|
106
|
+
padding: '5px 12px',
|
|
107
|
+
},
|
|
108
|
+
gcButton: {
|
|
109
|
+
font: 'inherit',
|
|
110
|
+
fontSize: 12,
|
|
111
|
+
cursor: 'pointer',
|
|
112
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
113
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
114
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
115
|
+
borderRadius: 6,
|
|
116
|
+
padding: '4px 10px',
|
|
117
|
+
},
|
|
118
|
+
buttonBusy: { opacity: 0.6, cursor: 'default' },
|
|
119
|
+
notice: { color: 'var(--dsw-alias-state-success-primary, #10b981)', fontSize: 12, margin: '8px 0 0' },
|
|
120
|
+
error: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, margin: '8px 0 0' },
|
|
121
|
+
empty: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 },
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ScratchWorkspacePage() {
|
|
125
|
+
const [state, setState] = useState(undefined)
|
|
126
|
+
const [error, setError] = useState(undefined)
|
|
127
|
+
const [notice, setNotice] = useState(undefined)
|
|
128
|
+
const [busyId, setBusyId] = useState(undefined)
|
|
129
|
+
const [gcBusy, setGcBusy] = useState(false)
|
|
130
|
+
|
|
131
|
+
const load = () => {
|
|
132
|
+
fetchState().then(
|
|
133
|
+
(next) => { setState(next); setError(undefined) },
|
|
134
|
+
(cause) => { setError(cause?.message ?? String(cause)) },
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
load()
|
|
140
|
+
const timer = setInterval(load, 5000)
|
|
141
|
+
return () => clearInterval(timer)
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
const apply = async (workspaceId, enabled) => {
|
|
145
|
+
setBusyId(workspaceId)
|
|
146
|
+
try {
|
|
147
|
+
setState(await toggle(workspaceId, enabled))
|
|
148
|
+
setError(undefined)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
setError(cause?.message ?? String(cause))
|
|
151
|
+
load()
|
|
152
|
+
} finally {
|
|
153
|
+
setBusyId(undefined)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const handleGc = async () => {
|
|
158
|
+
setGcBusy(true)
|
|
159
|
+
setNotice(undefined)
|
|
160
|
+
try {
|
|
161
|
+
const res = await triggerGc()
|
|
162
|
+
setNotice(TEXT.gcDone(res.cleanedCount ?? 0))
|
|
163
|
+
setError(undefined)
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
setError(cause?.message ?? String(cause))
|
|
166
|
+
} finally {
|
|
167
|
+
setGcBusy(false)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const workspaces = state?.workspaces ?? []
|
|
172
|
+
|
|
173
|
+
return React.createElement(
|
|
174
|
+
'div',
|
|
175
|
+
{ style: styles.root },
|
|
176
|
+
React.createElement('h3', { style: styles.title }, TEXT.title),
|
|
177
|
+
React.createElement('p', { style: styles.intro }, TEXT.intro),
|
|
178
|
+
React.createElement(
|
|
179
|
+
'div',
|
|
180
|
+
{ style: styles.toolbar },
|
|
181
|
+
React.createElement(
|
|
182
|
+
'span',
|
|
183
|
+
{ style: styles.summary },
|
|
184
|
+
state ? TEXT.isolatedCount(state.isolatedCount ?? 0) : TEXT.loading,
|
|
185
|
+
),
|
|
186
|
+
React.createElement(
|
|
187
|
+
'button',
|
|
188
|
+
{
|
|
189
|
+
type: 'button',
|
|
190
|
+
style: gcBusy ? { ...styles.gcButton, ...styles.buttonBusy } : styles.gcButton,
|
|
191
|
+
disabled: gcBusy,
|
|
192
|
+
onClick: handleGc,
|
|
193
|
+
},
|
|
194
|
+
gcBusy ? TEXT.gcRunning : TEXT.runGc,
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
notice ? React.createElement('p', { style: styles.notice }, notice) : null,
|
|
198
|
+
error ? React.createElement(
|
|
199
|
+
'p',
|
|
200
|
+
{ style: styles.error },
|
|
201
|
+
TEXT.failed(error),
|
|
202
|
+
React.createElement('button', {
|
|
203
|
+
type: 'button',
|
|
204
|
+
style: { ...styles.button, marginLeft: 8 },
|
|
205
|
+
onClick: load,
|
|
206
|
+
}, TEXT.retry),
|
|
207
|
+
) : null,
|
|
208
|
+
state !== undefined && workspaces.length === 0
|
|
209
|
+
? React.createElement('p', { style: styles.empty }, TEXT.empty)
|
|
210
|
+
: null,
|
|
211
|
+
workspaces.map((workspace) => React.createElement(
|
|
212
|
+
'div',
|
|
213
|
+
{ key: workspace.workspaceId, style: styles.row },
|
|
214
|
+
React.createElement(
|
|
215
|
+
'div',
|
|
216
|
+
{ style: styles.main },
|
|
217
|
+
React.createElement('div', { style: styles.name }, workspace.title),
|
|
218
|
+
React.createElement('div', { style: styles.path, title: workspace.path }, workspace.path),
|
|
219
|
+
),
|
|
220
|
+
React.createElement(
|
|
221
|
+
'span',
|
|
222
|
+
{ style: styles.state },
|
|
223
|
+
`${TEXT.sessions} ${workspace.sessionCount} · ${workspace.isolates ? TEXT.on : TEXT.off}`,
|
|
224
|
+
),
|
|
225
|
+
React.createElement(
|
|
226
|
+
'button',
|
|
227
|
+
{
|
|
228
|
+
type: 'button',
|
|
229
|
+
style: busyId === workspace.workspaceId
|
|
230
|
+
? { ...styles.button, ...styles.buttonBusy }
|
|
231
|
+
: styles.button,
|
|
232
|
+
disabled: busyId === workspace.workspaceId,
|
|
233
|
+
onClick: () => { void apply(workspace.workspaceId, !workspace.isolates) },
|
|
234
|
+
},
|
|
235
|
+
workspace.isolates ? TEXT.disable : TEXT.enable,
|
|
236
|
+
),
|
|
237
|
+
)),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const inject = ['slots']
|
|
242
|
+
|
|
243
|
+
function apply(ctx) {
|
|
244
|
+
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
245
|
+
name: 'settings.plugins.tab',
|
|
246
|
+
id: 'scratch-workspace',
|
|
247
|
+
order: 90,
|
|
248
|
+
label: TEXT.tab,
|
|
249
|
+
}, ScratchWorkspacePage))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
exports.apply = apply
|
|
253
|
+
exports.inject = inject
|
|
254
|
+
return module.exports
|
|
255
|
+
},
|
|
256
|
+
})
|
package/lib/command.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash commands for DeepSeek Harness (DSH) chat composer:
|
|
3
|
+
* - /scratch [status|enable|disable|gc]
|
|
4
|
+
* - /isolate [status|enable|disable|gc]
|
|
5
|
+
* @module dsh-scratch-workspace/lib/command
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register human-facing slash commands for the chat input box.
|
|
10
|
+
* @param ctx - Cordis context.
|
|
11
|
+
* @param iso - shared isolation state.
|
|
12
|
+
* @param api - public isolation API.
|
|
13
|
+
* @returns disposer removing command registrations.
|
|
14
|
+
*/
|
|
15
|
+
export function registerCommands(ctx, iso, api) {
|
|
16
|
+
const commands = ctx.get('commands', false)
|
|
17
|
+
if (!commands || typeof commands.register !== 'function') {
|
|
18
|
+
return () => {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const handler = async (invocation) => {
|
|
22
|
+
const rawInput = invocation.rawInput?.trim() ?? ''
|
|
23
|
+
const args = rawInput.split(/\s+/).filter(Boolean)
|
|
24
|
+
const action = args[0]?.toLowerCase() ?? 'status'
|
|
25
|
+
|
|
26
|
+
const session = invocation.agent?.session
|
|
27
|
+
const sessionCwd = session?.header?.cwd
|
|
28
|
+
const sessionId = session?.id
|
|
29
|
+
|
|
30
|
+
const parentRoot = sessionCwd
|
|
31
|
+
? (iso.ownerOf(sessionCwd) ?? sessionCwd)
|
|
32
|
+
: (iso.roots()[0] ?? iso.defaultPath?.())
|
|
33
|
+
|
|
34
|
+
if (action === 'status') {
|
|
35
|
+
const isIso = parentRoot ? (iso.workspaceRoot(parentRoot) !== undefined || iso.roots().includes(parentRoot)) : false
|
|
36
|
+
const sessionPath = sessionId ? (iso.sessionPath(sessionId) ?? sessionCwd) : sessionCwd
|
|
37
|
+
const lines = [
|
|
38
|
+
`📁 **工作区隔离状态**`,
|
|
39
|
+
`- **母工作区**: \`${parentRoot ?? '未知'}\``,
|
|
40
|
+
`- **隔离状态**: ${isIso ? '✅ 已开启' : '⚪ 未开启'}`,
|
|
41
|
+
`- **当前会话沙箱**: \`${sessionPath ?? '继承工作区'}\``,
|
|
42
|
+
`- **已隔离工作区数**: ${iso.roots().length}`,
|
|
43
|
+
``,
|
|
44
|
+
`💡 **可用操作**:`,
|
|
45
|
+
`- \`/scratch enable\`:开启当前工作区隔离`,
|
|
46
|
+
`- \`/scratch disable\`:关闭当前工作区隔离`,
|
|
47
|
+
`- \`/scratch gc\`:立即清理磁盘废弃与孤儿草稿目录`,
|
|
48
|
+
]
|
|
49
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'enable') {
|
|
53
|
+
if (!parentRoot) {
|
|
54
|
+
return { kind: 'error', text: '无法识别当前会话所属工作区,请在工作区内执行。' }
|
|
55
|
+
}
|
|
56
|
+
await api.setIsolatedPath(parentRoot, true)
|
|
57
|
+
return {
|
|
58
|
+
kind: 'success',
|
|
59
|
+
text: `✅ 已为工作区开启会话隔离:\`${parentRoot}\`\n后续新建会话将自动运行于专属 \`session-<id>\` 沙箱目录中。`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (action === 'disable') {
|
|
64
|
+
if (!parentRoot) {
|
|
65
|
+
return { kind: 'error', text: '无法识别当前会话所属工作区,请在工作区内执行。' }
|
|
66
|
+
}
|
|
67
|
+
await api.setIsolatedPath(parentRoot, false)
|
|
68
|
+
return {
|
|
69
|
+
kind: 'success',
|
|
70
|
+
text: `⚪ 已关闭工作区会话隔离:\`${parentRoot}\`。`,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (action === 'gc') {
|
|
75
|
+
const result = await api.cleanOrphans()
|
|
76
|
+
const cleanedCount = result?.cleanedCount ?? (Array.isArray(result) ? result.length : 0)
|
|
77
|
+
const scannedCount = result?.scannedCount ?? cleanedCount
|
|
78
|
+
return {
|
|
79
|
+
kind: 'success',
|
|
80
|
+
text: `🧹 **磁盘常态清理完成**\n- 扫描项目/目录: ${scannedCount} 个\n- 已回收废弃/孤儿目录: ${cleanedCount} 个\n- 磁盘状态: 常态洁净`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
kind: 'error',
|
|
86
|
+
text: `未知参数: \`${action}\`。\n可用参数: \`status\` | \`enable\` | \`disable\` | \`gc\``,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unregScratch = commands.register({
|
|
91
|
+
definitionId: 'dsh-scratch-workspace:scratch',
|
|
92
|
+
name: 'scratch',
|
|
93
|
+
description: '瞬态草稿工作区管理:查看隔离状态、切换隔离或清理磁盘垃圾',
|
|
94
|
+
input: { hint: 'status | enable | disable | gc' },
|
|
95
|
+
handler,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const unregIsolate = commands.register({
|
|
99
|
+
definitionId: 'dsh-scratch-workspace:isolate',
|
|
100
|
+
name: 'isolate',
|
|
101
|
+
description: '查看或切换当前工作区会话隔离状态',
|
|
102
|
+
input: { hint: 'status | enable | disable | gc' },
|
|
103
|
+
handler,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
unregScratch?.()
|
|
108
|
+
unregIsolate?.()
|
|
109
|
+
}
|
|
110
|
+
}
|