dsh-extra-folders 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kingOfSoySauce
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.en.md ADDED
@@ -0,0 +1,120 @@
1
+ # dsh-extra-folders
2
+
3
+ Attach extra writable folders to one workspace. Once the plugin is installed, the sidebar workspace row gets an extra "Add folder" item in its menu. Any folder you pick there is writable for that workspace's sessions. Both the file tools and the shell honor the grant, so writing there does not need a separate approval each time.
4
+
5
+ | Workspace menu | Dialog |
6
+ | --- | --- |
7
+ | ![Add folder in the workspace menu](assets/menu.png) | ![Add folder dialog](assets/dialog.png) |
8
+
9
+ ## Install
10
+
11
+ Requires DSH 0.2.2-alpha.3 or later.
12
+
13
+ ### Option 1: Add from the Plugins page
14
+
15
+ Open **Settings → Plugins → Add plugin** and paste this line:
16
+
17
+ ```text
18
+ github:kingOfSoySauce/dsh-extra-folders
19
+ ```
20
+
21
+ After it installs, make sure the plugin row is on.
22
+
23
+ ### Option 2: Command line
24
+
25
+ ```sh
26
+ dsh plugin --profile desktop add github:kingOfSoySauce/dsh-extra-folders
27
+ ```
28
+
29
+ ### Option 3: Ask an agent to install it
30
+
31
+ <details>
32
+ <summary><strong>Click to expand the prompt</strong></summary>
33
+
34
+ ```text
35
+ Please install dsh-extra-folders into the current profile:
36
+ https://github.com/kingOfSoySauce/dsh-extra-folders
37
+
38
+ First, confirm three things in read-only mode: whether the host is Desktop or Web, the current profile name, and whether the dsh version is >= 0.2.2-alpha.3.
39
+ If the version is too low, tell me and do not try to install.
40
+
41
+ On Desktop, prefer Plugins → Add plugin and paste
42
+ github:kingOfSoySauce/dsh-extra-folders
43
+ If the current host provides plugin management tools, you can also run
44
+ dsh plugin --profile <profile> add github:kingOfSoySauce/dsh-extra-folders.
45
+
46
+ After install, check that the plugin row is enabled. If a restart is needed, tell me and I will restart it myself.
47
+ Do not edit the profile's package.json by hand, and do not touch other plugins.
48
+ When it is installed, take me to the more menu of a sidebar workspace row and confirm that it contains "Add folder".
49
+ ```
50
+
51
+ </details>
52
+
53
+ ## Usage
54
+
55
+ Sidebar workspace row → the more button at the end of the row → **Add folder**.
56
+
57
+ The first row in the dialog is the workspace itself. It is always writable and cannot be removed. The rows below are the folders you added. You can remove each one on its own. Adding and removing take effect right away. No refresh, and no need to wait for the next session.
58
+
59
+ The grant follows the workspace. Subagents are covered too. Sessions in other workspaces do not see these folders.
60
+
61
+ ## How it compares to similar plugins
62
+
63
+ | | This plugin | multi-root-workspace | union-workspace |
64
+ | --- | --- | --- | --- |
65
+ | Registers model tools | No | No | Yes, 5 |
66
+ | Changes upstream components | Only wraps `sandboxPolicy.resolve()` | Disables the `fs-sandbox` and `sandbox` rows, replaces both implementations | No replacement. Adds separate tools to bypass it |
67
+ | Runtime dependencies | None | `koffi`, `zod` | None |
68
+ | Per-folder read-only or read-write levels | No | Yes | No |
69
+ | Sidebar management panel | No | Yes | Yes |
70
+ | Minimum DSH version | 0.2.2-alpha.3 | 0.1.5-rc.2 | 0.1.7-rc.2 (from peer deps) |
71
+ | bash can write to attached folders on macOS | Yes | Yes | No (needs the danger-full-access preset) |
72
+
73
+ This plugin does one thing: it adds the attached folders to the session's writable scope. It registers no tools, replaces no implementation, and adds no dependencies. Turn it off and the host behaves like stock DSH again.
74
+
75
+ The closest neighbour is jkm6221/dsh-workspace-folders. It uses the same mechanism (wraps `resolve()`, merges `additionalWritableRoots`) but registers a `workspace_folders` model tool and requires DSH 0.2.1-rc.1 or later.
76
+
77
+ If you need per-folder permission levels or a sidebar panel, use multi-root-workspace.
78
+
79
+ ## Compatibility
80
+
81
+ | Environment | Status |
82
+ | --- | --- |
83
+ | macOS desktop 0.2.2-rc.1 | Verified |
84
+ | DSH 0.2.2-alpha.3 and later | Supported |
85
+ | DSH 0.2.1 and earlier | Not supported. The kernel has no `additionalWritableRoots` and no workspace menu service |
86
+ | Web (`dsh web`) | Same code as desktop. The folder picker is replaced by in-app browsing. Not tested |
87
+ | Windows | Applies to the file tools only. The sandboxed shell is not affected. This limit comes from DSH. Not tested |
88
+
89
+ The kernel is checked when the host starts. If the kernel is not supported, the plugin writes one clear log line and stops. You are not left with a menu item that does nothing.
90
+
91
+ ## Update and uninstall
92
+
93
+ To update, pull the latest code, then turn the plugin row off and on again in Plugins.
94
+
95
+ ```sh
96
+ cd /absolute/path/dsh-extra-folders && git pull
97
+ ```
98
+
99
+ To uninstall:
100
+
101
+ ```sh
102
+ dsh plugin --profile desktop remove dsh-extra-folders
103
+ ```
104
+
105
+ You can also remove it directly in Plugins.
106
+
107
+ ## Troubleshooting
108
+
109
+ | Symptom | What to do |
110
+ | --- | --- |
111
+ | No "Add folder" in the menu | Check that the plugin row is on and refresh the page once. dsh below 0.2.2-alpha.3 does not have this item |
112
+ | I added a folder but it still asks for approval | Check whether the session permission is `read-only`. Attached folders do not apply in that mode |
113
+ | An old session still asks for approval, or asks first whether to request it | Start a new session. The approval habit from earlier turns carries over. A new grant does not rewrite the pattern already set in that conversation |
114
+ | I picked a folder but it was not added | The plugin only accepts real directories. A symlink is rejected, so pick the path it points to |
115
+ | bash cannot write on Windows | DSH only grants attached folders to the file tools. This is a DSH limit and the plugin cannot change it |
116
+ | On Web, the dialog opens but there is no system picker | On Web, in-app browsing replaces the system dialog |
117
+
118
+ ## License
119
+
120
+ MIT
package/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # dsh-extra-folders
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给一个工作区多挂几个可写文件夹。装好以后,侧栏工作区行的菜单里多一项「添加文件夹」,选进来的文件夹对这个工作区的会话可写,文件工具和 shell 都认,写到那里不用再逐次点授权。
4
+
5
+ | 工作区菜单 | 对话框 |
6
+ | --- | --- |
7
+ | ![工作区菜单里的「添加文件夹」](assets/menu.png) | ![添加文件夹对话框](assets/dialog.png) |
8
+
9
+ ## 安装
10
+
11
+ 需要 DSH 0.2.2-alpha.3 及以上。
12
+
13
+ ### 方式一:在插件页添加
14
+
15
+ **设置 → Plugins → Add plugin**,把下面这行粘进去:
16
+
17
+ ```text
18
+ github:kingOfSoySauce/dsh-extra-folders
19
+ ```
20
+
21
+ 装完确认插件行是开着的。
22
+
23
+ ### 方式二:命令行
24
+
25
+ ```sh
26
+ dsh plugin --profile desktop add github:kingOfSoySauce/dsh-extra-folders
27
+ ```
28
+
29
+ ### 方式三:让 Agent 装
30
+
31
+ <details>
32
+ <summary><strong>点击展开提示词</strong></summary>
33
+
34
+ ```text
35
+ 请帮我把 dsh-extra-folders 装到当前 profile:
36
+ https://github.com/kingOfSoySauce/dsh-extra-folders
37
+
38
+ 先只读确认三件事:宿主是桌面端还是 Web、当前 profile 名、dsh 版本是否 >= 0.2.2-alpha.3。
39
+ 版本不够就直接告诉我,不要尝试安装。
40
+
41
+ 桌面端优先用 Plugins → Add plugin,粘贴
42
+ github:kingOfSoySauce/dsh-extra-folders
43
+ 如果当前宿主提供了插件管理工具,也可以执行
44
+ dsh plugin --profile <profile> add github:kingOfSoySauce/dsh-extra-folders。
45
+
46
+ 装完核对插件行是启用状态。需要重启时告诉我,由我自己重启。
47
+ 不要手改 profile 的 package.json,不要动其他插件。
48
+ 装好后带我到侧栏工作区行的更多菜单,确认里面有「添加文件夹」。
49
+ ```
50
+
51
+ </details>
52
+
53
+ ## 使用
54
+
55
+ 侧栏工作区行 → 行尾的更多按钮 → **添加文件夹**。
56
+
57
+ 弹框第一行是工作区本身,它一直可写,不能移除。下面是你添加的文件夹,可以单独移除。添加和移除都立即生效,不用刷新,也不用等下一个会话。
58
+
59
+ 授权跟着工作区走。子 agent 也算在内,其他工作区的会话看不到这些文件夹。
60
+
61
+ ## 和同类插件比
62
+
63
+ | | 本插件 | multi-root-workspace | union-workspace |
64
+ | --- | --- | --- | --- |
65
+ | 注册模型工具 | 不注册 | 不注册 | 注册 5 个 |
66
+ | 改动上游组件 | 只包 `sandboxPolicy.resolve()` | 禁用 fs-sandbox、sandbox 两行,换掉 fs 与 sandbox 实现 | 不替换,另建工具绕开 |
67
+ | 运行时依赖 | 无 | `koffi`、`zod` | 无 |
68
+ | 每目录只读或读写分级 | 没有 | 有 | 没有 |
69
+ | 侧栏管理面板 | 没有 | 有 | 有 |
70
+ | macOS 上 bash 可写附加目录 | 是 | 是 | 否(要 danger-full-access 预设) |
71
+ | 最低 DSH 版本 | 0.2.2-alpha.3 | 0.1.5-rc.2 | 0.1.7-rc.2(按 peer 依赖推断) |
72
+
73
+ 这插件只做一件事:把附加目录并进会话的可写范围。不加工具、不换实现、不加依赖,关掉就回到原生行为。
74
+
75
+ 另一个同类是 jkm6221/dsh-workspace-folders,机制和我们一样(包装 `resolve()`、合并 `additionalWritableRoots`),但它注册了一个 `workspace_folders` 模型工具,最低要求 0.2.1-rc.1。
76
+
77
+ 要每目录分级权限或侧栏面板,用 multi-root-workspace。
78
+
79
+ ## 兼容性
80
+
81
+ | 环境 | 状态 |
82
+ | --- | --- |
83
+ | macOS 桌面端 0.2.2-rc.1 | 已验证 |
84
+ | DSH 0.2.2-alpha.3 及以上 | 支持 |
85
+ | DSH 0.2.1 及更早 | 不支持。内核没有 `additionalWritableRoots`,也没有工作区菜单服务 |
86
+ | Web(`dsh web`) | 代码与桌面端相同,文件夹选择器换成应用内浏览。未实测 |
87
+ | Windows | 只对文件工具生效,沙箱里的 shell 不受影响。这条限制来自 DSH 本身,未实测 |
88
+
89
+ 宿主启动时会检查内核。不支持就写一条明确日志并退出,不会留下一个点了没反应的菜单。
90
+
91
+ ## 更新与卸载
92
+
93
+ 更新:拉最新代码,然后在 Plugins 里把插件行关掉再打开。
94
+
95
+ ```sh
96
+ cd /绝对路径/dsh-extra-folders && git pull
97
+ ```
98
+
99
+ 卸载:
100
+
101
+ ```sh
102
+ dsh plugin --profile desktop remove dsh-extra-folders
103
+ ```
104
+
105
+ 也可以在 Plugins 里直接移除。
106
+
107
+ ## 遇到问题
108
+
109
+ | 现象 | 处理 |
110
+ | --- | --- |
111
+ | 菜单里没有「添加文件夹」 | 确认插件行开着,刷新一次页面;dsh 低于 0.2.2-alpha.3 不会有这一项 |
112
+ | 加了文件夹还是弹授权 | 看会话权限是不是 `read-only`,这个模式下附加文件夹不生效 |
113
+ | 老会话还在弹授权,或者先问一句要不要申请 | 新开一个会话。之前几轮的审批习惯会延续下来,新加的授权不会改写已经形成的对话惯性 |
114
+ | 选了文件夹却没加上 | 插件只收真实目录,选了软链接会被拒绝,选它指向的路径 |
115
+ | Windows 上 bash 写不进去 | DSH 只给文件工具放行附加目录,这是 DSH 的限制,插件改不了 |
116
+ | Web 端点开但没有系统选择器 | Web 端由应用内浏览代替系统对话框 |
117
+
118
+ ## License
119
+
120
+ MIT
Binary file
Binary file
package/client.js ADDED
@@ -0,0 +1,447 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-extra-folders',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ /** The official controls: Modal and Button are seed modules every plugin may require. */
7
+ let primitives
8
+ try {
9
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives')
10
+ } catch {
11
+ primitives = undefined
12
+ }
13
+
14
+ const ROUTE = '/extra-folders'
15
+ const DIALOG_CLASS = 'wxf-dialog'
16
+ const ICON_KEY = 'workspace-extra-folders.folder'
17
+ /** After 打开文件夹 and 在 Finder 中显示, before 重命名 (100) and 删除 (300). */
18
+ const MENU_ORDER = 150
19
+
20
+ const COPY = {
21
+ zh: {
22
+ menu: '添加文件夹',
23
+ title: '工作区添加文件夹',
24
+ add: '添加文件夹',
25
+ cancel: '取消',
26
+ close: '关闭',
27
+ empty: '还没有文件夹',
28
+ remove: '移除',
29
+ missing: '文件夹已不存在',
30
+ failed: '没保存成功,再试一次',
31
+ pickerUnavailable: '这里打不开系统文件夹选择器',
32
+ root: '工作区',
33
+ codes: {
34
+ notWorkspace: '这个目录不是侧栏里的工作区',
35
+ duplicate: '这个文件夹已经加过了',
36
+ notDirectory: '选中的不是文件夹',
37
+ missing: '文件夹不见了,可能刚被删掉',
38
+ symlink: '这是软链接,请选它指向的真实路径',
39
+ self: '这就是工作区本身',
40
+ unknown: '没保存成功,再试一次'
41
+ }
42
+ },
43
+ en: {
44
+ menu: 'Add folder',
45
+ title: 'Add workspace folders',
46
+ add: 'Add folder',
47
+ cancel: 'Cancel',
48
+ close: 'Close',
49
+ empty: 'No folders yet',
50
+ remove: 'Remove',
51
+ missing: 'Folder is gone',
52
+ failed: "Didn't save. Try again",
53
+ pickerUnavailable: 'The system folder picker is not available here',
54
+ root: 'Workspace',
55
+ codes: {
56
+ notWorkspace: 'That directory is not a workspace in the sidebar',
57
+ duplicate: 'That folder is already attached',
58
+ notDirectory: 'That is not a folder',
59
+ missing: 'That folder is gone',
60
+ symlink: 'That is a symlink. Pick the real path it points to.',
61
+ self: 'That is the workspace itself',
62
+ unknown: "Didn't save. Try again"
63
+ }
64
+ }
65
+ }
66
+
67
+ /** Captured Client context so components can reach services lazily. */
68
+ let pluginContext
69
+
70
+ /** One dictionary per render, picked from the live locale. Never mixed. */
71
+ function copy() {
72
+ try {
73
+ const snapshot = pluginContext?.get?.('locale')?.getLocale?.()
74
+ const id = typeof snapshot?.active === 'string' ? snapshot.active : snapshot?.id
75
+ return COPY[typeof id === 'string' && id.startsWith('zh') ? 'zh' : 'en'] ?? COPY.en
76
+ } catch {
77
+ return COPY.en
78
+ }
79
+ }
80
+
81
+ /** One translated sentence per failure code. */
82
+ function describe(failure) {
83
+ const dict = copy()
84
+ const code = typeof failure?.code === 'string' ? failure.code : 'unknown'
85
+ return dict.codes[code] ?? dict.failed
86
+ }
87
+
88
+ async function callRoute(endpoint, payload) {
89
+ if (typeof location === 'undefined') throw new Error('the workspace route needs the browser GUI')
90
+ const url = new URL(`${ROUTE}/${endpoint}`, location.href).toString()
91
+ const response = await fetch(url, {
92
+ method: 'POST',
93
+ credentials: 'same-origin',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify(payload ?? {})
96
+ })
97
+ const text = await response.text()
98
+ let data
99
+ try {
100
+ data = JSON.parse(text)
101
+ } catch {
102
+ data = undefined
103
+ }
104
+ const code = typeof data?.error === 'string' ? data.error : 'unknown'
105
+ if (!response.ok || typeof data?.error === 'string') {
106
+ const failure = new Error(code)
107
+ failure.code = code
108
+ throw failure
109
+ }
110
+ return data
111
+ }
112
+
113
+ // -- one open dialog at a time -----------------------------------------
114
+ let openRequest = null
115
+ const listeners = new Set()
116
+ function publish(next) {
117
+ openRequest = next
118
+ for (const listener of [...listeners]) {
119
+ try {
120
+ listener()
121
+ } catch {
122
+ // one broken listener must not block the others
123
+ }
124
+ }
125
+ }
126
+ function subscribe(listener) {
127
+ listeners.add(listener)
128
+ return () => {
129
+ listeners.delete(listener)
130
+ }
131
+ }
132
+ function useRequest() {
133
+ return React.useSyncExternalStore(subscribe, () => openRequest, () => openRequest)
134
+ }
135
+
136
+ // Rows copy the models page: one bordered card per entry, actions at the right edge.
137
+ const styles = {
138
+ list: { display: 'flex', flexDirection: 'column', gap: '10px' },
139
+ row: {
140
+ display: 'flex',
141
+ alignItems: 'center',
142
+ gap: '12px',
143
+ minHeight: '52px',
144
+ padding: '0 16px',
145
+ borderRadius: '12px',
146
+ border: '1px solid var(--dsw-alias-border-l1)',
147
+ background: 'transparent'
148
+ },
149
+ rowPath: { flex: '1 1 auto', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', direction: 'ltr', fontSize: '14px' },
150
+ remove: {
151
+ border: '1px solid var(--dsw-alias-border-l1)',
152
+ background: 'transparent',
153
+ color: 'var(--dsw-alias-label-primary)',
154
+ borderRadius: '8px',
155
+ padding: '4px 12px',
156
+ cursor: 'pointer',
157
+ font: 'inherit',
158
+ fontSize: '13px'
159
+ },
160
+ tag: { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', flex: '0 0 auto' },
161
+ missing: { color: 'var(--dsw-alias-state-warn-primary)', fontSize: '12px' },
162
+ error: { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', lineHeight: '1.5' },
163
+ addRow: {
164
+ display: 'flex',
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ gap: '8px',
168
+ minHeight: '52px',
169
+ width: '100%',
170
+ borderRadius: '12px',
171
+ border: '1px dashed var(--dsw-alias-border-l2)',
172
+ background: 'transparent',
173
+ color: 'var(--dsw-alias-label-primary)',
174
+ cursor: 'pointer',
175
+ font: 'inherit',
176
+ fontSize: '14px'
177
+ }
178
+ }
179
+
180
+ function FolderGlyph() {
181
+ return h('svg', {
182
+ width: 14,
183
+ height: 14,
184
+ viewBox: '0 0 16 16',
185
+ fill: 'none',
186
+ stroke: 'currentColor',
187
+ strokeWidth: 1.3,
188
+ strokeLinecap: 'round',
189
+ strokeLinejoin: 'round',
190
+ 'aria-hidden': true
191
+ }, [
192
+ h('path', { key: 'folder', d: 'M1.5 3.5C1.5 2.94772 1.94772 2.5 2.5 2.5H6.5L8 4.5H13.5C14.0523 4.5 14.5 4.94772 14.5 5.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V3.5Z' }),
193
+ h('path', { key: 'plus', d: 'M8 7.5V11.5M6 9.5H10' })
194
+ ])
195
+ }
196
+
197
+ /** One workspace's attached folders, rendered inside the official Modal. */
198
+ function FolderDialog({ request, onClose }) {
199
+ const text = copy()
200
+ const workspace = typeof request.path === 'string' ? request.path : ''
201
+ const title = typeof request.title === 'string' && request.title !== '' ? request.title : workspace
202
+ const [folders, setFolders] = React.useState([])
203
+ const [missing, setMissing] = React.useState([])
204
+ const [busy, setBusy] = React.useState(false)
205
+ const [error, setError] = React.useState('')
206
+
207
+ const load = React.useCallback(async () => {
208
+ const data = await callRoute('list', { workspace })
209
+ setFolders(Array.isArray(data?.folders) ? data.folders : [])
210
+ setMissing(Array.isArray(data?.missing) ? data.missing : [])
211
+ }, [workspace])
212
+
213
+ React.useEffect(() => {
214
+ load().catch((failure) => setError(describe(failure)))
215
+ }, [load])
216
+
217
+ const add = async () => {
218
+ setError('')
219
+ const ui = pluginContext?.get?.('uiWorkspace')
220
+ if (typeof ui?.pickDirectory !== 'function') {
221
+ setError(text.pickerUnavailable)
222
+ return
223
+ }
224
+ let picked
225
+ try {
226
+ picked = await ui.pickDirectory()
227
+ } catch (failure) {
228
+ setError(describe(failure))
229
+ return
230
+ }
231
+ if (typeof picked !== 'string' || picked === '') return
232
+ setBusy(true)
233
+ try {
234
+ await callRoute('add', { workspace, path: picked })
235
+ await load()
236
+ } catch (failure) {
237
+ setError(describe(failure))
238
+ } finally {
239
+ setBusy(false)
240
+ }
241
+ }
242
+
243
+ const remove = async (folder) => {
244
+ setError('')
245
+ setBusy(true)
246
+ try {
247
+ await callRoute('remove', { workspace, path: folder })
248
+ await load()
249
+ } catch (failure) {
250
+ setError(describe(failure))
251
+ } finally {
252
+ setBusy(false)
253
+ }
254
+ }
255
+
256
+ /** The workspace itself always comes first and cannot be removed. */
257
+ const rows = [
258
+ { path: workspace, removable: false },
259
+ ...folders.map((folder) => ({ path: folder, removable: true }))
260
+ ]
261
+
262
+ const body = h('div', { style: styles.list },
263
+ rows.map((row) => h('div', { key: row.path, style: styles.row }, [
264
+ h('span', { key: 'path', style: styles.rowPath, title: row.path }, row.path),
265
+ row.removable ? null : h('span', { key: 'tag', style: styles.tag }, text.root),
266
+ row.removable && missing.includes(row.path) ? h('span', { key: 'missing', style: styles.missing }, text.missing) : null,
267
+ row.removable
268
+ ? h('button', {
269
+ key: 'remove',
270
+ type: 'button',
271
+ style: styles.remove,
272
+ disabled: busy,
273
+ onClick: () => {
274
+ void remove(row.path)
275
+ }
276
+ }, text.remove)
277
+ : null
278
+ ])))
279
+
280
+ const Modal = primitives?.Modal
281
+ const Button = primitives?.Button
282
+ /** forwardRef and memo components are objects, so never test `typeof x === 'function'` here. */
283
+ const official = Modal !== undefined && Modal !== null && Button !== undefined && Button !== null
284
+ const addRow = h('button', {
285
+ key: 'add',
286
+ type: 'button',
287
+ style: { ...styles.addRow, opacity: busy ? 0.6 : 1 },
288
+ disabled: busy,
289
+ onClick: () => {
290
+ void add()
291
+ }
292
+ }, [
293
+ h('svg', { key: 'plus', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', 'aria-hidden': true }, [
294
+ h('path', { key: 'h', d: 'M8 3.5v9' }),
295
+ h('path', { key: 'v', d: 'M3.5 8h9' })
296
+ ]),
297
+ h('span', { key: 'label' }, text.add)
298
+ ])
299
+
300
+ if (official) {
301
+ return h(Modal, {
302
+ open: true,
303
+ onClose,
304
+ closeLabel: text.close,
305
+ title: text.title,
306
+ className: DIALOG_CLASS,
307
+ // Both actions stay right-aligned inside the Modal's own footer container,
308
+ // the way the rename and delete dialogs place theirs.
309
+ footer: h(React.Fragment, null,
310
+ h(Button, { key: 'close', variant: 'outline', onClick: onClose }, text.close),
311
+ h(Button, { key: 'add', variant: 'primary', disabled: busy, onClick: () => { void add() } }, text.add)),
312
+ children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '16px' } }, [
313
+ body,
314
+ error === '' ? null : h('div', { key: 'error', role: 'alert', style: styles.error }, error)
315
+ ])
316
+ })
317
+ }
318
+
319
+ // Older deployments without the primitives seed keep a usable panel.
320
+ return h('div', {
321
+ role: 'dialog',
322
+ 'aria-modal': 'true',
323
+ 'aria-label': text.title,
324
+ style: {
325
+ position: 'fixed',
326
+ inset: '0',
327
+ display: 'flex',
328
+ alignItems: 'center',
329
+ justifyContent: 'center',
330
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-base) 55%, transparent)',
331
+ zIndex: 60
332
+ }
333
+ }, h('div', {
334
+ style: {
335
+ width: 'min(520px, calc(100vw - 32px))',
336
+ display: 'flex',
337
+ flexDirection: 'column',
338
+ gap: '12px',
339
+ padding: '20px',
340
+ borderRadius: '16px',
341
+ background: 'var(--dsw-alias-bg-overlay)',
342
+ border: '1px solid var(--dsw-alias-border-l1)',
343
+ color: 'var(--dsw-alias-label-primary)'
344
+ }
345
+ }, [
346
+ body,
347
+ error === '' ? null : h('div', { key: 'error', style: styles.error }, error),
348
+ addRow,
349
+ h('button', {
350
+ key: 'close',
351
+ type: 'button',
352
+ onClick: onClose,
353
+ style: { alignSelf: 'flex-end', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderRadius: '10px', padding: '6px 14px', cursor: 'pointer', font: 'inherit' }
354
+ }, text.close)
355
+ ]))
356
+ }
357
+
358
+ /** A broken dialog must not take the overlay layer with it. */
359
+ class OverlayBoundary extends React.Component {
360
+ constructor(props) {
361
+ super(props)
362
+ this.state = { failed: false }
363
+ }
364
+
365
+ static getDerivedStateFromError() {
366
+ return { failed: true }
367
+ }
368
+
369
+ componentDidCatch(error) {
370
+ console.error('dsh-extra-folders: dialog failed', error)
371
+ }
372
+
373
+ render() {
374
+ return this.state.failed ? null : this.props.children
375
+ }
376
+ }
377
+
378
+ function OverlayHost() {
379
+ const request = useRequest()
380
+ if (request === null) return null
381
+ return h(OverlayBoundary, null, h(FolderDialog, {
382
+ key: request.path,
383
+ request,
384
+ onClose: () => publish(null)
385
+ }))
386
+ }
387
+
388
+ /**
389
+ * The workspace row's own more menu: the action list this plugin contributes.
390
+ * The menu owns rendering, so an action needs a label, an optional icon key,
391
+ * and what to run.
392
+ */
393
+ function registerWorkspaceAction(ctx) {
394
+ ctx.inject(['contextMenus'], (scope) => {
395
+ scope.effect(() => scope.contextMenus.register({
396
+ id: 'workspace-extra-folders.attach',
397
+ target: 'workspace',
398
+ order: MENU_ORDER,
399
+ observe: () => {
400
+ const locale = ctx.get('locale')
401
+ return locale !== undefined && typeof locale.subscribe === 'function' ? [locale] : []
402
+ },
403
+ resolve: (target) => [{
404
+ id: 'attach-folders',
405
+ label: copy().menu,
406
+ icon: { key: ICON_KEY },
407
+ run: () => {
408
+ publish({ path: target?.path, title: target?.displayTitle })
409
+ }
410
+ }]
411
+ }), 'dsh-extra-folders: workspace menu action')
412
+ })
413
+ }
414
+
415
+ return {
416
+ inject: ['slots'],
417
+ apply(ctx) {
418
+ pluginContext = ctx
419
+ try {
420
+ ctx.effect(() => {
421
+ const style = document.createElement('style')
422
+ style.dataset.plugin = 'workspace-extra-folders'
423
+ style.textContent = `.${DIALOG_CLASS}{width:max-content;min-width:min(380px,calc(100vw - 32px));max-width:min(680px,calc(100vw - 32px))}`
424
+ document.head.append(style)
425
+ return () => style.remove()
426
+ }, 'dsh-extra-folders: dialog width')
427
+
428
+ ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({
429
+ name: 'contextMenu.icon',
430
+ key: ICON_KEY
431
+ }, FolderGlyph))
432
+
433
+ registerWorkspaceAction(ctx)
434
+
435
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
436
+ name: 'shell.overlay',
437
+ id: 'workspace-extra-folders.dialog',
438
+ order: 20
439
+ }, OverlayHost))
440
+ } catch (error) {
441
+ // A deployment without this plugin's slots must keep its boot.
442
+ console.error('dsh-extra-folders: client half could not install', error)
443
+ }
444
+ }
445
+ }
446
+ }
447
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: extra-folders
3
+ name: dsh-extra-folders
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Workspace extra folders">
2
+ <rect width="64" height="64" rx="14" fill="#1f6feb"/>
3
+ <path d="M14 22.5c0-1.38 1.12-2.5 2.5-2.5h10l4 4h17c1.38 0 2.5 1.12 2.5 2.5v18c0 1.38-1.12 2.5-2.5 2.5h-31A2.5 2.5 0 0 1 14 44.5v-22Z" fill="#ffffff" fill-opacity="0.92"/>
4
+ <path d="M32 31v10M27 36h10" stroke="#1f6feb" stroke-width="3.2" stroke-linecap="round"/>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Workspace extra folders — Host half.
3
+ *
4
+ * Attach additional writable directories to ONE workspace. The list is durable
5
+ * per canonical workspace path, is managed only from the Web UI (the model has
6
+ * no write path), and is granted per capability call by wrapping
7
+ * `ctx.sandboxPolicy.resolve()`: the resolved policy carries the calling
8
+ * Session's workspace folders in `additionalWritableRoots`, which the shared
9
+ * `writableRoots()` derivation in `@deepseek-ai/dsh-sandbox` turns into the
10
+ * allow-list used by BOTH the filesystem fence and the Seatbelt/bwrap/Landlock
11
+ * profiles. Sessions in other workspaces never see them.
12
+ *
13
+ * The UI talks to this half over a loopback route fenced by
14
+ * `ctx.connection.requestRejection()`, the same authenticated-peer check the
15
+ * `/api` routes use: a local process without a live browser Peer is refused.
16
+ *
17
+ * Failures travel back as stable CODES, never as English sentences: the Client
18
+ * half owns every user-visible sentence, so one workspace's error text cannot
19
+ * arrive in the wrong language.
20
+ *
21
+ * The plugin contributes NO prompt text of its own. DSH's sandbox-policy note
22
+ * already renders `Additional writable directories: [...]` from the resolved
23
+ * policy, so a second note would only duplicate tokens. The policy note is
24
+ * projected as a tail entry of the session history and de-duplicated while the
25
+ * list holds, so adding or removing a folder changes the tail only. It never
26
+ * rewrites the system prompt or an earlier prefix, which keeps the provider's
27
+ * prefix cache intact.
28
+ *
29
+ * @module dsh-extra-folders
30
+ */
31
+
32
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
33
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
34
+ import { homedir } from 'node:os'
35
+ import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path'
36
+
37
+ /** Cordis plugin name. */
38
+ export const name = 'workspace-extra-folders'
39
+
40
+ /** Loopback JSON route the Client half calls. */
41
+ const ROUTE = '/extra-folders'
42
+ /** Durable state format. */
43
+ const STATE_VERSION = 1
44
+ /** Bound on a request body; the route only carries two paths. */
45
+ const MAX_BODY_BYTES = 64 * 1024
46
+
47
+ /** The Harness home that owns plugin state. */
48
+ function dshHome() {
49
+ const configured = process.env.DSH_HOME
50
+ return typeof configured === 'string' && configured !== '' ? configured : join(homedir(), '.dsh')
51
+ }
52
+
53
+ /** An error carrying a code the Client half translates. */
54
+ function fail(code) {
55
+ const error = new Error(code)
56
+ error.code = code
57
+ return error
58
+ }
59
+
60
+ /** Canonical identity of a path, tolerant of a path that disappeared. */
61
+ function canonical(path) {
62
+ try {
63
+ return realpathSync(path)
64
+ } catch {
65
+ return resolvePath(path)
66
+ }
67
+ }
68
+
69
+ /** Whether a path is an existing directory. */
70
+ function isDirectory(target) {
71
+ try {
72
+ return statSync(target).isDirectory()
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Validate a directory grant the way the sandbox will: it must exist, be a
80
+ * directory, and be spelled as its own physical path. `additionalRoot()` in the
81
+ * sandbox rejects symlinked grants, so accepting one here would only fail later,
82
+ * on every confined call.
83
+ * @param target - candidate directory from the folder picker.
84
+ * @returns the physical path to store.
85
+ */
86
+ function physicalDirectory(target) {
87
+ let stats
88
+ try {
89
+ stats = statSync(target)
90
+ } catch {
91
+ throw fail('missing')
92
+ }
93
+ if (!stats.isDirectory()) throw fail('notDirectory')
94
+ const physical = realpathSync(target)
95
+ if (physical !== resolvePath(target)) throw fail('symlink')
96
+ return physical
97
+ }
98
+
99
+ /**
100
+ * The durable per-workspace folder lists plus their write path.
101
+ * @param ctx - plugin context, used for diagnostics only.
102
+ */
103
+ function createStore(ctx) {
104
+ const file = join(dshHome(), 'extra-folders.json')
105
+ let workspaces = read()
106
+
107
+ function read() {
108
+ /** The pre-rename state file, read when the current one does not exist. */
109
+ const legacyFile = join(dshHome(), 'workspace-extra-folders.json')
110
+ for (const candidate of [file, legacyFile]) {
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
113
+ const source = parsed?.version === STATE_VERSION && typeof parsed?.workspaces === 'object' && parsed.workspaces !== null
114
+ ? parsed.workspaces
115
+ : {}
116
+ const result = {}
117
+ for (const [workspace, list] of Object.entries(source)) {
118
+ if (workspace === '' || !Array.isArray(list)) continue
119
+ const folders = [...new Set(list.filter((entry) => typeof entry === 'string' && entry !== ''))]
120
+ if (folders.length > 0) result[workspace] = folders
121
+ }
122
+ return result
123
+ } catch (error) {
124
+ if (error?.code !== 'ENOENT') ctx.logger?.warn?.(`dsh-extra-folders: cannot read ${candidate}: ${String(error)}`)
125
+ }
126
+ }
127
+ return {}
128
+ }
129
+
130
+ async function persist() {
131
+ await mkdir(dirname(file), { recursive: true })
132
+ const temporary = `${file}.${process.pid}.tmp`
133
+ await writeFile(temporary, `${JSON.stringify({ version: STATE_VERSION, workspaces }, null, 2)}\n`, 'utf8')
134
+ await rename(temporary, file)
135
+ }
136
+
137
+ const foldersFor = (workspace) => (Array.isArray(workspaces[workspace]) ? [...workspaces[workspace]] : [])
138
+
139
+ return {
140
+ list: foldersFor,
141
+ existing: (workspace) => foldersFor(workspace).filter(isDirectory),
142
+ missing: (workspace) => foldersFor(workspace).filter((folder) => !isDirectory(folder)),
143
+ async add(workspace, target) {
144
+ const physical = physicalDirectory(target)
145
+ if (physical === workspace) throw fail('self')
146
+ const current = foldersFor(workspace)
147
+ if (current.includes(physical)) throw fail('duplicate')
148
+ workspaces = { ...workspaces, [workspace]: [...current, physical] }
149
+ await persist()
150
+ return physical
151
+ },
152
+ async remove(workspace, target) {
153
+ const absolute = canonical(target)
154
+ const current = foldersFor(workspace)
155
+ const index = current.indexOf(absolute) !== -1 ? current.indexOf(absolute) : current.indexOf(target)
156
+ if (index === -1) throw fail('unknown')
157
+ const next = current.filter((_, position) => position !== index)
158
+ const updated = { ...workspaces }
159
+ if (next.length === 0) delete updated[workspace]
160
+ else updated[workspace] = next
161
+ workspaces = updated
162
+ await persist()
163
+ return absolute
164
+ },
165
+ count: () => Object.keys(workspaces).length,
166
+ stateFile: file
167
+ }
168
+ }
169
+
170
+ /** One workspace's attached folders, plus the ones whose directory vanished. */
171
+ function summarize(store, workspace) {
172
+ return { workspace, folders: store.list(workspace), missing: store.missing(workspace) }
173
+ }
174
+
175
+ /**
176
+ * Extend the shared sandbox policy per call. Every enforcing family (filesystem
177
+ * fence, one-shot bash, terminal) resolves its policy through this one service,
178
+ * so this wrapper is the whole grant: folders are merged into
179
+ * `additionalWritableRoots` only for a Session whose immutable cwd is that
180
+ * workspace, and only while the folders still exist on disk.
181
+ * @param policy - the mounted `sandboxPolicy` service.
182
+ * @param existing - folders that currently exist for one canonical workspace.
183
+ * @returns a disposer restoring the original method.
184
+ */
185
+ function installGrant(policy, existing) {
186
+ if (policy === null || typeof policy !== 'object' || typeof policy.resolve !== 'function') return () => {}
187
+ const original = policy.resolve
188
+ const ownDescriptor = Object.getOwnPropertyDescriptor(policy, 'resolve')
189
+ const resolveWithFolders = function resolveWithWorkspaceFolders(request = {}) {
190
+ const resolved = original.call(this, request)
191
+ const cwd = request?.session?.header?.cwd
192
+ if (typeof cwd !== 'string' || cwd === '') return resolved
193
+ const folders = existing(canonical(cwd))
194
+ if (folders.length === 0) return resolved
195
+ const configured = resolved.additionalWritableRoots ?? []
196
+ const extras = folders.filter((folder) => !configured.includes(folder))
197
+ if (extras.length === 0) return resolved
198
+ return { ...resolved, additionalWritableRoots: Object.freeze([...configured, ...extras]) }
199
+ }
200
+ policy.resolve = resolveWithFolders
201
+ return () => {
202
+ if (policy.resolve !== resolveWithFolders) return
203
+ if (ownDescriptor !== undefined) Object.defineProperty(policy, 'resolve', ownDescriptor)
204
+ else delete policy.resolve
205
+ }
206
+ }
207
+
208
+ /** Whether a path is a workspace the sidebar lists (tolerant of a missing registry). */
209
+ function isRegisteredWorkspace(ctx, workspace) {
210
+ const registry = ctx.get('workspaceRegistry')
211
+ if (registry === undefined || typeof registry.list !== 'function') return true
212
+ try {
213
+ const wanted = canonical(workspace)
214
+ return registry.list().some((entry) => canonical(entry.path) === wanted)
215
+ } catch {
216
+ return true
217
+ }
218
+ }
219
+
220
+ /** Read one bounded JSON body. */
221
+ function readJsonBody(request) {
222
+ return new Promise((resolve, reject) => {
223
+ const chunks = []
224
+ let size = 0
225
+ request.on('data', (chunk) => {
226
+ size += chunk.length
227
+ if (size > MAX_BODY_BYTES) {
228
+ reject(fail('tooLarge'))
229
+ request.destroy()
230
+ return
231
+ }
232
+ chunks.push(chunk)
233
+ })
234
+ request.on('end', () => {
235
+ const text = Buffer.concat(chunks).toString('utf8').trim()
236
+ if (text === '') {
237
+ resolve({})
238
+ return
239
+ }
240
+ try {
241
+ resolve(JSON.parse(text))
242
+ } catch {
243
+ reject(fail('badBody'))
244
+ }
245
+ })
246
+ request.on('error', reject)
247
+ })
248
+ }
249
+
250
+ /** Write one JSON response. */
251
+ function sendJson(response, status, value) {
252
+ const body = JSON.stringify(value)
253
+ response.writeHead(status, {
254
+ 'content-type': 'application/json; charset=utf-8',
255
+ 'content-length': Buffer.byteLength(body),
256
+ 'cache-control': 'no-store'
257
+ })
258
+ response.end(body)
259
+ }
260
+
261
+ /**
262
+ * Register every Host contribution.
263
+ * @param ctx - Host plugin context.
264
+ */
265
+ export function apply(ctx) {
266
+ const store = createStore(ctx)
267
+
268
+ // 0.2.2-alpha.2 added `additionalWritableRoots`; earlier lines cannot honor an extra
269
+ // root at all. Say so instead of installing a menu that grants nothing.
270
+ const policy = ctx.get('sandboxPolicy')
271
+ if (policy !== undefined && !('additionalWritableRoots' in policy)) {
272
+ ctx.logger?.error?.(
273
+ 'dsh-extra-folders: this DSH build has no additionalWritableRoots on its sandbox policy, '
274
+ + 'so extra folders cannot be granted. Requires DSH >= 0.2.2-alpha.3.'
275
+ )
276
+ return
277
+ }
278
+
279
+ ctx.inject(['sandboxPolicy'], (scope) => {
280
+ scope.effect(() => installGrant(scope.sandboxPolicy, (workspace) => store.existing(workspace)), 'dsh-extra-folders: sandbox grant')
281
+ })
282
+
283
+ ctx.inject(['webServer'], (scope) => {
284
+ scope.effect(() => scope.webServer.register({
285
+ kind: 'prefix',
286
+ path: ROUTE,
287
+ handler: (request, response) => {
288
+ void (async () => {
289
+ const connection = ctx.get('connection')
290
+ if (connection !== undefined && typeof connection.admit === 'function') {
291
+ const admission = connection.admit(request)
292
+ if (admission !== null && typeof admission === 'object' && 'rejection' in admission) {
293
+ response.writeHead(admission.rejection)
294
+ response.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
295
+ return
296
+ }
297
+ }
298
+ if (request.method !== 'POST') {
299
+ sendJson(response, 405, { error: 'unknown' })
300
+ return
301
+ }
302
+ let payload
303
+ try {
304
+ payload = await readJsonBody(request)
305
+ } catch (error) {
306
+ sendJson(response, 400, { error: error?.code ?? 'unknown' })
307
+ return
308
+ }
309
+ const endpoint = new URL(request.url ?? '/', 'http://127.0.0.1').pathname.slice(ROUTE.length + 1)
310
+ const workspaceInput = typeof payload?.workspace === 'string' ? payload.workspace : ''
311
+ if (workspaceInput === '' || !isAbsolute(workspaceInput)) {
312
+ sendJson(response, 400, { error: 'notWorkspace' })
313
+ return
314
+ }
315
+ const workspace = canonical(workspaceInput)
316
+ if (!isDirectory(workspace) || !isRegisteredWorkspace(ctx, workspace)) {
317
+ sendJson(response, 409, { error: 'notWorkspace' })
318
+ return
319
+ }
320
+ try {
321
+ if (endpoint === 'list') {
322
+ sendJson(response, 200, summarize(store, workspace))
323
+ return
324
+ }
325
+ if (endpoint !== 'add' && endpoint !== 'remove') {
326
+ sendJson(response, 404, { error: 'unknown' })
327
+ return
328
+ }
329
+ const target = typeof payload?.path === 'string' ? payload.path.trim() : ''
330
+ if (target === '') {
331
+ sendJson(response, 400, { error: 'missing' })
332
+ return
333
+ }
334
+ if (endpoint === 'add') {
335
+ const added = await store.add(workspace, target)
336
+ sendJson(response, 200, { ...summarize(store, workspace), added })
337
+ return
338
+ }
339
+ const removed = await store.remove(workspace, target)
340
+ sendJson(response, 200, { ...summarize(store, workspace), removed })
341
+ } catch (error) {
342
+ sendJson(response, 400, { error: error?.code ?? 'unknown' })
343
+ }
344
+ })()
345
+ }
346
+ }), 'dsh-extra-folders: loopback route')
347
+ })
348
+
349
+ ctx.logger?.info?.(`dsh-extra-folders: ${store.count()} workspace(s) carry attached folders (${store.stateFile})`)
350
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Workspace folders",
4
+ "description": "A workspace has one writable directory by default, so writing anywhere else asks for approval again and again. This plugin adds more directories from the workspace row's menu. Attached directories are writable for that workspace's sessions by both the file tools and the shell. Sessions in other workspaces don't get that grant."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "工作区多文件夹",
4
+ "description": "工作区默认只有一个可写目录,写到别处要一次次点授权。这个插件让你在工作区行的菜单里多加几个目录,加进来的目录对这个工作区的会话可写,文件工具和 bash 都认。别的工作区的会话没有这份授权。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "dsh-extra-folders",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Attach extra writable folders to one workspace, granted per session, not profile-wide.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "workspace",
10
+ "multi-root",
11
+ "folders",
12
+ "writable",
13
+ "sandbox",
14
+ "permissions"
15
+ ],
16
+ "author": "kingOfSoySauce",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/kingOfSoySauce/dsh-extra-folders.git"
21
+ },
22
+ "homepage": "https://github.com/kingOfSoySauce/dsh-extra-folders#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/kingOfSoySauce/dsh-extra-folders/issues"
25
+ },
26
+ "type": "module",
27
+ "main": "index.js",
28
+ "exports": {
29
+ ".": "./index.js",
30
+ "./client": "./client.js",
31
+ "./locale/*.json": "./locale/*.json",
32
+ "./icon": "./icon.svg"
33
+ },
34
+ "files": [
35
+ "index.js",
36
+ "client.js",
37
+ "cordis.patch.yml",
38
+ "locale/*.json",
39
+ "icon.svg",
40
+ "assets/menu.png",
41
+ "assets/dialog.png",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "dsh": ">=0.2.2-alpha.3 <0.3"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "immediately": true
55
+ }
56
+ }
57
+ }