dsh-workspace-plus 0.2.0 → 0.6.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/lib/index.js CHANGED
@@ -1,707 +1,790 @@
1
- /**
2
- * dsh-workspace-plus — host half.
3
- *
4
- * Feature 2: a DSH workspace keeps ONE directory (`SessionHeader.cwd`), but a
5
- * real project often spans several roots (a frontend repo, a backend repo, a
6
- * docs folder). This half lets one workspace carry N extra directories, each
7
- * with a short **label**, and makes those labels usable in conversation:
8
- *
9
- * 1. a durable, plugin-owned store: workspace path -> [{ label, path, note }]
10
- * 2. a per-request runtime-context contribution (`workspaceplus:directories`)
11
- * that renders the label table for the calling Session's workspace, so the
12
- * agent sees the mapping without spending a tool call
13
- * 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
14
- * when the user asks in natural language
15
- * 4. a small JSON HTTP route the browser half uses to manage the same store
16
- *
17
- * The whole point is routing by label instead of by guesswork: the injected
18
- * table gives absolute paths, so a similar-looking directory name elsewhere on
19
- * disk cannot capture the edit.
20
- *
21
- * The module deliberately imports nothing but Node builtins: an externally
22
- * installed bundle cannot rely on resolving `@deepseek-ai/*` from its own
23
- * location, and the two contracts it needs (`systemPrompt.context({...})` and
24
- * the registry-ready tool definition shape) are plain data.
25
- *
26
- * @module dsh-workspace-plus
27
- */
28
-
29
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
30
- import { homedir } from 'node:os'
31
- import { dirname, isAbsolute, join, resolve } from 'node:path'
32
-
33
- /** Plugin name shown by the loader and in host logs. */
34
- export const name = 'dsh-workspace-plus'
35
-
36
- /** Services the two contributions require; both ship in the base composition. */
37
- export const inject = ['tools', 'systemPrompt']
38
-
39
- /** Runtime-context entry name (stable: it is also the contribution's identity). */
40
- export const CONTEXT_NAME = 'workspaceplus:directories'
41
-
42
- /**
43
- * Runtime-context order. The shipped runtime contexts sit in the 110-120 band
44
- * (sandbox policy, approval policy, subagent delegation); 130 places the
45
- * directory table right after them, where it reads as part of the same
46
- * "where am I allowed to work" block.
47
- */
48
- export const CONTEXT_ORDER = 130
49
-
50
- /** On-disk state format version. */
51
- export const STATE_VERSION = 1
52
-
53
- /** Tool name exposed to the model. */
54
- export const TOOL_NAME = 'workspace_dirs'
55
-
56
- /** HTTP route the browser half reads and writes. */
57
- export const API_PATH = '/workspaceplus/api'
58
-
59
- /**
60
- * Plugin settings and their defaults. `defaultWorkspaceEnabled` is deliberately
61
- * `false`: the default (first-use) Workspace belongs to the product, not to this
62
- * plugin, so label routing stays inert there until the user turns it on from the
63
- * `workspace+` settings page.
64
- */
65
- export const DEFAULT_SETTINGS = { defaultWorkspaceEnabled: false }
66
-
67
- /** Settings key of the default-workspace switch. */
68
- export const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
69
-
70
- /** Label grammar: short, shell-friendly, unambiguous in prose. */
71
- const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/
72
-
73
- /** Labels that would read as a path or a pronoun in prose and only cause confusion. */
74
- const RESERVED_LABELS = new Set(['.', '..', 'cwd', 'root', 'this', 'workspace'])
75
-
76
- // ---------------------------------------------------------------------------
77
- // Pure helpers (exported for tools/verify.mjs)
78
- // ---------------------------------------------------------------------------
79
-
80
- /**
81
- * Locate the Harness home the same way the rest of the harness does: the
82
- * `DSH_HOME` environment variable when the host process carries it, otherwise
83
- * the default `~/.dsh`.
84
- *
85
- * @param env - environment snapshot (injectable for tests).
86
- * @returns the absolute Harness home directory.
87
- */
88
- export function dshHome(env = process.env) {
89
- const fromEnv = env.DSH_HOME
90
- if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim())
91
- return join(homedir(), '.dsh')
92
- }
93
-
94
- /**
95
- * Absolute path of this plugin's state file.
96
- *
97
- * @param env - environment snapshot (injectable for tests).
98
- * @returns the state file path.
99
- */
100
- export function stateFilePath(env = process.env) {
101
- return join(dshHome(env), 'workspaceplus', 'directories.json')
102
- }
103
-
104
- /**
105
- * Comparison key for a directory path. Windows paths are case-insensitive, so
106
- * the key folds case there and keeps the platform's own spelling elsewhere.
107
- *
108
- * @param path - any absolute or relative path.
109
- * @returns the stable lookup key.
110
- */
111
- export function keyOf(path) {
112
- const absolute = resolve(String(path))
113
- return process.platform === 'win32' ? absolute.toLowerCase() : absolute
114
- }
115
-
116
- /** @returns an empty, valid state object. */
117
- export function emptyState() {
118
- return { version: STATE_VERSION, settings: { ...DEFAULT_SETTINGS }, workspaces: {} }
119
- }
120
-
121
- /**
122
- * Normalize the settings block, filling in every default for anything missing or
123
- * malformed. An older state file without a `settings` key keeps working.
124
- *
125
- * @param raw - the parsed `settings` value, or anything else.
126
- * @returns a complete settings object.
127
- */
128
- export function normalizeSettings(raw) {
129
- const settings = { ...DEFAULT_SETTINGS }
130
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return settings
131
- if (typeof raw[DEFAULT_WORKSPACE_SETTING] === 'boolean') {
132
- settings[DEFAULT_WORKSPACE_SETTING] = raw[DEFAULT_WORKSPACE_SETTING]
133
- }
134
- return settings
135
- }
136
-
137
- /**
138
- * Validate and normalize a parsed state document. Unknown shapes degrade to an
139
- * empty state rather than throwing, because a corrupt state file must not stop
140
- * the Harness from booting.
141
- *
142
- * @param raw - parsed JSON, or anything else.
143
- * @returns a normalized state object.
144
- */
145
- export function normalizeState(raw) {
146
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return emptyState()
147
- const workspaces = raw.workspaces
148
- if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) return emptyState()
149
- const next = emptyState()
150
- next.settings = normalizeSettings(raw.settings)
151
- for (const [key, entry] of Object.entries(workspaces)) {
152
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
153
- const path = typeof entry.path === 'string' && entry.path !== '' ? entry.path : key
154
- const dirs = []
155
- const seen = new Set()
156
- for (const dir of Array.isArray(entry.dirs) ? entry.dirs : []) {
157
- if (dir === null || typeof dir !== 'object' || Array.isArray(dir)) continue
158
- const label = typeof dir.label === 'string' ? dir.label.trim() : ''
159
- const dirPath = typeof dir.path === 'string' ? dir.path.trim() : ''
160
- if (!LABEL_PATTERN.test(label) || dirPath === '') continue
161
- const labelKey = label.toLowerCase()
162
- if (seen.has(labelKey)) continue
163
- seen.add(labelKey)
164
- dirs.push({
165
- label,
166
- path: resolve(dirPath),
167
- ...(typeof dir.note === 'string' && dir.note.trim() !== '' ? { note: dir.note.trim() } : {}),
168
- })
169
- }
170
- next.workspaces[keyOf(path)] = { path: resolve(path), dirs }
171
- }
172
- return next
173
- }
174
-
175
- /**
176
- * Read the state file, tolerating a missing, unreadable, or corrupt file.
177
- *
178
- * @param file - state file path.
179
- * @returns the normalized state.
180
- */
181
- export function readState(file) {
182
- try {
183
- return normalizeState(JSON.parse(readFileSync(file, 'utf8')))
184
- } catch {
185
- return emptyState()
186
- }
187
- }
188
-
189
- /**
190
- * Persist the state atomically (write a sibling temp file, then rename), so a
191
- * crash mid-write cannot leave a half-written mapping behind.
192
- *
193
- * @param file - state file path.
194
- * @param state - the state to persist.
195
- */
196
- export function writeState(file, state) {
197
- mkdirSync(dirname(file), { recursive: true })
198
- const temporary = `${file}.tmp`
199
- writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
200
- renameSync(temporary, file)
201
- }
202
-
203
- /**
204
- * Whether the label routing runs for one workspace.
205
- *
206
- * The default (first-use) Workspace is the product's, not this plugin's: it stays
207
- * inert unless the user enables the switch on the `workspace+` settings page.
208
- * Every other workspace is enabled by definition.
209
- *
210
- * @param options - the settings, the workspace path, and the resolved default path.
211
- * @returns true when the feature should run for that workspace.
212
- */
213
- export function isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath }) {
214
- if (defaultWorkspacePath === null || defaultWorkspacePath === undefined) return true
215
- if (keyOf(workspacePath) !== keyOf(defaultWorkspacePath)) return true
216
- return normalizeSettings(settings)[DEFAULT_WORKSPACE_SETTING] === true
217
- }
218
-
219
- /**
220
- * Apply one settings mutation.
221
- *
222
- * @param state - normalized state (mutated only when the operation succeeds).
223
- * @param patch - the requested settings values.
224
- * @returns `{ ok, message, settings }`.
225
- */
226
- export function applySettings(state, patch) {
227
- const wanted = patch?.[DEFAULT_WORKSPACE_SETTING]
228
- if (typeof wanted !== 'boolean') {
229
- return { ok: false, message: `${DEFAULT_WORKSPACE_SETTING} 必须是布尔值`, settings: state.settings }
230
- }
231
- state.settings = { ...normalizeSettings(state.settings), [DEFAULT_WORKSPACE_SETTING]: wanted }
232
- return {
233
- ok: true,
234
- settings: state.settings,
235
- message: wanted ? '已对默认工作区启用 workspace+' : '已对默认工作区停用 workspace+',
236
- }
237
- }
238
-
239
- /**
240
- * The labeled directories registered for one workspace directory.
241
- *
242
- * @param state - normalized state.
243
- * @param workspacePath - the workspace's directory.
244
- * @returns its entry, or undefined when nothing is registered.
245
- */
246
- export function entryFor(state, workspacePath) {
247
- return state.workspaces[keyOf(workspacePath)]
248
- }
249
-
250
- /**
251
- * Validate one directory a user wants to register.
252
- *
253
- * @param path - the candidate directory.
254
- * @returns `{ ok: true, path }` or `{ ok: false, message }`.
255
- */
256
- export function validateDirectory(path) {
257
- const text = String(path ?? '').trim()
258
- if (text === '') return { ok: false, message: 'path 不能为空' }
259
- if (!isAbsolute(text)) return { ok: false, message: `path 必须是绝对路径:${text}` }
260
- const absolute = resolve(text)
261
- if (!existsSync(absolute)) return { ok: false, message: `目录不存在:${absolute}` }
262
- try {
263
- if (!statSync(absolute).isDirectory()) return { ok: false, message: `不是目录:${absolute}` }
264
- } catch (error) {
265
- return { ok: false, message: `无法访问 ${absolute}:${String(error?.message ?? error)}` }
266
- }
267
- return { ok: true, path: absolute }
268
- }
269
-
270
- /**
271
- * Validate a label.
272
- *
273
- * @param label - the candidate label.
274
- * @returns `{ ok: true, label }` or `{ ok: false, message }`.
275
- */
276
- export function validateLabel(label) {
277
- const text = String(label ?? '').trim()
278
- if (text === '') return { ok: false, message: 'label 不能为空' }
279
- if (!LABEL_PATTERN.test(text)) {
280
- return { ok: false, message: `label 只能是字母/数字/. _ -,且不超过 32 个字符:${text}` }
281
- }
282
- if (RESERVED_LABELS.has(text.toLowerCase())) return { ok: false, message: `label 不能使用保留字:${text}` }
283
- return { ok: true, label: text }
284
- }
285
-
286
- /**
287
- * Apply one store operation. Every branch validates first and returns a
288
- * discriminated result, so both the Tool and the HTTP route share one behavior
289
- * and one set of messages.
290
- *
291
- * @param state - normalized state (mutated only when the operation succeeds).
292
- * @param operation - `{ op: 'add' | 'remove' | 'update', workspace, label, path, note }`.
293
- * @returns `{ ok: true, message, entry }` or `{ ok: false, message }`.
294
- */
295
- export function applyOperation(state, operation) {
296
- const workspaceCheck = validateDirectory(operation.workspace)
297
- if (!workspaceCheck.ok) return { ok: false, message: `工作区目录无效:${workspaceCheck.message}` }
298
- const workspacePath = workspaceCheck.path
299
- const key = keyOf(workspacePath)
300
- const current = state.workspaces[key] ?? { path: workspacePath, dirs: [] }
301
-
302
- const labelCheck = validateLabel(operation.label)
303
- if (!labelCheck.ok) return labelCheck
304
- const labelKey = labelCheck.label.toLowerCase()
305
- const at = current.dirs.findIndex((dir) => dir.label.toLowerCase() === labelKey)
306
-
307
- if (operation.op === 'add') {
308
- if (at !== -1) {
309
- return { ok: false, message: `标签 ${current.dirs[at].label} 已存在(指向 ${current.dirs[at].path})。要改路径请用 update。` }
310
- }
311
- const directoryCheck = validateDirectory(operation.path)
312
- if (!directoryCheck.ok) return directoryCheck
313
- current.dirs.push({
314
- label: labelCheck.label,
315
- path: directoryCheck.path,
316
- ...(typeof operation.note === 'string' && operation.note.trim() !== '' ? { note: operation.note.trim() } : {}),
317
- })
318
- state.workspaces[key] = current
319
- return { ok: true, entry: current, message: `已为工作区 ${workspacePath} 登记 ${labelCheck.label} -> ${directoryCheck.path}` }
320
- }
321
-
322
- if (operation.op === 'remove') {
323
- if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label}` }
324
- const [removed] = current.dirs.splice(at, 1)
325
- if (current.dirs.length === 0) delete state.workspaces[key]
326
- else state.workspaces[key] = current
327
- return { ok: true, entry: current.dirs.length === 0 ? { path: workspacePath, dirs: [] } : current, message: `已移除标签 ${removed.label}(${removed.path})` }
328
- }
329
-
330
- if (operation.op === 'update') {
331
- if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label};新增请用 add` }
332
- const target = { ...current.dirs[at] }
333
- if (operation.path !== undefined && operation.path !== null && String(operation.path).trim() !== '') {
334
- const directoryCheck = validateDirectory(operation.path)
335
- if (!directoryCheck.ok) return directoryCheck
336
- target.path = directoryCheck.path
337
- }
338
- if (operation.note !== undefined) {
339
- const note = String(operation.note).trim()
340
- if (note === '') delete target.note
341
- else target.note = note
342
- }
343
- current.dirs[at] = target
344
- state.workspaces[key] = current
345
- return { ok: true, entry: current, message: `已更新标签 ${target.label} -> ${target.path}` }
346
- }
347
-
348
- return { ok: false, message: `未知操作:${String(operation.op)}` }
349
- }
350
-
351
- /**
352
- * Render one workspace's mapping for the model. Returns an empty string when
353
- * the workspace has no registered directories, so an unused feature costs no
354
- * prompt bytes.
355
- *
356
- * @param options - workspace path, its entry, and the effective sandbox facts.
357
- * @returns the runtime-context text, or `''`.
358
- */
359
- export function renderDirectoryContext({ workspacePath, entry, sandboxMode }) {
360
- if (entry === undefined || !Array.isArray(entry.dirs) || entry.dirs.length === 0) return ''
361
- const restricted = sandboxMode !== undefined && sandboxMode !== 'danger-full-access'
362
- const inside = (dir) => keyOf(dir) === keyOf(workspacePath) || keyOf(dir).startsWith(`${keyOf(workspacePath)}\\`) || keyOf(dir).startsWith(`${keyOf(workspacePath)}/`)
363
-
364
- const lines = [
365
- '工作区多目录映射(dsh-workspace-plus 插件维护)',
366
- '',
367
- `当前会话的 cwd(工作区根目录):\`${workspacePath}\``,
368
- '',
369
- '用户提到下列「标签」时,指的就是表中对应的绝对目录。请直接使用表中的绝对路径,',
370
- '不要根据目录名相似、命名习惯或当前 cwd 去推测位置:',
371
- '',
372
- ]
373
- for (const dir of entry.dirs) {
374
- const note = dir.note === undefined ? '' : `(${dir.note})`
375
- const outside = restricted && !inside(dir.path) ? ' ⚠ 在工作区根目录之外' : ''
376
- lines.push(`- \`${dir.label}\` → \`${dir.path}\`${note}${outside}`)
377
- }
378
- lines.push(
379
- '',
380
- '规则:',
381
- '1. 读写、编辑、搜索和命令中的路径都使用上表的绝对路径;不要把它当作相对于 cwd 的相对路径。',
382
- '2. 标签不区分大小写。',
383
- `3. 只在上表列出的目录和工作区根目录 \`${workspacePath}\` 内工作。不要扫描 cwd 的同级目录,也不要用 \`**/*\` 这类无范围模式在 cwd 下“先找找看”——要定位文件时,先用标签选定目录,再在该目录内按模式搜索。`,
384
- '4. 上表没有覆盖的路径,先调用 workspace_dirs 工具确认,不要猜。',
385
- '5. 用户要求增删改这份映射时,调用 workspace_dirs 工具,不要直接编辑状态文件。',
386
- )
387
- if (restricted) {
388
- lines.push(
389
- `6. 当前文件策略为 ${sandboxMode}:只有工作区根目录内的文件可直接写;标有 ⚠ 的目录需要工具自身的升级流程` +
390
- '(如 write/edit 的 sandbox_permissions + justification),按工具返回的提示处理,不要因为它不在工作区内就直接放弃,也不要反复重试同一次写入。',
391
- )
392
- }
393
- return lines.join('\n')
394
- }
395
-
396
- /**
397
- * Render one workspace's mapping for a human/agent tool result.
398
- *
399
- * @param workspacePath - the workspace directory.
400
- * @param entry - its registered directories.
401
- * @returns a readable multi-line summary.
402
- */
403
- export function renderDirectoryList(workspacePath, entry) {
404
- if (entry === undefined || entry.dirs.length === 0) {
405
- return `工作区 ${workspacePath} 还没有登记任何目录。用 workspace_dirs add 添加,例如:\n action=add, workspace=${workspacePath}, label=backend, path=D:\\proj\\backend`
406
- }
407
- const lines = [`工作区 ${workspacePath} 已登记 ${entry.dirs.length} 个目录:`]
408
- for (const dir of entry.dirs) {
409
- lines.push(`- ${dir.label} -> ${dir.path}${dir.note === undefined ? '' : ` (${dir.note})`}`)
410
- }
411
- return lines.join('\n')
412
- }
413
-
414
- /** The closed action vocabulary of the `workspace_dirs` tool. */
415
- export const TOOL_ACTIONS = ['list', 'add', 'remove', 'update']
416
-
417
- /** The model-facing parameter schema of the `workspace_dirs` tool. */
418
- export const TOOL_PARAMETERS = {
419
- type: 'object',
420
- properties: {
421
- action: {
422
- type: 'string',
423
- enum: TOOL_ACTIONS,
424
- description:
425
- 'list reads the mapping; add registers a new labeled directory; remove drops a label; update changes a label\'s path or note.',
426
- },
427
- label: {
428
- type: 'string',
429
- description: 'Conversation label for the directory. Required for add/remove/update (letters, digits, ".", "_", "-", max 32).',
430
- },
431
- path: {
432
- type: 'string',
433
- description: 'Absolute directory path: required for add, optional for update (re-points the label).',
434
- },
435
- note: {
436
- type: 'string',
437
- description: 'Optional one-line description, shown to the agent and in the GUI.',
438
- },
439
- workspace: {
440
- type: 'string',
441
- description: 'Absolute path of the target workspace directory. Defaults to the calling session workspace (its cwd).',
442
- },
443
- },
444
- required: ['action'],
445
- additionalProperties: false,
446
- }
447
-
448
- /** The model-facing description of the `workspace_dirs` tool. */
449
- export const TOOL_DESCRIPTION = [
450
- 'Read or edit the extra labeled directories registered for a DSH workspace.',
451
- 'A workspace has one root (the session cwd); this tool maintains the additional project folders that belong to it,',
452
- 'each addressed in conversation by a short label. The current mapping is already in your context; call this tool to',
453
- 'confirm it, or when the user asks to add, remove or re-point a labeled directory.',
454
- 'Never guess a directory from a similar name — resolve it here first.',
455
- ].join(' ')
456
-
457
- // ---------------------------------------------------------------------------
458
- // Plugin
459
- // ---------------------------------------------------------------------------
460
-
461
- /**
462
- * Host-side entry point. Wires the store to the three consumers (prompt
463
- * context, tool, HTTP route) and lets Cordis dispose all of them on unload.
464
- *
465
- * @param ctx - host plugin context.
466
- */
467
- export function apply(ctx) {
468
- const file = stateFilePath()
469
-
470
- /**
471
- * Identity of the first-use (default) Workspace, resolved through the PUBLIC
472
- * registry API: `initializeDefault` returns the recorded default immediately
473
- * when one exists, returns `undefined` when automatic creation is ineligible,
474
- * and only ever calls the directory resolver when it would actually create a
475
- * workspace — so the probe passes a resolver that refuses, and a brand-new
476
- * install simply reports "no default workspace" instead of creating one.
477
- *
478
- * `resolved` stays false when the registry is not usable yet (its service is
479
- * registered before `[Service.init]` finishes, so `requireState()` can throw on
480
- * a cold boot). Every later caller retries, which keeps a failed first attempt
481
- * from silently enabling the feature on the default Workspace forever.
482
- */
483
- let defaultWorkspace = { resolved: false, id: null, path: null }
484
-
485
- const resolveDefaultWorkspace = async (registry = ctx.get('workspaceRegistry')) => {
486
- if (defaultWorkspace.resolved) return defaultWorkspace
487
- if (registry === undefined || typeof registry.initializeDefault !== 'function') return defaultWorkspace
488
- try {
489
- const workspace = await registry.initializeDefault(() => {
490
- throw new Error('dsh-workspace-plus: declined to create the default workspace')
491
- })
492
- defaultWorkspace = {
493
- resolved: true,
494
- id: workspace === undefined ? null : String(workspace.id),
495
- path: workspace === undefined ? null : workspace.path,
496
- }
497
- } catch {
498
- // Registry not started yet, or the probe was declined: retry on the next call.
499
- }
500
- return defaultWorkspace
501
- }
502
-
503
- /**
504
- * Warm the cache as soon as the registry service is usable — the synchronous
505
- * prompt path can then read a settled value from the very first request.
506
- */
507
- ctx.inject(['workspaceRegistry'], (registryScope) => {
508
- void resolveDefaultWorkspace(registryScope.workspaceRegistry)
509
- })
510
-
511
- /** Read-modify-write through the single state file, serialized per call. */
512
- const mutate = (operation) => {
513
- const state = readState(file)
514
- const result = applyOperation(state, operation)
515
- if (result.ok) writeState(file, state)
516
- return result
517
- }
518
-
519
- /** Read-modify-write for the settings block. */
520
- const mutateSettings = (patch) => {
521
- const state = readState(file)
522
- const result = applySettings(state, patch)
523
- if (result.ok) writeState(file, state)
524
- return result
525
- }
526
-
527
- /** Whether the feature runs for a workspace path, given the current settings and probe. */
528
- const enabledFor = (workspacePath, settings) =>
529
- isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath: defaultWorkspace.path })
530
-
531
- const snapshot = () => {
532
- const registry = ctx.get('workspaceRegistry')
533
- const state = readState(file)
534
- const workspaces = registry === undefined ? [] : registry.list()
535
- return {
536
- registryAvailable: registry !== undefined,
537
- settings: { ...state.settings },
538
- defaultWorkspaceId: defaultWorkspace.id,
539
- defaultWorkspacePath: defaultWorkspace.path,
540
- workspaces: workspaces.map((workspace) => {
541
- const entry = entryFor(state, workspace.path)
542
- const isDefault = defaultWorkspace.path !== null && keyOf(defaultWorkspace.path) === keyOf(workspace.path)
543
- return {
544
- id: String(workspace.id),
545
- path: workspace.path,
546
- title: workspace.title,
547
- isDefault,
548
- enabled: enabledFor(workspace.path, state.settings),
549
- dirs: entry === undefined ? [] : entry.dirs.map((dir) => ({ ...dir })),
550
- }
551
- }),
552
- }
553
- }
554
-
555
- // --- 1. Make the label table part of every request in that workspace ------
556
- // `context()` and `register()` each install their own effect on this plugin's
557
- // fiber, so both contributions disappear when the plugin unloads.
558
- ctx.systemPrompt.context({
559
- name: CONTEXT_NAME,
560
- order: CONTEXT_ORDER,
561
- text: (context) => {
562
- const session = context.agent?.session
563
- const workspacePath = session?.header?.cwd
564
- if (typeof workspacePath !== 'string' || workspacePath === '') return ''
565
- const state = readState(file)
566
- // The default Workspace stays untouched until the user opts in.
567
- if (!enabledFor(workspacePath, state.settings)) return ''
568
- const entry = entryFor(state, workspacePath)
569
- if (entry === undefined || entry.dirs.length === 0) return ''
570
- const policy = ctx.get('sandboxPolicy')
571
- const sandboxMode = session === undefined ? undefined : policy?.resolve({ session })?.mode
572
- return renderDirectoryContext({ workspacePath, entry, sandboxMode })
573
- },
574
- })
575
-
576
- // --- 2. Let the agent inspect and edit the mapping ------------------------
577
- ctx.tools.register({
578
- name: TOOL_NAME,
579
- description: TOOL_DESCRIPTION,
580
- parameters: TOOL_PARAMETERS,
581
- output: {
582
- schema: { type: 'string' },
583
- render: (_args, value) => [{ type: 'text', text: String(value) }],
584
- },
585
- async execute(args, exec) {
586
- const session = exec.agent?.session
587
- const action = args?.action
588
- if (!TOOL_ACTIONS.includes(action)) {
589
- return `Error: action 必须是 ${TOOL_ACTIONS.join(' / ')} 之一,收到 ${JSON.stringify(action ?? null)}`
590
- }
591
- const workspace =
592
- typeof args.workspace === 'string' && args.workspace.trim() !== '' ? args.workspace : session?.header?.cwd
593
- if (typeof workspace !== 'string' || workspace === '') {
594
- return 'Error: 没有可用的工作区目录。请通过 workspace 参数显式指定一个绝对路径。'
595
- }
596
- // The tool can afford a fresh probe, so its gate is never decided on a
597
- // cache that a cold boot could not fill.
598
- await resolveDefaultWorkspace()
599
- const state = readState(file)
600
- if (!enabledFor(workspace, state.settings)) {
601
- return (
602
- `Error: ${resolve(workspace)} 是默认工作区,workspace+ 默认不对它启用。` +
603
- '请在 GUI 的 设置 → workspace+ 打开「对默认工作区启用」,或改用其它工作区。'
604
- )
605
- }
606
- if (action === 'list') {
607
- return renderDirectoryList(resolve(workspace), entryFor(state, workspace))
608
- }
609
- const result = mutate({
610
- op: action,
611
- workspace,
612
- label: args.label,
613
- path: args.path,
614
- note: args.note,
615
- })
616
- if (!result.ok) return `Error: ${result.message}`
617
- return `${result.message}\n\n${renderDirectoryList(resolve(workspace), result.entry)}`
618
- },
619
- })
620
-
621
- // --- 3. Give the browser half a same-origin JSON endpoint -----------------
622
- ctx.inject(['webServer'], (webCtx) => {
623
- webCtx.effect(
624
- () =>
625
- webCtx.webServer.register({
626
- kind: 'exact',
627
- path: API_PATH,
628
- handler: async (req, res) => {
629
- const connection = ctx.get('connection')
630
- const admission = connection?.admit?.(req)
631
- if (admission !== undefined && 'rejection' in admission) {
632
- res.writeHead(admission.rejection)
633
- res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
634
- return
635
- }
636
- const send = (status, payload) => {
637
- const body = JSON.stringify(payload)
638
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
639
- res.end(body)
640
- }
641
- try {
642
- // The settings page must see the default-workspace identity even on
643
- // the very first request after boot, so the probe is awaited here.
644
- await resolveDefaultWorkspace()
645
- if (req.method === 'GET') {
646
- send(200, { ok: true, ...snapshot() })
647
- return
648
- }
649
- if (req.method !== 'POST') {
650
- send(405, { ok: false, message: 'method not allowed' })
651
- return
652
- }
653
- const chunks = []
654
- let bytes = 0
655
- for await (const chunk of req) {
656
- bytes += chunk.length
657
- if (bytes > 64 * 1024) {
658
- send(413, { ok: false, message: 'request body too large' })
659
- return
660
- }
661
- chunks.push(chunk)
662
- }
663
- let payload
664
- try {
665
- payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
666
- } catch {
667
- send(400, { ok: false, message: 'body is not JSON' })
668
- return
669
- }
670
- if (payload?.op === 'settings') {
671
- const settingsResult = mutateSettings(payload?.settings)
672
- if (!settingsResult.ok) {
673
- send(200, { ok: false, message: settingsResult.message, ...snapshot() })
674
- return
675
- }
676
- send(200, { ok: true, message: settingsResult.message, ...snapshot() })
677
- return
678
- }
679
- const operation = {
680
- op: payload?.op,
681
- workspace: payload?.workspace,
682
- label: payload?.label,
683
- path: payload?.path,
684
- note: payload?.note,
685
- }
686
- if (operation.op === undefined) {
687
- send(400, { ok: false, message: 'missing op' })
688
- return
689
- }
690
- const result = mutate(operation)
691
- if (!result.ok) {
692
- send(200, { ok: false, message: result.message, ...snapshot() })
693
- return
694
- }
695
- send(200, { ok: true, message: result.message, ...snapshot() })
696
- } catch (error) {
697
- ctx.logger?.warn?.(error)
698
- send(500, { ok: false, message: String(error?.message ?? error) })
699
- }
700
- },
701
- }),
702
- 'dsh-workspace-plus: /workspaceplus/api route',
703
- )
704
- })
705
-
706
- console.log(`[${name}] host half loaded — workspace label routing is active (${file})`)
707
- }
1
+ /**
2
+ * dsh-workspace-plus — host half.
3
+ *
4
+ * Feature 2: a DSH workspace keeps ONE directory (`SessionHeader.cwd`), but a
5
+ * real project often spans several roots (a frontend repo, a backend repo, a
6
+ * docs folder). This half lets one workspace carry N extra directories, each
7
+ * with a short **label**, and makes those labels usable in conversation:
8
+ *
9
+ * 1. a durable, plugin-owned store: workspace path -> [{ label, path, note }]
10
+ * 2. a per-request runtime-context contribution (`workspaceplus:directories`)
11
+ * that renders the label table for the calling Session's workspace, so the
12
+ * agent sees the mapping without spending a tool call
13
+ * 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
14
+ * when the user asks in natural language
15
+ * 4. a small JSON HTTP route the browser half uses to manage the same store
16
+ *
17
+ * The whole point is routing by label instead of by guesswork: the injected
18
+ * table gives absolute paths, so a similar-looking directory name elsewhere on
19
+ * disk cannot capture the edit.
20
+ *
21
+ * The module deliberately imports nothing but Node builtins: an externally
22
+ * installed bundle cannot rely on resolving `@deepseek-ai/*` from its own
23
+ * location, and the two contracts it needs (`systemPrompt.context({...})` and
24
+ * the registry-ready tool definition shape) are plain data.
25
+ *
26
+ * @module dsh-workspace-plus
27
+ */
28
+
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
30
+ import { homedir } from 'node:os'
31
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
32
+
33
+ /** Plugin name shown by the loader and in host logs. */
34
+ export const name = 'dsh-workspace-plus'
35
+
36
+ /** Services the two contributions require; both ship in the base composition. */
37
+ export const inject = ['tools', 'systemPrompt']
38
+
39
+ /** Runtime-context entry name (stable: it is also the contribution's identity). */
40
+ export const CONTEXT_NAME = 'workspaceplus:directories'
41
+
42
+ /**
43
+ * Runtime-context order. The shipped runtime contexts sit in the 110-120 band
44
+ * (sandbox policy, approval policy, subagent delegation); 130 places the
45
+ * directory table right after them, where it reads as part of the same
46
+ * "where am I allowed to work" block.
47
+ */
48
+ export const CONTEXT_ORDER = 130
49
+
50
+ /** On-disk state format version. */
51
+ export const STATE_VERSION = 1
52
+
53
+ /** Tool name exposed to the model. */
54
+ export const TOOL_NAME = 'workspace_dirs'
55
+
56
+ /** HTTP route the browser half reads and writes. */
57
+ export const API_PATH = '/workspaceplus/api'
58
+
59
+ /**
60
+ * Plugin settings and their defaults.
61
+ *
62
+ * `workspaceEnabled` is the per-workspace layer: a `{ <keyOf(path)>: boolean }`
63
+ * map, where an entry overrides the default for exactly that workspace. It is
64
+ * empty out of the box, and an absent entry means "use the default".
65
+ *
66
+ * `defaultWorkspaceEnabled` is the default for the default (first-use) Workspace
67
+ * only, and is deliberately `false`: that Workspace belongs to the product, not
68
+ * to this plugin, so label routing stays inert there until the user turns that
69
+ * workspace's switch on. Every other workspace defaults to enabled, which is why
70
+ * the map starts empty rather than listing them.
71
+ */
72
+ export const DEFAULT_SETTINGS = { defaultWorkspaceEnabled: false, workspaceEnabled: {} }
73
+
74
+ /** Settings key of the default-workspace switch. */
75
+ export const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
76
+
77
+ /** Settings key of the per-workspace override map. */
78
+ export const WORKSPACE_ENABLED_SETTING = 'workspaceEnabled'
79
+
80
+ /** @returns a fresh settings object (never a shared reference to the defaults). */
81
+ export function defaultSettings() {
82
+ return { [DEFAULT_WORKSPACE_SETTING]: false, [WORKSPACE_ENABLED_SETTING]: {} }
83
+ }
84
+
85
+ /** Label grammar: short, shell-friendly, unambiguous in prose. */
86
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/
87
+
88
+ /** Labels that would read as a path or a pronoun in prose and only cause confusion. */
89
+ const RESERVED_LABELS = new Set(['.', '..', 'cwd', 'root', 'this', 'workspace'])
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Pure helpers (exported for tools/verify.mjs)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Locate the Harness home the same way the rest of the harness does: the
97
+ * `DSH_HOME` environment variable when the host process carries it, otherwise
98
+ * the default `~/.dsh`.
99
+ *
100
+ * @param env - environment snapshot (injectable for tests).
101
+ * @returns the absolute Harness home directory.
102
+ */
103
+ export function dshHome(env = process.env) {
104
+ const fromEnv = env.DSH_HOME
105
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim())
106
+ return join(homedir(), '.dsh')
107
+ }
108
+
109
+ /**
110
+ * Absolute path of this plugin's state file.
111
+ *
112
+ * @param env - environment snapshot (injectable for tests).
113
+ * @returns the state file path.
114
+ */
115
+ export function stateFilePath(env = process.env) {
116
+ return join(dshHome(env), 'workspaceplus', 'directories.json')
117
+ }
118
+
119
+ /**
120
+ * Comparison key for a directory path. Windows paths are case-insensitive, so
121
+ * the key folds case there and keeps the platform's own spelling elsewhere.
122
+ *
123
+ * @param path - any absolute or relative path.
124
+ * @returns the stable lookup key.
125
+ */
126
+ export function keyOf(path) {
127
+ const absolute = resolve(String(path))
128
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
129
+ }
130
+
131
+ /** @returns an empty, valid state object. */
132
+ export function emptyState() {
133
+ return { version: STATE_VERSION, settings: defaultSettings(), workspaces: {} }
134
+ }
135
+
136
+ /**
137
+ * Normalize the settings block, filling in every default for anything missing or
138
+ * malformed. An older state file without a `settings` key — including one written
139
+ * before the per-workspace layer existed — keeps working: its missing map simply
140
+ * normalizes to "no overrides".
141
+ *
142
+ * @param raw - the parsed `settings` value, or anything else.
143
+ * @returns a complete settings object.
144
+ */
145
+ export function normalizeSettings(raw) {
146
+ const settings = defaultSettings()
147
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return settings
148
+ if (typeof raw[DEFAULT_WORKSPACE_SETTING] === 'boolean') {
149
+ settings[DEFAULT_WORKSPACE_SETTING] = raw[DEFAULT_WORKSPACE_SETTING]
150
+ }
151
+ const overrides = raw[WORKSPACE_ENABLED_SETTING]
152
+ if (overrides !== null && typeof overrides === 'object' && !Array.isArray(overrides)) {
153
+ for (const [key, value] of Object.entries(overrides)) {
154
+ // Only booleans are meaningful; anything else is dropped rather than
155
+ // guessed at, so a hand-edited file can never silently enable a workspace.
156
+ if (typeof value === 'boolean') settings[WORKSPACE_ENABLED_SETTING][key] = value
157
+ }
158
+ }
159
+ return settings
160
+ }
161
+
162
+ /**
163
+ * Validate and normalize a parsed state document. Unknown shapes degrade to an
164
+ * empty state rather than throwing, because a corrupt state file must not stop
165
+ * the Harness from booting.
166
+ *
167
+ * @param raw - parsed JSON, or anything else.
168
+ * @returns a normalized state object.
169
+ */
170
+ export function normalizeState(raw) {
171
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return emptyState()
172
+ const workspaces = raw.workspaces
173
+ if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) return emptyState()
174
+ const next = emptyState()
175
+ next.settings = normalizeSettings(raw.settings)
176
+ for (const [key, entry] of Object.entries(workspaces)) {
177
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
178
+ const path = typeof entry.path === 'string' && entry.path !== '' ? entry.path : key
179
+ const dirs = []
180
+ const seen = new Set()
181
+ for (const dir of Array.isArray(entry.dirs) ? entry.dirs : []) {
182
+ if (dir === null || typeof dir !== 'object' || Array.isArray(dir)) continue
183
+ const label = typeof dir.label === 'string' ? dir.label.trim() : ''
184
+ const dirPath = typeof dir.path === 'string' ? dir.path.trim() : ''
185
+ if (!LABEL_PATTERN.test(label) || dirPath === '') continue
186
+ const labelKey = label.toLowerCase()
187
+ if (seen.has(labelKey)) continue
188
+ seen.add(labelKey)
189
+ dirs.push({
190
+ label,
191
+ path: resolve(dirPath),
192
+ ...(typeof dir.note === 'string' && dir.note.trim() !== '' ? { note: dir.note.trim() } : {}),
193
+ })
194
+ }
195
+ next.workspaces[keyOf(path)] = { path: resolve(path), dirs }
196
+ }
197
+ return next
198
+ }
199
+
200
+ /**
201
+ * Read the state file, tolerating a missing, unreadable, or corrupt file.
202
+ *
203
+ * @param file - state file path.
204
+ * @returns the normalized state.
205
+ */
206
+ export function readState(file) {
207
+ try {
208
+ return normalizeState(JSON.parse(readFileSync(file, 'utf8')))
209
+ } catch {
210
+ return emptyState()
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Persist the state atomically (write a sibling temp file, then rename), so a
216
+ * crash mid-write cannot leave a half-written mapping behind.
217
+ *
218
+ * @param file - state file path.
219
+ * @param state - the state to persist.
220
+ */
221
+ export function writeState(file, state) {
222
+ mkdirSync(dirname(file), { recursive: true })
223
+ const temporary = `${file}.tmp`
224
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
225
+ renameSync(temporary, file)
226
+ }
227
+
228
+ /**
229
+ * Whether the label routing runs for one workspace.
230
+ *
231
+ * Resolution order:
232
+ * 1. an explicit entry in `settings.workspaceEnabled` — the per-workspace
233
+ * switches on the `workspace+` settings page — wins outright;
234
+ * 2. otherwise the default for that workspace: the default (first-use) Workspace
235
+ * is the product's, not this plugin's, so it stays inert until its own switch
236
+ * is turned on; every other workspace is enabled by definition.
237
+ *
238
+ * @param options - the settings, the workspace path, and the resolved default path.
239
+ * @returns true when the feature should run for that workspace.
240
+ */
241
+ export function isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath }) {
242
+ const normalized = normalizeSettings(settings)
243
+ const key = keyOf(workspacePath)
244
+ const override = normalized[WORKSPACE_ENABLED_SETTING][key]
245
+ if (typeof override === 'boolean') return override
246
+ if (defaultWorkspacePath === null || defaultWorkspacePath === undefined) return true
247
+ if (key !== keyOf(defaultWorkspacePath)) return true
248
+ return normalized[DEFAULT_WORKSPACE_SETTING] === true
249
+ }
250
+
251
+ /**
252
+ * Apply one settings mutation. The patch may carry either layer, or both:
253
+ *
254
+ * - `defaultWorkspaceEnabled: boolean` — the default for the default Workspace;
255
+ * - `workspaceEnabled: { <key>: boolean | null }` — per-workspace overrides,
256
+ * where `null` drops the override so that workspace falls back to its default.
257
+ *
258
+ * Nothing is written unless every value in the patch is usable, so a rejected
259
+ * request can never leave half of a change behind.
260
+ *
261
+ * @param state - normalized state (mutated only when the operation succeeds).
262
+ * @param patch - the requested settings values.
263
+ * @returns `{ ok, message, settings }`.
264
+ */
265
+ export function applySettings(state, patch) {
266
+ const current = normalizeSettings(state.settings)
267
+ const next = {
268
+ [DEFAULT_WORKSPACE_SETTING]: current[DEFAULT_WORKSPACE_SETTING],
269
+ [WORKSPACE_ENABLED_SETTING]: { ...current[WORKSPACE_ENABLED_SETTING] },
270
+ }
271
+ const messages = []
272
+ const object = patch !== null && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}
273
+
274
+ if (Object.prototype.hasOwnProperty.call(object, DEFAULT_WORKSPACE_SETTING)) {
275
+ const wanted = object[DEFAULT_WORKSPACE_SETTING]
276
+ if (typeof wanted !== 'boolean') {
277
+ return { ok: false, message: `${DEFAULT_WORKSPACE_SETTING} 必须是布尔值`, settings: state.settings }
278
+ }
279
+ next[DEFAULT_WORKSPACE_SETTING] = wanted
280
+ messages.push(wanted ? '已对默认工作区启用 workspace+' : '已对默认工作区停用 workspace+')
281
+ }
282
+
283
+ const overrides = object[WORKSPACE_ENABLED_SETTING]
284
+ if (overrides !== undefined) {
285
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
286
+ return {
287
+ ok: false,
288
+ message: `${WORKSPACE_ENABLED_SETTING} 必须是「工作区键 → 布尔值」的对象`,
289
+ settings: state.settings,
290
+ }
291
+ }
292
+ for (const [key, wanted] of Object.entries(overrides)) {
293
+ // `null` restores the workspace's own default instead of pinning it.
294
+ if (wanted === null) {
295
+ delete next[WORKSPACE_ENABLED_SETTING][key]
296
+ messages.push(`已把 ${key} 恢复为默认`)
297
+ continue
298
+ }
299
+ if (typeof wanted !== 'boolean') {
300
+ return {
301
+ ok: false,
302
+ message: `${WORKSPACE_ENABLED_SETTING}.${key} 必须是布尔值或 null`,
303
+ settings: state.settings,
304
+ }
305
+ }
306
+ next[WORKSPACE_ENABLED_SETTING][key] = wanted
307
+ messages.push(wanted ? `已启用 ${key} 的 workspace+` : `已停用 ${key} 的 workspace+`)
308
+ }
309
+ }
310
+
311
+ if (messages.length === 0) {
312
+ return { ok: false, message: '没有可应用的设置变更', settings: state.settings }
313
+ }
314
+ state.settings = next
315
+ return { ok: true, settings: next, message: messages.join(';') }
316
+ }
317
+
318
+ /**
319
+ * The labeled directories registered for one workspace directory.
320
+ *
321
+ * @param state - normalized state.
322
+ * @param workspacePath - the workspace's directory.
323
+ * @returns its entry, or undefined when nothing is registered.
324
+ */
325
+ export function entryFor(state, workspacePath) {
326
+ return state.workspaces[keyOf(workspacePath)]
327
+ }
328
+
329
+ /**
330
+ * Validate one directory a user wants to register.
331
+ *
332
+ * @param path - the candidate directory.
333
+ * @returns `{ ok: true, path }` or `{ ok: false, message }`.
334
+ */
335
+ export function validateDirectory(path) {
336
+ const text = String(path ?? '').trim()
337
+ if (text === '') return { ok: false, message: 'path 不能为空' }
338
+ if (!isAbsolute(text)) return { ok: false, message: `path 必须是绝对路径:${text}` }
339
+ const absolute = resolve(text)
340
+ if (!existsSync(absolute)) return { ok: false, message: `目录不存在:${absolute}` }
341
+ try {
342
+ if (!statSync(absolute).isDirectory()) return { ok: false, message: `不是目录:${absolute}` }
343
+ } catch (error) {
344
+ return { ok: false, message: `无法访问 ${absolute}:${String(error?.message ?? error)}` }
345
+ }
346
+ return { ok: true, path: absolute }
347
+ }
348
+
349
+ /**
350
+ * Validate a label.
351
+ *
352
+ * @param label - the candidate label.
353
+ * @returns `{ ok: true, label }` or `{ ok: false, message }`.
354
+ */
355
+ export function validateLabel(label) {
356
+ const text = String(label ?? '').trim()
357
+ if (text === '') return { ok: false, message: 'label 不能为空' }
358
+ if (!LABEL_PATTERN.test(text)) {
359
+ return { ok: false, message: `label 只能是字母/数字/. _ -,且不超过 32 个字符:${text}` }
360
+ }
361
+ if (RESERVED_LABELS.has(text.toLowerCase())) return { ok: false, message: `label 不能使用保留字:${text}` }
362
+ return { ok: true, label: text }
363
+ }
364
+
365
+ /**
366
+ * Apply one store operation. Every branch validates first and returns a
367
+ * discriminated result, so both the Tool and the HTTP route share one behavior
368
+ * and one set of messages.
369
+ *
370
+ * @param state - normalized state (mutated only when the operation succeeds).
371
+ * @param operation - `{ op: 'add' | 'remove' | 'update', workspace, label, path, note }`.
372
+ * @returns `{ ok: true, message, entry }` or `{ ok: false, message }`.
373
+ */
374
+ export function applyOperation(state, operation) {
375
+ const workspaceCheck = validateDirectory(operation.workspace)
376
+ if (!workspaceCheck.ok) return { ok: false, message: `工作区目录无效:${workspaceCheck.message}` }
377
+ const workspacePath = workspaceCheck.path
378
+ const key = keyOf(workspacePath)
379
+ const current = state.workspaces[key] ?? { path: workspacePath, dirs: [] }
380
+
381
+ const labelCheck = validateLabel(operation.label)
382
+ if (!labelCheck.ok) return labelCheck
383
+ const labelKey = labelCheck.label.toLowerCase()
384
+ const at = current.dirs.findIndex((dir) => dir.label.toLowerCase() === labelKey)
385
+
386
+ if (operation.op === 'add') {
387
+ if (at !== -1) {
388
+ return { ok: false, message: `标签 ${current.dirs[at].label} 已存在(指向 ${current.dirs[at].path})。要改路径请用 update。` }
389
+ }
390
+ const directoryCheck = validateDirectory(operation.path)
391
+ if (!directoryCheck.ok) return directoryCheck
392
+ current.dirs.push({
393
+ label: labelCheck.label,
394
+ path: directoryCheck.path,
395
+ ...(typeof operation.note === 'string' && operation.note.trim() !== '' ? { note: operation.note.trim() } : {}),
396
+ })
397
+ state.workspaces[key] = current
398
+ return { ok: true, entry: current, message: `已为工作区 ${workspacePath} 登记 ${labelCheck.label} -> ${directoryCheck.path}` }
399
+ }
400
+
401
+ if (operation.op === 'remove') {
402
+ if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label}` }
403
+ const [removed] = current.dirs.splice(at, 1)
404
+ if (current.dirs.length === 0) delete state.workspaces[key]
405
+ else state.workspaces[key] = current
406
+ return { ok: true, entry: current.dirs.length === 0 ? { path: workspacePath, dirs: [] } : current, message: `已移除标签 ${removed.label}(${removed.path})` }
407
+ }
408
+
409
+ if (operation.op === 'update') {
410
+ if (at === -1) return { ok: false, message: `工作区 ${workspacePath} 没有标签 ${labelCheck.label};新增请用 add` }
411
+ const target = { ...current.dirs[at] }
412
+ if (operation.path !== undefined && operation.path !== null && String(operation.path).trim() !== '') {
413
+ const directoryCheck = validateDirectory(operation.path)
414
+ if (!directoryCheck.ok) return directoryCheck
415
+ target.path = directoryCheck.path
416
+ }
417
+ if (operation.note !== undefined) {
418
+ const note = String(operation.note).trim()
419
+ if (note === '') delete target.note
420
+ else target.note = note
421
+ }
422
+ current.dirs[at] = target
423
+ state.workspaces[key] = current
424
+ return { ok: true, entry: current, message: `已更新标签 ${target.label} -> ${target.path}` }
425
+ }
426
+
427
+ return { ok: false, message: `未知操作:${String(operation.op)}` }
428
+ }
429
+
430
+ /**
431
+ * Render one workspace's mapping for the model. Returns an empty string when
432
+ * the workspace has no registered directories, so an unused feature costs no
433
+ * prompt bytes.
434
+ *
435
+ * @param options - workspace path, its entry, and the effective sandbox facts.
436
+ * @returns the runtime-context text, or `''`.
437
+ */
438
+ export function renderDirectoryContext({ workspacePath, entry, sandboxMode }) {
439
+ if (entry === undefined || !Array.isArray(entry.dirs) || entry.dirs.length === 0) return ''
440
+ const restricted = sandboxMode !== undefined && sandboxMode !== 'danger-full-access'
441
+ const inside = (dir) => keyOf(dir) === keyOf(workspacePath) || keyOf(dir).startsWith(`${keyOf(workspacePath)}\\`) || keyOf(dir).startsWith(`${keyOf(workspacePath)}/`)
442
+
443
+ const lines = [
444
+ '工作区多目录映射(dsh-workspace-plus 插件维护)',
445
+ '',
446
+ `当前会话的 cwd(工作区根目录):\`${workspacePath}\``,
447
+ '',
448
+ '用户提到下列「标签」时,指的就是表中对应的绝对目录。请直接使用表中的绝对路径,',
449
+ '不要根据目录名相似、命名习惯或当前 cwd 去推测位置:',
450
+ '',
451
+ ]
452
+ for (const dir of entry.dirs) {
453
+ const note = dir.note === undefined ? '' : `(${dir.note})`
454
+ const outside = restricted && !inside(dir.path) ? ' ⚠ 在工作区根目录之外' : ''
455
+ lines.push(`- \`${dir.label}\` → \`${dir.path}\`${note}${outside}`)
456
+ }
457
+ lines.push(
458
+ '',
459
+ '规则:',
460
+ '1. 读写、编辑、搜索和命令中的路径都使用上表的绝对路径;不要把它当作相对于 cwd 的相对路径。',
461
+ '2. 标签不区分大小写。',
462
+ `3. 只在上表列出的目录和工作区根目录 \`${workspacePath}\` 内工作。不要扫描 cwd 的同级目录,也不要用 \`**/*\` 这类无范围模式在 cwd 下“先找找看”——要定位文件时,先用标签选定目录,再在该目录内按模式搜索。`,
463
+ '4. 上表没有覆盖的路径,先调用 workspace_dirs 工具确认,不要猜。',
464
+ '5. 用户要求增删改这份映射时,调用 workspace_dirs 工具,不要直接编辑状态文件。',
465
+ )
466
+ if (restricted) {
467
+ lines.push(
468
+ `6. 当前文件策略为 ${sandboxMode}:只有工作区根目录内的文件可直接写;标有 ⚠ 的目录需要工具自身的升级流程` +
469
+ '(如 write/edit 的 sandbox_permissions + justification),按工具返回的提示处理,不要因为它不在工作区内就直接放弃,也不要反复重试同一次写入。',
470
+ )
471
+ }
472
+ return lines.join('\n')
473
+ }
474
+
475
+ /**
476
+ * Render one workspace's mapping for a human/agent tool result.
477
+ *
478
+ * @param workspacePath - the workspace directory.
479
+ * @param entry - its registered directories.
480
+ * @returns a readable multi-line summary.
481
+ */
482
+ export function renderDirectoryList(workspacePath, entry) {
483
+ if (entry === undefined || entry.dirs.length === 0) {
484
+ return `工作区 ${workspacePath} 还没有登记任何目录。用 workspace_dirs add 添加,例如:\n action=add, workspace=${workspacePath}, label=backend, path=D:\\proj\\backend`
485
+ }
486
+ const lines = [`工作区 ${workspacePath} 已登记 ${entry.dirs.length} 个目录:`]
487
+ for (const dir of entry.dirs) {
488
+ lines.push(`- ${dir.label} -> ${dir.path}${dir.note === undefined ? '' : ` (${dir.note})`}`)
489
+ }
490
+ return lines.join('\n')
491
+ }
492
+
493
+ /** The closed action vocabulary of the `workspace_dirs` tool. */
494
+ export const TOOL_ACTIONS = ['list', 'add', 'remove', 'update']
495
+
496
+ /** The model-facing parameter schema of the `workspace_dirs` tool. */
497
+ export const TOOL_PARAMETERS = {
498
+ type: 'object',
499
+ properties: {
500
+ action: {
501
+ type: 'string',
502
+ enum: TOOL_ACTIONS,
503
+ description:
504
+ 'list reads the mapping; add registers a new labeled directory; remove drops a label; update changes a label\'s path or note.',
505
+ },
506
+ label: {
507
+ type: 'string',
508
+ description: 'Conversation label for the directory. Required for add/remove/update (letters, digits, ".", "_", "-", max 32).',
509
+ },
510
+ path: {
511
+ type: 'string',
512
+ description: 'Absolute directory path: required for add, optional for update (re-points the label).',
513
+ },
514
+ note: {
515
+ type: 'string',
516
+ description: 'Optional one-line description, shown to the agent and in the GUI.',
517
+ },
518
+ workspace: {
519
+ type: 'string',
520
+ description: 'Absolute path of the target workspace directory. Defaults to the calling session workspace (its cwd).',
521
+ },
522
+ },
523
+ required: ['action'],
524
+ additionalProperties: false,
525
+ }
526
+
527
+ /** The model-facing description of the `workspace_dirs` tool. */
528
+ export const TOOL_DESCRIPTION = [
529
+ 'Read or edit the extra labeled directories registered for a DSH workspace.',
530
+ 'A workspace has one root (the session cwd); this tool maintains the additional project folders that belong to it,',
531
+ 'each addressed in conversation by a short label. The current mapping is already in your context; call this tool to',
532
+ 'confirm it, or when the user asks to add, remove or re-point a labeled directory.',
533
+ 'Never guess a directory from a similar name — resolve it here first.',
534
+ ].join(' ')
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Plugin
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * Host-side entry point. Wires the store to the three consumers (prompt
542
+ * context, tool, HTTP route) and lets Cordis dispose all of them on unload.
543
+ *
544
+ * @param ctx - host plugin context.
545
+ */
546
+ export function apply(ctx) {
547
+ const file = stateFilePath()
548
+
549
+ /**
550
+ * Identity of the first-use (default) Workspace, resolved through the PUBLIC
551
+ * registry API: `initializeDefault` returns the recorded default immediately
552
+ * when one exists, returns `undefined` when automatic creation is ineligible,
553
+ * and only ever calls the directory resolver when it would actually create a
554
+ * workspace — so the probe passes a resolver that refuses, and a brand-new
555
+ * install simply reports "no default workspace" instead of creating one.
556
+ *
557
+ * `resolved` stays false when the registry is not usable yet (its service is
558
+ * registered before `[Service.init]` finishes, so `requireState()` can throw on
559
+ * a cold boot). Every later caller retries, which keeps a failed first attempt
560
+ * from silently enabling the feature on the default Workspace forever.
561
+ */
562
+ let defaultWorkspace = { resolved: false, id: null, path: null }
563
+
564
+ const resolveDefaultWorkspace = async (registry = ctx.get('workspaceRegistry')) => {
565
+ if (defaultWorkspace.resolved) return defaultWorkspace
566
+ if (registry === undefined || typeof registry.initializeDefault !== 'function') return defaultWorkspace
567
+ try {
568
+ const workspace = await registry.initializeDefault(() => {
569
+ throw new Error('dsh-workspace-plus: declined to create the default workspace')
570
+ })
571
+ defaultWorkspace = {
572
+ resolved: true,
573
+ id: workspace === undefined ? null : String(workspace.id),
574
+ path: workspace === undefined ? null : workspace.path,
575
+ }
576
+ } catch {
577
+ // Registry not started yet, or the probe was declined: retry on the next call.
578
+ }
579
+ return defaultWorkspace
580
+ }
581
+
582
+ /**
583
+ * Warm the cache as soon as the registry service is usable — the synchronous
584
+ * prompt path can then read a settled value from the very first request.
585
+ */
586
+ ctx.inject(['workspaceRegistry'], (registryScope) => {
587
+ void resolveDefaultWorkspace(registryScope.workspaceRegistry)
588
+ })
589
+
590
+ /** Read-modify-write through the single state file, serialized per call. */
591
+ const mutate = (operation) => {
592
+ const state = readState(file)
593
+ const result = applyOperation(state, operation)
594
+ if (result.ok) writeState(file, state)
595
+ return result
596
+ }
597
+
598
+ /** Read-modify-write for the settings block. */
599
+ const mutateSettings = (patch) => {
600
+ const state = readState(file)
601
+ const result = applySettings(state, patch)
602
+ if (result.ok) writeState(file, state)
603
+ return result
604
+ }
605
+
606
+ /** Whether the feature runs for a workspace path, given the current settings and probe. */
607
+ const enabledFor = (workspacePath, settings) =>
608
+ isFeatureEnabledFor({ settings, workspacePath, defaultWorkspacePath: defaultWorkspace.path })
609
+
610
+ const snapshot = () => {
611
+ const registry = ctx.get('workspaceRegistry')
612
+ const state = readState(file)
613
+ const workspaces = registry === undefined ? [] : registry.list()
614
+ return {
615
+ registryAvailable: registry !== undefined,
616
+ settings: { ...state.settings },
617
+ defaultWorkspaceId: defaultWorkspace.id,
618
+ defaultWorkspacePath: defaultWorkspace.path,
619
+ workspaces: workspaces.map((workspace) => {
620
+ const entry = entryFor(state, workspace.path)
621
+ const isDefault = defaultWorkspace.path !== null && keyOf(defaultWorkspace.path) === keyOf(workspace.path)
622
+ return {
623
+ id: String(workspace.id),
624
+ // The workspace's settings key, so the browser half can address a
625
+ // per-workspace switch without re-implementing the platform's path
626
+ // folding (case-insensitive on Windows, exact elsewhere).
627
+ key: keyOf(workspace.path),
628
+ path: workspace.path,
629
+ title: workspace.title,
630
+ isDefault,
631
+ enabled: enabledFor(workspace.path, state.settings),
632
+ dirs: entry === undefined ? [] : entry.dirs.map((dir) => ({ ...dir })),
633
+ }
634
+ }),
635
+ }
636
+ }
637
+
638
+ // --- 1. Make the label table part of every request in that workspace ------
639
+ // `context()` and `register()` each install their own effect on this plugin's
640
+ // fiber, so both contributions disappear when the plugin unloads.
641
+ ctx.systemPrompt.context({
642
+ name: CONTEXT_NAME,
643
+ order: CONTEXT_ORDER,
644
+ text: (context) => {
645
+ const session = context.agent?.session
646
+ const workspacePath = session?.header?.cwd
647
+ if (typeof workspacePath !== 'string' || workspacePath === '') return ''
648
+ const state = readState(file)
649
+ // The default Workspace stays untouched until the user opts in.
650
+ if (!enabledFor(workspacePath, state.settings)) return ''
651
+ const entry = entryFor(state, workspacePath)
652
+ if (entry === undefined || entry.dirs.length === 0) return ''
653
+ const policy = ctx.get('sandboxPolicy')
654
+ const sandboxMode = session === undefined ? undefined : policy?.resolve({ session })?.mode
655
+ return renderDirectoryContext({ workspacePath, entry, sandboxMode })
656
+ },
657
+ })
658
+
659
+ // --- 2. Let the agent inspect and edit the mapping ------------------------
660
+ ctx.tools.register({
661
+ name: TOOL_NAME,
662
+ description: TOOL_DESCRIPTION,
663
+ parameters: TOOL_PARAMETERS,
664
+ output: {
665
+ schema: { type: 'string' },
666
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
667
+ },
668
+ async execute(args, exec) {
669
+ const session = exec.agent?.session
670
+ const action = args?.action
671
+ if (!TOOL_ACTIONS.includes(action)) {
672
+ return `Error: action 必须是 ${TOOL_ACTIONS.join(' / ')} 之一,收到 ${JSON.stringify(action ?? null)}`
673
+ }
674
+ const workspace =
675
+ typeof args.workspace === 'string' && args.workspace.trim() !== '' ? args.workspace : session?.header?.cwd
676
+ if (typeof workspace !== 'string' || workspace === '') {
677
+ return 'Error: 没有可用的工作区目录。请通过 workspace 参数显式指定一个绝对路径。'
678
+ }
679
+ // The tool can afford a fresh probe, so its gate is never decided on a
680
+ // cache that a cold boot could not fill.
681
+ await resolveDefaultWorkspace()
682
+ const state = readState(file)
683
+ if (!enabledFor(workspace, state.settings)) {
684
+ return (
685
+ `Error: ${resolve(workspace)} 已在 workspace+ 里停用。` +
686
+ '请在 GUI 的 设置 → workspace+ 打开这个工作区的开关,或改用其它工作区。'
687
+ )
688
+ }
689
+ if (action === 'list') {
690
+ return renderDirectoryList(resolve(workspace), entryFor(state, workspace))
691
+ }
692
+ const result = mutate({
693
+ op: action,
694
+ workspace,
695
+ label: args.label,
696
+ path: args.path,
697
+ note: args.note,
698
+ })
699
+ if (!result.ok) return `Error: ${result.message}`
700
+ return `${result.message}\n\n${renderDirectoryList(resolve(workspace), result.entry)}`
701
+ },
702
+ })
703
+
704
+ // --- 3. Give the browser half a same-origin JSON endpoint -----------------
705
+ ctx.inject(['webServer'], (webCtx) => {
706
+ webCtx.effect(
707
+ () =>
708
+ webCtx.webServer.register({
709
+ kind: 'exact',
710
+ path: API_PATH,
711
+ handler: async (req, res) => {
712
+ const connection = ctx.get('connection')
713
+ const admission = connection?.admit?.(req)
714
+ if (admission !== undefined && 'rejection' in admission) {
715
+ res.writeHead(admission.rejection)
716
+ res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
717
+ return
718
+ }
719
+ const send = (status, payload) => {
720
+ const body = JSON.stringify(payload)
721
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
722
+ res.end(body)
723
+ }
724
+ try {
725
+ // The settings page must see the default-workspace identity even on
726
+ // the very first request after boot, so the probe is awaited here.
727
+ await resolveDefaultWorkspace()
728
+ if (req.method === 'GET') {
729
+ send(200, { ok: true, ...snapshot() })
730
+ return
731
+ }
732
+ if (req.method !== 'POST') {
733
+ send(405, { ok: false, message: 'method not allowed' })
734
+ return
735
+ }
736
+ const chunks = []
737
+ let bytes = 0
738
+ for await (const chunk of req) {
739
+ bytes += chunk.length
740
+ if (bytes > 64 * 1024) {
741
+ send(413, { ok: false, message: 'request body too large' })
742
+ return
743
+ }
744
+ chunks.push(chunk)
745
+ }
746
+ let payload
747
+ try {
748
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
749
+ } catch {
750
+ send(400, { ok: false, message: 'body is not JSON' })
751
+ return
752
+ }
753
+ if (payload?.op === 'settings') {
754
+ const settingsResult = mutateSettings(payload?.settings)
755
+ if (!settingsResult.ok) {
756
+ send(200, { ok: false, message: settingsResult.message, ...snapshot() })
757
+ return
758
+ }
759
+ send(200, { ok: true, message: settingsResult.message, ...snapshot() })
760
+ return
761
+ }
762
+ const operation = {
763
+ op: payload?.op,
764
+ workspace: payload?.workspace,
765
+ label: payload?.label,
766
+ path: payload?.path,
767
+ note: payload?.note,
768
+ }
769
+ if (operation.op === undefined) {
770
+ send(400, { ok: false, message: 'missing op' })
771
+ return
772
+ }
773
+ const result = mutate(operation)
774
+ if (!result.ok) {
775
+ send(200, { ok: false, message: result.message, ...snapshot() })
776
+ return
777
+ }
778
+ send(200, { ok: true, message: result.message, ...snapshot() })
779
+ } catch (error) {
780
+ ctx.logger?.warn?.(error)
781
+ send(500, { ok: false, message: String(error?.message ?? error) })
782
+ }
783
+ },
784
+ }),
785
+ 'dsh-workspace-plus: /workspaceplus/api route',
786
+ )
787
+ })
788
+
789
+ console.log(`[${name}] host half loaded — workspace label routing is active (${file})`)
790
+ }