dsh-scratch-workspace 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  ---
24
24
 
25
- ## 为什么需要它(面向什么问题)
25
+ ## 插件用途
26
26
 
27
27
  在 DeepSeek Harness 日常开发中,同时运行多个会话或使用默认工作区即兴提问时,开发者普遍面临以下三大核心痛点:
28
28
 
@@ -42,20 +42,34 @@
42
42
 
43
43
  ---
44
44
 
45
- ## 用什么方法(架构与技术原理)
45
+ ## 效果一览
46
+
47
+ - 🚀 **并行开发零干扰**:多个会话同时对同一项目执行重构、分析或测试,产物互不交叉。
48
+ - 🛡️ **无感平滑升级**:不受 DSH 小版本架构调整影响,插件长久稳定运行。
49
+ - 📂 **侧边栏清爽如初**:所有会话紧密附着于所属工作区,消除孤儿会话打扰。
50
+ - 🧹 **磁盘常态零负担**:废弃临时目录自动清理,保持本地环境轻盈纯净。
51
+
52
+ | 侧边栏原生聚合 (DSH 界面) | 物理沙箱隔离 (Finder 真实目录) |
53
+ | :---: | :---: |
54
+ | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/02-sidebar-workspace-grouping.png" width="280" alt="侧边栏工作区归集效果" /> | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/01-finder-isolated-folders.png" width="280" alt="Finder 目录隔离效果" /> |
55
+ | **顶层统一归集**:会话在侧边栏完整归属母工作区,拒绝臃肿碎片化,保持工作区清爽 | **底层物理隔离**:每个会话分配专属 `session-<标题>-<ID>` 子目录,改名实时就地同步,产物互不踩踏 |
56
+
57
+ | 交互式斜杠指令 (Slash Command) | ⭐ 即时状态看板与向导 (/scratch) |
58
+ | :---: | :---: |
59
+ | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/03-slash-command-menu.png" width="240" alt="斜杠指令菜单" /><br/><br/><img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/04-slash-command-args.png" width="240" alt="子命令提示" /> | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/05-scratch-status-card.png" width="580" alt="即时状态看板" /> |
60
+ | **开箱即用的指令交互**:输入 `/scratch` 自动补全,支持 `status`、`enable`、`disable` 与 `gc` | **状态一目了然(核心看板)**:清晰展示母工作区映射、会话沙箱物理路径与快捷运维向导 |
61
+
62
+ ---
63
+
64
+ ## 架构与技术原理
46
65
 
47
66
  ```mermaid
48
- flowchart TD
49
- User["用户在工作区新建会话"] --> Controller["sessionController.create()"]
50
- Controller --> Check{"检测是否为隔离工作区或默认工作区"}
67
+ flowchart LR
68
+ Create["新建会话"] --> Check{"是否隔离?"}
51
69
  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 守护:空闲回收 + 会话销毁即时释放"]
70
+ Check -- 是 --> Alloc["分配专属子沙箱<br/>session-xxx"]
71
+ Alloc --> Safe["微秒重定向 & 防冲突<br/>解绑 blank cwd"] --> Compat["双轨索引拦截<br/>挂靠母工作区"]
72
+ Compat --> Ready["沙箱安全写入<br/>侧边栏原生聚合"] -. 守护 .-> GC["常态生命周期 GC<br/>空闲清理 & 销毁即释"]
59
73
  ```
60
74
 
61
75
  ### 核心方法与实现机制
@@ -73,25 +87,24 @@ flowchart TD
73
87
 
74
88
  ---
75
89
 
76
- ## 达到了什么效果
77
-
78
- - 🚀 **并行开发零干扰**:多个会话同时对同一项目执行重构、分析或测试,产物互不交叉。
79
- - 🛡️ **无感平滑升级**:不受 DSH 小版本架构调整影响,插件长久稳定运行。
80
- - 📂 **侧边栏清爽如初**:所有会话紧密附着于所属工作区,消除孤儿会话打扰。
81
- - 🧹 **磁盘常态零负担**:废弃临时目录自动清理,保持本地环境轻盈纯净。
90
+ ## 安装与使用
82
91
 
83
- ---
92
+ ### 方式一:从 npm 安装(推荐)
84
93
 
