dsh-session-ops 0.0.0-stage → 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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 会话存储的**路径命名**知识:项目目录键(`--C-proj--`)、会话目录、generation 文件名。
3
+ *
4
+ * 这些命名事实原本分散在两个插件里,而且是同一份知识的**两个方向**:
5
+ * - `dsh-session-move` 需要**正向**编码(`projectKey` / `encodeSegment`)——搬迁时
6
+ * 要把会话日志放到目标 cwd 对应的 project 目录;
7
+ * - `dsh-session-admin` 需要**反向**还原(`decodeProjectKey`)——删除面板要给出
8
+ * 「这条会话大概是哪个目录的」,而头部读不出来时只有目录键可猜。
9
+ * 两份实现各自维护同一套规则,改动任何一边都要记得另一边——这就是本模块存在的理由。
10
+ *
11
+ * 规则(与 `dsh-session-persistence-jsonl` 的 `format.js` 逐字一致):
12
+ * - 路径里每个非 `[A-Za-z0-9._-]` 的码位写成 `~XXXX`(大写十六进制、四位);
13
+ * - `/` `\` `:` 三种分隔符**折叠成 `-`**(盘符的冒号也因此变成 `-`);
14
+ * - 目录键形如 `--<readable>--`,`readable` 截断到 251 字符;
15
+ * - 会话目录 = `<root>/<projectKey(cwd)>/<encodeSegment(id)>`。
16
+ *
17
+ * @module dsh-session-ops/store/paths
18
+ */
19
+
20
+ import { readdirSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ /** generation 规范名:`session.jsonl[.zstd]` 或 `session.vN.jsonl[.zstd]`。 */
24
+ const GENERATION_NAME = /^session(?:\.v(\d+))?\.jsonl(\.zstd)?$/
25
+
26
+ /**
27
+ * 把任意字符串编码成一个安全路径段(与会话 id 共用同一套转义)。
28
+ * @param raw - 原始字符串。
29
+ * @returns 单个文件系统安全路径段。
30
+ */
31
+ export function encodeSegment(raw) {
32
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment')
33
+ if (raw === '.') return '~002E'
34
+ if (raw === '..') return '~002E~002E'
35
+ let out = ''
36
+ for (let i = 0; i < raw.length; i++) {
37
+ const code = raw.charCodeAt(i)
38
+ const ch = String.fromCharCode(code)
39
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
40
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * 项目目录键:分隔符折叠成 `-`,不安全码位 `~XXXX`,截断到 251 字符。
47
+ * @param cwd - 会话的项目目录。
48
+ * @returns 单个文件系统安全的项目目录名。
49
+ */
50
+ export function projectKey(cwd) {
51
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path')
52
+ let readable = ''
53
+ let separatorRun = false
54
+ for (let i = 0; i < cwd.length; i++) {
55
+ const code = cwd.charCodeAt(i)
56
+ const ch = String.fromCharCode(code)
57
+ if (ch === '/' || ch === '\\' || ch === ':') {
58
+ if (!separatorRun) readable += '-'
59
+ separatorRun = true
60
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
61
+ readable += ch
62
+ separatorRun = false
63
+ } else {
64
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
65
+ separatorRun = false
66
+ }
67
+ }
68
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
69
+ }
70
+
71
+ /**
72
+ * 把 `sessions/` 下的项目目录键还原成路径。
73
+ *
74
+ * 只做「够用」的反解:分隔符在磁盘上已经全部退化成 `-`,无法区分 `/` 与 `\`
75
+ * (这里一律还原成 `\`),盘符的冒号也没有单独转义(`C:\a` → `--C-a--`,这里
76
+ * 按 `^[A-Za-z]\\` 补回来)。**真正的 cwd 以会话头部里的记录为准**,这个只作为兜底。
77
+ * @param key - 项目目录名。
78
+ * @returns 反解出的路径。
79
+ */
80
+ export function decodeProjectKey(key) {
81
+ let s = key
82
+ if (s.startsWith('--')) s = s.slice(2)
83
+ if (s.endsWith('--')) s = s.slice(0, -2)
84
+ s = s.replace(/~([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
85
+ s = s.replace(/-/g, '\\')
86
+ // 盘符的冒号在目录键里没有单独转义(`C:\a` → `--C-a--`),补回来。
87
+ return /^[A-Za-z]\\/.test(s) ? `${s[0]}:${s.slice(1)}` : s
88
+ }
89
+
90
+ /**
91
+ * **宽松**的会话日志文件名判定:`.jsonl.zstd` 或 `.jsonl` 结尾就算。
92
+ *
93
+ * 与 {@link parseGenerationName}(**规范** generation 名)是两件事,别互相替换:
94
+ * 删除路径要能看见并清掉任何形状的日志文件(哪怕是手工改名或半截写入的),
95
+ * 而搬迁路径必须只认规范名,否则会把临时文件当成真日志搬走。
96
+ * @param name - 目录项名。
97
+ * @returns 是否像一份会话日志。
98
+ */
99
+ export function isSessionLogFile(name) {
100
+ return name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')
101
+ }
102
+
103
+ /**
104
+ * 目录里是不是规范 generation 名。
105
+ * @param name - 目录项名。
106
+ * @returns 版本号(v0 为 0)与是否压缩;不是规范名时 `undefined`。
107
+ */
108
+ export function parseGenerationName(name) {
109
+ const match = GENERATION_NAME.exec(name)
110
+ if (match === null) return undefined
111
+ return { version: match[1] === undefined ? 0 : Number(match[1]), compressed: match[2] === '.zstd' }
112
+ }
113
+
114
+ /**
115
+ * 选出会话目录里版本最高的规范 generation 文件。
116
+ *
117
+ * 临时名与备份名(`*.session-move-tmp`、`*.session-move-old`)不匹配规范名,
118
+ * 一律进 `others`——搬迁必须保证「任何时刻都不存在两份规范名日志」。
119
+ * @param dir - 会话目录。
120
+ * @returns `{selected, all, others}`;列不了目录或没有规范名时 `{error, others?}`
121
+ * (错误文案被两个插件的用户可见报告引用,改动前先看测试)。
122
+ */
123
+ export function findGeneration(dir) {
124
+ let entries
125
+ try {
126
+ entries = readdirSync(dir, { withFileTypes: true })
127
+ } catch (error) {
128
+ return { error: `cannot list session directory ${dir}: ${String(error?.message ?? error)}` }
129
+ }
130
+ const generations = []
131
+ const others = []
132
+ for (const entry of entries) {
133
+ const parsed = parseGenerationName(entry.name)
134
+ if (parsed === undefined) {
135
+ others.push(entry.name)
136
+ continue
137
+ }
138
+ generations.push({ name: entry.name, version: parsed.version, compressed: parsed.compressed })
139
+ }
140
+ if (generations.length === 0) return { error: `no canonical session log in ${dir}`, others }
141
+ generations.sort((left, right) => right.version - left.version)
142
+ return { selected: generations[0], all: generations, others }
143
+ }
144
+
145
+ /** 物理编码后缀。 */
146
+ export function compressionSuffix(compression) {
147
+ return compression === 'zstd' ? '.zstd' : ''
148
+ }
149
+
150
+ /** 一个 generation 的规范文件名(v0 不带 `vN` 段)。 */
151
+ export function generationLogFilename(version, compression) {
152
+ return `${version === 0 ? 'session' : `session.v${version}`}.jsonl${compressionSuffix(compression)}`
153
+ }
154
+
155
+ /** 会话目录:`<root>/<projectKey>/<encodeSegment(id)>`。 */
156
+ export function sessionDir(root, cwd, id) {
157
+ return join(join(root, cwd === undefined ? '_no-cwd' : projectKey(cwd)), encodeSegment(id))
158
+ }
159
+
160
+ /** 一个 generation 的绝对日志路径。 */
161
+ export function logPath(root, cwd, id, version, compression) {
162
+ return join(sessionDir(root, cwd, id), generationLogFilename(version, compression))
163
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * 会话**归属**知识:工作区注册表(`ctx.workspaceRegistry`)、驻留会话、以及「正在跑一轮」。
3
+ *
4
+ * 这份知识在两个插件里各有一半,写在一处才不会走形:
5
+ * - `dsh-session-admin` 要判断「这条会话属于哪个工作区 / 归档没有」才能决定能不能删,
6
+ * 并在删文件**之前**把它从登记表里摘掉;
7
+ * - `dsh-session-move` 要判断「属于谁 / 目标工作区是哪一个 / 正在跑没有」才能搬,
8
+ * 并在搬完之后 attach 目标、detach 原属、把内存里缓存的 cwd 一起改掉。
9
+ *
10
+ * ## 三个容易踩的坑(都是实测结论,不是推测)
11
+ *
12
+ * 1. **`workspace.sessionIds` 是「按会话头索引过滤过的视图」**,不是完整记账:
13
+ * DSH 每次读取都用 `realpath(header.cwd) === workspace.path` 过滤一遍。完整记账在
14
+ * `workspace.record.sessionIds`(见 {@link accountedSessionIds})。
15
+ * 会话目录一删,`sessionIds` 就再也查不到这条会话——所以「摘登记表」必须早于「删文件」。
16
+ * 2. **「挂着」≠「正在跑」**:会话在创建 agent 时被 enter,只在 agent dispose 时才 detach,
17
+ * 长期运行的 `dsh web` 里只要碰过就基本一直挂着。删/搬的守卫只认「正在跑一轮」。
18
+ * 3. **`workspace.json` 里的工作区对象自身不带 id**,id 只存在于外层键上;从磁盘读表时
19
+ * 必须重新组装视图,否则所有守卫会因为 `owner.workspaceId === undefined` 静默失效。
20
+ *
21
+ * 出错时的策略刻意**不统一**,两边各自保留(见各函数的文档):
22
+ * 拿不到 `agents` 服务时 admin 保守地把全部驻留会话当成「在跑」(宁可拒绝),
23
+ * move 则把状态当成未知(`undefined`,界面照常显示)。
24
+ *
25
+ * @module dsh-session-ops/store/registry
26
+ */
27
+
28
+ /** agent 表示「这一轮还在跑」的状态值。 */
29
+ export const SESSION_RUNNING_STATUS = 'running'
30
+
31
+ /**
32
+ * Host 进程里仍挂着的会话 id 集合(`ctx.sessions.list()`)。
33
+ *
34
+ * 服务缺失或抛错时退化成空集合——**这个集合只用于界面提示,不要拿它当删除守卫**
35
+ * (理由见模块注释第 2 条)。
36
+ * @param sessions - `ctx.get('sessions')` 拿到的会话服务(可为 undefined)。
37
+ * @returns `Set<string>`。
38
+ */
39
+ export function residentSessionIds(sessions) {
40
+ const ids = new Set()
41
+ try {
42
+ const list = typeof sessions?.list === 'function' ? sessions.list() : []
43
+ for (const session of list) ids.add(String(session.id))
44
+ } catch {
45
+ /* 没有会话存储时视为无驻留 */
46
+ }
47
+ return ids
48
+ }
49
+
50
+ /**
51
+ * 一条会话的 agent 状态(`ctx.get('agents').get(id)?.status`)。
52
+ * @param agents - `ctx.get('agents')` 拿到的 agent 服务(可为 undefined)。
53
+ * @param sessionId - 会话 id。
54
+ * @returns 状态字符串;没有 agent、拿不到服务或抛错时 `undefined`(**未知**)。
55
+ */
56
+ export function agentSessionStatus(agents, sessionId) {
57
+ try {
58
+ const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined
59
+ return agent !== undefined && typeof agent.status === 'string' ? agent.status : undefined
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 真正「正在跑一轮」的会话:驻留集合里 agent 状态为 {@link SESSION_RUNNING_STATUS} 的那部分。
67
+ *
68
+ * 拿不到 agents 服务时**保守退化**为「全部驻留会话都算在跑」——宁可拒绝,不可误删/误搬。
69
+ * 单条查询抛错也算在跑(同一条原则)。
70
+ * @param residents - {@link residentSessionIds} 的结果。
71
+ * @param agents - `ctx.get('agents')`。
72
+ * @returns `Set<string>`(可能就是 `residents` 本身)。
73
+ */
74
+ export function runningSessionIds(residents, agents) {
75
+ if (agents === undefined || typeof agents.get !== 'function') return residents
76
+ const running = new Set()
77
+ for (const id of residents) {
78
+ try {
79
+ if (agents.get(id)?.status === SESSION_RUNNING_STATUS) running.add(id)
80
+ } catch {
81
+ running.add(id)
82
+ }
83
+ }
84
+ return running
85
+ }
86
+
87
+ /**
88
+ * 工作区的**完整记账**成员表(`workspace.record.sessionIds`)。
89
+ * @param workspace - 工作区实体。
90
+ * @returns id 数组;实体没有 record 视图时 `undefined`。
91
+ */
92
+ export function accountedSessionIds(workspace) {
93
+ const record = workspace?.record
94
+ return record !== undefined && Array.isArray(record.sessionIds) ? record.sessionIds : undefined
95
+ }
96
+
97
+ /**
98
+ * 归档集合(`registry.archivedSessionIds`)。
99
+ * @param registry - `ctx.workspaceRegistry`。
100
+ * @returns `Set<string>`(id 归一成字符串)。
101
+ */
102
+ export function archivedSessionIds(registry) {
103
+ return new Set(Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : [])
104
+ }
105
+
106
+ /**
107
+ * 按**完整记账**找出会话所属的工作区。
108
+ * @param workspaces - 工作区实体数组(通常是 `registry.list()`)。
109
+ * @param sessionId - 会话 id。
110
+ * @returns 工作区实体,或 `undefined`。
111
+ */
112
+ export function workspaceOwningSession(workspaces, sessionId) {
113
+ for (const workspace of workspaces) {
114
+ const ids = accountedSessionIds(workspace)
115
+ if (ids !== undefined && ids.includes(sessionId)) return workspace
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ /**
121
+ * 按 id → path → title 的顺序找一个工作区(后两者不区分大小写)。
122
+ * @param workspaces - 工作区实体数组(通常是 `registry.list()`)。
123
+ * @param key - id、绝对路径或标题;空值一律不匹配。
124
+ * @returns 工作区实体,或 `undefined`。
125
+ */
126
+ export function findWorkspace(workspaces, key) {
127
+ if (typeof key !== 'string' || key.length === 0) return undefined
128
+ for (const workspace of workspaces) if (String(workspace.id) === key) return workspace
129
+ const lowered = key.toLowerCase()
130
+ for (const workspace of workspaces) if (String(workspace.path).toLowerCase() === lowered) return workspace
131
+ for (const workspace of workspaces) if (String(workspace.title).toLowerCase() === lowered) return workspace
132
+ return undefined
133
+ }
134
+
135
+ /**
136
+ * 把一个即将被删除的会话从它登记过的每个持久结构里摘掉。
137
+ *
138
+ * 走 `workspaceRegistry`(宿主的权威登记表)而不是直接改 `storages/workspace.json`:
139
+ * 宿主自己也把这份表放在内存里,手改文件要么被下一次写入覆盖,要么让内存与磁盘脱节。
140
+ *
141
+ * 覆盖三处:工作区成员表、归档集合、置顶集合。后两个是幂等的(不在集合里就不写盘),
142
+ * 所以无条件调用它们不会产生多余的写入。
143
+ *
144
+ * **必须在删文件之前调用**:`Workspace.sessionIds` 是按会话头索引过滤过的视图,
145
+ * 目录一删,成员关系就再也查不到了。
146
+ * @param registry - `ctx.workspaceRegistry`。
147
+ * @param sessionId - 即将删除的会话 id。
148
+ * @returns 全部持久写入完成之后 resolve;值是真正被 detach 的工作区 id 列表。
149
+ */
150
+ export async function releaseSessionEverywhere(registry, sessionId) {
151
+ const detached = []
152
+ for (const workspace of registry.list()) {
153
+ if (!workspace.sessionIds.includes(sessionId)) continue
154
+ await workspace.detachSession(sessionId)
155
+ detached.push(String(workspace.id))
156
+ }
157
+ await registry.unarchiveSession(sessionId)
158
+ await registry.unpinSession(sessionId)
159
+ return detached
160
+ }
161
+
162
+ /**
163
+ * 持久化后端的**写入句柄表**(`persistence.tracker.writers`)。
164
+ *
165
+ * 有句柄 = 这条会话随时可能落盘。搬迁必须抢在它下一次 append 之前把 header 改掉。
166
+ * @param persistence - `ctx.sessionPersistence`(可为 undefined)。
167
+ * @returns `Map<id, handle>`,或 `undefined`(后端不暴露 tracker 时)。
168
+ */
169
+ export function sessionWriteHandles(persistence) {
170
+ const tracker = persistence?.tracker
171
+ return tracker !== undefined && tracker.writers instanceof Map ? tracker.writers : undefined
172
+ }
173
+
174
+ /**
175
+ * 把「这条会话以后往哪写」在**内存里**也改成新 cwd。
176
+ *
177
+ * 搬迁只搬了磁盘上的目录,内存里还有三处记着旧 cwd,漏掉任何一处都会让下一次 append
178
+ * 写到不存在的位置(ENOENT):
179
+ * 1. 持久化写入句柄的 `header`——它每次 append 都从自己的 header 重新推 logPath
180
+ * (`appendLines → logPath(meta.cwd)`),所以改 header 就够了;改之前先 `flush()`,
181
+ * 免得把还没落盘的缓冲写到旧路径;
182
+ * 2. 驻留会话对象自身的 `header`(工具/界面从它解析 cwd);
183
+ * 3. 注册表里的头部缓存(`registry.headers`,`Map<id, header>`)。
184
+ *
185
+ * 每一步都独立尽力而为:句柄 flush 失败只记录,不阻断其余改写。
186
+ * @param options - `{writers, sessions, registry, sessionId, cwd}`;`writers` 来自
187
+ * {@link sessionWriteHandles},`sessions` 是 `ctx.get('sessions')`。
188
+ * @returns `{writeHandleRedirected, liveHeaderUpdated, registryHeaderCacheUpdated, flushError?, liveSession}`
189
+ * ——`flushError` 只在 flush 抛错时出现;`liveSession` 是驻留会话对象(没有则 undefined)。
190
+ */
191
+ export async function redirectSessionWriters(options) {
192
+ const { writers, sessions, registry, sessionId, cwd } = options
193
+ const outcome = {
194
+ writeHandleRedirected: false,
195
+ liveHeaderUpdated: false,
196
+ registryHeaderCacheUpdated: false,
197
+ liveSession: undefined,
198
+ }
199
+ const handle = writers === undefined ? undefined : writers.get(sessionId)
200
+ if (handle !== undefined && handle !== null && typeof handle.header === 'object') {
201
+ try {
202
+ await handle.flush()
203
+ } catch (error) {
204
+ outcome.flushError = String(error?.message ?? error)
205
+ }
206
+ handle.header = { ...handle.header, cwd }
207
+ outcome.writeHandleRedirected = true
208
+ }
209
+ const live = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined
210
+ outcome.liveSession = live
211
+ if (live !== undefined) {
212
+ live.header = { ...live.header, cwd }
213
+ outcome.liveHeaderUpdated = true
214
+ }
215
+ if (registry.headers instanceof Map) {
216
+ const cached = registry.headers.get(sessionId)
217
+ if (cached !== undefined) {
218
+ registry.headers.set(sessionId, { ...cached, cwd })
219
+ outcome.registryHeaderCacheUpdated = true
220
+ }
221
+ }
222
+ return outcome
223
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session operations",
4
+ "description": "Really delete persisted sessions and move them between workspaces: two right-pane tabs, batch filters, running guards, context-menu entries and shared session-store primitives.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "会话运维",
4
+ "description": "真实删除与跨工作区搬迁会话:右栏两个页签、批量筛选、运行中守卫、会话右键菜单入口,以及共用的会话存储原语。",
5
+ "detail": "docs/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "dsh-session-ops",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Session operations for DeepSeek Harness: really delete persisted sessions (workspace/archive/subagent filters, running-session guards) and move sessions between workspaces (log relocation, header.cwd rewrite, workspace registry follow). Ships the shared session-store primitives (project-directory keys, jsonl.zstd frames, workspace registry, host/HTTP plumbing) as lib/store. One host entry with two HTTP prefixes and two model tools, one browser bundle with two right-pane tabs and a merged config card.",
9
+ "keywords": [
10
+ "dsh",
11
+ "dsh-plugin",
12
+ "deepseek-harness",
13
+ "cordis-plugin",
14
+ "session",
15
+ "session-delete",
16
+ "session-move",
17
+ "session-store",
18
+ "housekeeping"
19
+ ],
20
+ "license": "MIT",
21
+ "author": "Cooper Zhuang",
22
+ "type": "module",
23
+ "main": "lib/index.js",
24
+ "exports": {
25
+ ".": {
26
+ "default": "./lib/index.js"
27
+ },
28
+ "./client": {
29
+ "default": "./lib/client.js"
30
+ },
31
+ "./store": {
32
+ "default": "./lib/store/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "cordis.patch.yml",
39
+ "README.md",
40
+ "README.zh.md",
41
+ "LICENSE",
42
+ "icon.svg",
43
+ "locale",
44
+ "docs"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --test"
48
+ },
49
+ "engines": {
50
+ "node": ">=24",
51
+ "dsh": "^0.2.1-rc.2 || ^0.2.2-alpha.2"
52
+ },
53
+ "dependencies": {
54
+ "@deepseek-ai/schemastery": "^3.18.5 || ^3.18.6-alpha.1"
55
+ },
56
+ "peerDependencies": {
57
+ "@deepseek-ai/schemastery": "^3.18.5 || ^3.18.6-alpha.1"
58
+ },
59
+ "icon": "./icon.svg",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git",
63
+ "directory": "packages/dsh-session-ops"
64
+ },
65
+ "homepage": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/tree/main/packages/dsh-session-ops#readme",
66
+ "bugs": {
67
+ "url": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/issues"
68
+ },
69
+ "dsh": {
70
+ "manifestVersion": 1,
71
+ "bundle": {
72
+ "patch": "./cordis.patch.yml"
73
+ },
74
+ "client": {
75
+ "inject": [
76
+ "@deepseek-ai/dsh-client-ui-context-menu"
77
+ ],
78
+ "platform": "web"
79
+ }
80
+ }
81
+ }