85
- ## 安装与使用
94
+ ```bash
95
+ dsh plugin add dsh-scratch-workspace
96
+ # 或者通过 npm / pnpm
97
+ pnpm add dsh-scratch-workspace
98
+ ```
86
99
 
87
- ### 方式一:从本地安装(开发调试)
100
+ ### 方式二:从本地安装(开发调试)
88
101
 
89
102
  在 DeepSeek Harness 所在配置 Profile 中添加本插件依赖(例如 `~/.dsh/profiles/desktop`):
90
103
 
91
104
  ```bash
92
105
  # 1. 链接到你的 DSH Profile
93
106
  cd ~/.dsh/profiles/desktop
94
- pnpm add "link:/Users/midaxia/Documents/Projects/Codex Projects/dsh-plugins/dsh-scratch-workspace"
107
+ pnpm add "link:/path/to/dsh-scratch-workspace"
95
108
 
96
109
  # 2. 将 "dsh-scratch-workspace" 添加到 package.json 中的 dsh.profile.bundles 列表
97
110
 
@@ -108,16 +121,17 @@ dsh plugin --profile desktop add https://github.com/MDX-Tom/dsh-scratch-workspac
108
121
 
109
122
  ## 开启与控制隔离
110
123
 
111
- 隔离状态以工作区目录内的 `.dsh-isolate` 标记文件作为持久化依据,设置随目录移动,也可在以下入口灵活控制:
124
+ 插件支持多种灵活的开启与控制方式:
112
125
 
113
- 1. **对话内 Slash 命令**:
126
+ 1. **对话内 Slash 命令(推荐)**:
114
127
  在聊天输入框键入 `/scratch` 或 `/isolate` 即可调出交互命令,支持:
115
128
  - `/scratch status`:查看当前工作区隔离状态与草稿目录信息
116
- - `/scratch enable`:为当前工作区开启沙箱隔离(写入 `.dsh-isolate`)
129
+ - `/scratch enable`:为当前工作区开启沙箱隔离(自动写入 `.dsh-isolate` 标记)
117
130
  - `/scratch disable`:关闭隔离
118
131
  - `/scratch gc`:立即执行磁盘垃圾清理,回收空闲与孤儿会话目录
119
- 2. **默认工作区自动开启**:
120
- 默认工作区 (`default-workspace`) 自动享受沙箱隔离与草稿清理效果,无需任何手动配置。
132
+ 2. **目录标记文件 (`.dsh-isolate`) 与免配置自启**:
133
+ - **自定义工程/普通工作区**:在根目录下放置 `.dsh-isolate` 标记文件作为持久化依据,设置随目录与代码仓库移动。
134
+ - **默认工作区 (`default-workspace`)**:内置免配置自启,开箱即用,无需手动放置或生成标记文件。
121
135
  3. **Agent 智能调用 (LLM Tool)**:
122
136
  模型可自动调用 `session_isolation` 工具查看或切换隔离状态:
123
137
  - `action: "enable"`:为当前工作区开启隔离
package/index.js CHANGED
@@ -58,8 +58,22 @@ async function apply(ctx) {
58
58
  await renameSessionDirToTitle(sessionId, title, iso, reg, ctx, session)
59
59
  })
60
60
 
61
- const webServer = ctx.get('webServer', false)
62
- const offRoutes = webServer === undefined ? undefined : registerWebRoutes(ctx, webServer, iso)
61
+ let offRoutes = undefined
62
+ const setupWeb = (ws) => {
63
+ if (ws && !offRoutes) {
64
+ offRoutes = registerWebRoutes(ctx, ws, iso)
65
+ }
66
+ }
67
+
68
+ const initialWs = ctx.get?.('webServer', false) ?? ctx.root?.get?.('webServer', false) ?? ctx.webServer
69
+ if (initialWs) {
70
+ setupWeb(initialWs)
71
+ } else if (typeof ctx.inject === 'function') {
72
+ ctx.inject(['webServer'], (innerCtx) => {
73
+ const ws = innerCtx.get?.('webServer', false) ?? innerCtx.webServer
74
+ setupWeb(ws)
75
+ })
76
+ }
63
77
 
64
78
  ctx.logger?.info?.(
65
79
  `dsh-scratch-workspace ready: ${iso.roots().length} isolating director${iso.roots().length === 1 ? 'y' : 'ies'}`,
package/lib/patch.js CHANGED
@@ -24,6 +24,144 @@ import { cleanOrphans, isDirectoryEmpty, removeSessionDir, shortSessionId } from
24
24
  /** The registry-internal host property. */
25
25
  const HOST_PROPERTY = 'host'
26
26
 
27
+ /**
28
+ * Resolves a session directory path to its actual existing on-disk location,
29
+ * reconciling title-based renames, cold restarts, and stale header cwds.
30
+ * @param {string} [targetPath] - candidate or stale path
31
+ * @param {object} [options]
32
+ * @param {string} [options.sessionId] - session ID if known
33
+ * @param {object} [options.iso] - isolation state
34
+ * @param {object} [options.ctx] - Cordis context
35
+ * @returns {Promise<string>} real on-disk directory or fallback targetPath
36
+ */
37
+ export async function resolveSessionDir(targetPath, { sessionId, iso, ctx } = {}) {
38
+ // 1. If targetPath already exists on disk as a directory, it is valid
39
+ if (typeof targetPath === 'string' && targetPath !== '') {
40
+ try {
41
+ if ((await stat(targetPath)).isDirectory()) {
42
+ if (sessionId) iso?.recordSessionPath?.(sessionId, targetPath)
43
+ return targetPath
44
+ }
45
+ } catch {}
46
+ }
47
+
48
+ // 2. Check if isolation state has a recorded rewrite
49
+ if (typeof targetPath === 'string' && targetPath !== '') {
50
+ const rewritten = iso?.rewritePath?.(targetPath)
51
+ if (rewritten && rewritten !== targetPath) {
52
+ try {
53
+ if ((await stat(rewritten)).isDirectory()) {
54
+ if (sessionId) iso?.recordSessionPath?.(sessionId, rewritten)
55
+ return rewritten
56
+ }
57
+ } catch {}
58
+ }
59
+ }
60
+
61
+ // 3. If sessionId is provided, check if iso already knows its session path
62
+ if (sessionId) {
63
+ const current = iso?.sessionPath?.(sessionId)
64
+ if (current && current !== targetPath) {
65
+ try {
66
+ if ((await stat(current)).isDirectory()) {
67
+ if (targetPath) iso?.recordRename?.(targetPath, current)
68
+ return current
69
+ }
70
+ } catch {}
71
+ }
72
+ }
73
+
74
+ // 4. If sessionId is not provided, try to find sessionId from ctx.sessions or ctx.sessionQuery
75
+ let effectiveSessionId = sessionId
76
+ if (!effectiveSessionId && typeof targetPath === 'string' && targetPath !== '' && ctx) {
77
+ const sessionsService = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
78
+ if (typeof sessionsService?.list === 'function') {
79
+ try {
80
+ for (const s of sessionsService.list()) {
81
+ if (s?.header?.cwd === targetPath) {
82
+ effectiveSessionId = s.id
83
+ break
84
+ }
85
+ }
86
+ } catch {}
87
+ }
88
+
89
+ if (!effectiveSessionId) {
90
+ const query = typeof ctx.get === 'function' ? ctx.get('sessionQuery', false) : ctx.sessionQuery
91
+ if (typeof query?.listSessions === 'function') {
92
+ try {
93
+ const records = await query.listSessions()
94
+ for (const r of records) {
95
+ if (r?.header?.cwd === targetPath) {
96
+ effectiveSessionId = r.header.id
97
+ break
98
+ }
99
+ }
100
+ } catch {}
101
+ }
102
+ }
103
+ }
104
+
105
+ // 5. If effectiveSessionId is known, check workingDirectory service if available
106
+ if (effectiveSessionId && ctx) {
107
+ const wd = typeof ctx.get === 'function' ? ctx.get('workingDirectory', false) : ctx.workingDirectory
108
+ const sessionsService = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
109
+ const session = sessionsService?.get?.(effectiveSessionId)
110
+ if (session && typeof wd?.get === 'function') {
111
+ try {
112
+ const wdPath = wd.get(session)
113
+ if (wdPath && (await stat(wdPath)).isDirectory()) {
114
+ iso?.recordSessionPath?.(effectiveSessionId, wdPath)
115
+ if (targetPath && targetPath !== wdPath) iso?.recordRename?.(targetPath, wdPath)
116
+ return wdPath
117
+ }
118
+ } catch {}
119
+ }
120
+ }
121
+
122
+ // 6. Look for matching session directory on disk using candidate short IDs
123
+ const candidateShorts = new Set()
124
+ if (effectiveSessionId) {
125
+ candidateShorts.add(shortSessionId(effectiveSessionId))
126
+ }
127
+ if (typeof targetPath === 'string' && targetPath !== '') {
128
+ const b = basename(targetPath)
129
+ const match = b.match(SESSION_DIR_PATTERN)
130
+ if (match) {
131
+ const s = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
132
+ if (s) candidateShorts.add(s)
133
+ }
134
+ }
135
+
136
+ if (candidateShorts.size > 0) {
137
+ const roots = new Set(iso?.roots?.() ?? [])
138
+ if (typeof targetPath === 'string' && targetPath !== '') {
139
+ const parent = iso?.ownerOf?.(targetPath) ?? (await stat(targetPath).catch(() => null) ? null : resolve(join(targetPath, '..')))
140
+ if (parent) roots.add(parent)
141
+ }
142
+
143
+ for (const root of roots) {
144
+ try {
145
+ const entries = await readdir(root, { withFileTypes: true })
146
+ for (const entry of entries) {
147
+ if (!entry.isDirectory()) continue
148
+ const match = entry.name.match(SESSION_DIR_PATTERN)
149
+ if (!match) continue
150
+ const entryShort = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
151
+ if (candidateShorts.has(entryShort)) {
152
+ const foundDir = resolve(join(root, entry.name))
153
+ if (effectiveSessionId) iso?.recordSessionPath?.(effectiveSessionId, foundDir)
154
+ if (targetPath && targetPath !== foundDir) iso?.recordRename?.(targetPath, foundDir)
155
+ return foundDir
156
+ }
157
+ }
158
+ } catch {}
159
+ }
160
+ }
161
+
162
+ return targetPath
163
+ }
164
+
27
165
  /**
28
166
  * Locate and remove a session's directory if it is empty.
29
167
  * Uses 3-tier lookup: in-memory iso state -> registry index -> isolating roots scan by shortId.
@@ -347,6 +485,65 @@ export function patchSessionCreate(ctx, iso) {
347
485
  }
348
486
  }
349
487
 
488
+ // Hook sessionController.list to project real on-disk session paths to frontend useSessions
489
+ const originalList = controller()?.list
490
+ if (typeof originalList === 'function') {
491
+ controller().list = async function list(request, signal) {
492
+ const res = await originalList.call(this, request, signal)
493
+ if (Array.isArray(res?.items)) {
494
+ for (const item of res.items) {
495
+ if (!item?.sessionId) continue
496
+ const resolved = await resolveSessionDir(item.cwd, {
497
+ sessionId: item.sessionId,
498
+ iso,
499
+ ctx,
500
+ })
501
+ if (resolved) {
502
+ item.cwd = resolved
503
+ }
504
+ }
505
+ }
506
+ return res
507
+ }
508
+ }
509
+
510
+ // Also hook controller().listState.list if present
511
+ const listState = controller()?.listState
512
+ const originalStateList = listState?.list
513
+ if (typeof originalStateList === 'function') {
514
+ listState.list = async function list(signal) {
515
+ const items = await originalStateList.call(this, signal)
516
+ if (Array.isArray(items)) {
517
+ for (const item of items) {
518
+ if (!item?.sessionId) continue
519
+ const resolved = await resolveSessionDir(item.cwd, {
520
+ sessionId: item.sessionId,
521
+ iso,
522
+ ctx,
523
+ })
524
+ if (resolved) {
525
+ item.cwd = resolved
526
+ }
527
+ }
528
+ }
529
+ return items
530
+ }
531
+ }
532
+
533
+ // Hook controller().openWorkspacePath to rewrite paths for file reveals/opens
534
+ const originalOpenWorkspacePath = controller()?.openWorkspacePath
535
+ if (typeof originalOpenWorkspacePath === 'function') {
536
+ controller().openWorkspacePath = async function openWorkspacePath(request, signal) {
537
+ if (request && typeof request.path === 'string') {
538
+ const resolved = await resolveSessionDir(request.path, { iso, ctx })
539
+ if (resolved && resolved !== request.path) {
540
+ request = { ...request, path: resolved }
541
+ }
542
+ }
543
+ return await originalOpenWorkspacePath.call(this, request, signal)
544
+ }
545
+ }
546
+
350
547
  // Hook workspaceController.archiveSession for frontend archive actions
351
548
  const wsCtrl = ctx.root?.workspaceController ?? ctx.workspaceController
352
549
  const originalWsArchive = wsCtrl?.archiveSession
@@ -365,6 +562,13 @@ export function patchSessionCreate(ctx, iso) {
365
562
  if (typeof originalRename === 'function') controller().rename = originalRename
366
563
  if (typeof originalDelete === 'function') controller().delete = originalDelete
367
564
  if (typeof originalWsArchive === 'function') wsCtrl.archiveSession = originalWsArchive
565
+ if (typeof originalList === 'function') controller().list = originalList
566
+ if (typeof originalStateList === 'function' && controller()?.listState) {
567
+ controller().listState.list = originalStateList
568
+ }
569
+ if (typeof originalOpenWorkspacePath === 'function') {
570
+ controller().openWorkspacePath = originalOpenWorkspacePath
571
+ }
368
572
  }
369
573
  }
370
574
 
@@ -481,7 +685,10 @@ export function patchWorkingDirectory(ctx, iso) {
481
685
  * @param {object} [sessionObj] - live session object if available
482
686
  */
483
687
  export async function renameSessionDirToTitle(sessionId, title, iso, registry, ctx, sessionObj) {
484
- const currentDir = iso.sessionPath(sessionId)
688
+ let currentDir = iso.sessionPath(sessionId)
689
+ if (!currentDir) {
690
+ currentDir = await resolveSessionDir(undefined, { sessionId, iso, ctx })
691
+ }
485
692
  if (!currentDir) return
486
693
 
487
694
  const clean = sanitizeTitleForPath(title)
package/lib/web.js CHANGED
@@ -3,6 +3,10 @@
3
3
  * @module dsh-scratch-workspace/lib/web
4
4
  */
5
5
 
6
+ import { Readable } from 'node:stream'
7
+ import { stat } from 'node:fs/promises'
8
+ import { resolveSessionDir } from './patch.js'
9
+
6
10
  export const LIST_ROUTE = '/scratch-workspace/workspaces'
7
11
  export const TOGGLE_ROUTE = '/scratch-workspace/toggle'
8
12
  export const GC_ROUTE = '/scratch-workspace/gc'
@@ -11,6 +15,8 @@ export const GC_ROUTE = '/scratch-workspace/gc'
11
15
  export const LEGACY_LIST_ROUTE = '/session-isolation/workspaces'
12
16
  export const LEGACY_TOGGLE_ROUTE = '/session-isolation/toggle'
13
17
 
18
+ export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open'
19
+
14
20
  const MAX_BODY_BYTES = 4096
15
21
 
16
22
  function json(res, status, body) {
@@ -110,7 +116,131 @@ export function registerWebRoutes(ctx, webServer, iso) {
110
116
  disposers.push(webServer.register({ kind: 'exact', path: LEGACY_LIST_ROUTE, handler: handleList }))
111
117
  disposers.push(webServer.register({ kind: 'exact', path: LEGACY_TOGGLE_ROUTE, handler: handleToggle }))
112
118
 
119
+ // Intercept open-in-app to rewrite stale or renamed session directories
120
+ disposers.push(interceptOpenInApp(webServer, iso, ctx))
121
+
113
122
  return () => {
114
123
  for (const d of disposers) d?.()
115
124
  }
116
125
  }
126
+
127
+ function createFakeReq(originalReq, buffer) {
128
+ const fakeStream = Readable.from([buffer])
129
+ return new Proxy(originalReq, {
130
+ get(target, prop, receiver) {
131
+ if (
132
+ prop === Symbol.asyncIterator ||
133
+ prop === 'read' ||
134
+ prop === 'on' ||
135
+ prop === 'once' ||
136
+ prop === 'pipe' ||
137
+ prop === 'resume' ||
138
+ prop === 'pause'
139
+ ) {
140
+ const val = fakeStream[prop]
141
+ return typeof val === 'function' ? val.bind(fakeStream) : val
142
+ }
143
+ if (prop === 'headers') {
144
+ return {
145
+ ...target.headers,
146
+ 'content-length': String(buffer.length),
147
+ }
148
+ }
149
+ const val = Reflect.get(target, prop, receiver)
150
+ return typeof val === 'function' ? val.bind(target) : val
151
+ },
152
+ })
153
+ }
154
+
155
+ export function interceptOpenInApp(webServer, iso, ctx) {
156
+ if (!webServer || typeof webServer.register !== 'function') return () => {}
157
+
158
+ const wrapHandler = (originalHandler) => {
159
+ if (typeof originalHandler !== 'function') return originalHandler
160
+ if (originalHandler.__dsh_scratch_wrapped) return originalHandler
161
+
162
+ const wrapped = async function openInAppHandler(req, res) {
163
+ if (req.method !== 'POST') {
164
+ return originalHandler(req, res)
165
+ }
166
+
167
+ const chunks = []
168
+ let size = 0
169
+ let tooLarge = false
170
+ try {
171
+ for await (const chunk of req) {
172
+ size += chunk.length
173
+ if (size > 65536) {
174
+ tooLarge = true
175
+ break
176
+ }
177
+ chunks.push(chunk)
178
+ }
179
+ } catch {}
180
+
181
+ const rawBuffer = Buffer.concat(chunks)
182
+ if (tooLarge) {
183
+ const fakeReq = createFakeReq(req, rawBuffer)
184
+ return originalHandler(fakeReq, res)
185
+ }
186
+
187
+ let body = null
188
+ try {
189
+ body = JSON.parse(rawBuffer.toString('utf8'))
190
+ } catch {}
191
+
192
+ if (!body || typeof body.path !== 'string') {
193
+ const fakeReq = createFakeReq(req, rawBuffer)
194
+ return originalHandler(fakeReq, res)
195
+ }
196
+
197
+ let isDir = false
198
+ try {
199
+ isDir = (await stat(body.path)).isDirectory()
200
+ } catch {
201
+ isDir = false
202
+ }
203
+
204
+ if (!isDir) {
205
+ const targetPath = await resolveSessionDir(body.path, { iso, ctx })
206
+ if (targetPath && targetPath !== body.path) {
207
+ body.path = targetPath
208
+ const newBuffer = Buffer.from(JSON.stringify(body), 'utf8')
209
+ const fakeReq = createFakeReq(req, newBuffer)
210
+ return originalHandler(fakeReq, res)
211
+ }
212
+ }
213
+
214
+ const fakeReq = createFakeReq(req, rawBuffer)
215
+ return originalHandler(fakeReq, res)
216
+ }
217
+
218
+ wrapped.__dsh_scratch_wrapped = true
219
+ wrapped.__dsh_scratch_original = originalHandler
220
+ return wrapped
221
+ }
222
+
223
+ // 1. If /open-in-app/open is already registered in webServer.exact
224
+ const existingRoute = webServer.exact?.get(OPEN_IN_APP_OPEN_PATH)
225
+ let originalExactHandler = undefined
226
+ if (existingRoute && typeof existingRoute.handler === 'function') {
227
+ originalExactHandler = existingRoute.handler
228
+ existingRoute.handler = wrapHandler(existingRoute.handler)
229
+ }
230
+
231
+ // 2. Wrap webServer.register to catch future /open-in-app/open registrations
232
+ const originalRegister = webServer.register.bind(webServer)
233
+ webServer.register = function (route) {
234
+ if (route?.kind === 'exact' && route?.path === OPEN_IN_APP_OPEN_PATH && typeof route.handler === 'function') {
235
+ route.handler = wrapHandler(route.handler)
236
+ }
237
+ return originalRegister(route)
238
+ }
239
+
240
+ return () => {
241
+ webServer.register = originalRegister
242
+ if (existingRoute && originalExactHandler) {
243
+ existingRoute.handler = originalExactHandler
244
+ }
245
+ }
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scratch-workspace",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "DeepSeek Harness 瞬态会话隔离与草稿清理插件 — 顺应官方原生工作区规范的会话沙箱隔离、侧边栏归集与磁盘生命周期自动清理",
5
5
  "author": "Mi Tom <6468993+MDX-Tom@users.noreply.github.com>",
6
6
  "license": "MIT",