dsh-wsl-desktop 0.2.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.
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Build the WSL agent preset from a shipped one.
3
+ *
4
+ * A session's execution world is chosen by its preset, not by a global router:
5
+ * the model also needs a different tool dialect (bash rather than PowerShell),
6
+ * and one process-wide provider could not vary that per session. This module
7
+ * therefore rewrites a shipped preset's entry list — it removes the rows that
8
+ * name the host execution world and appends one `isolate` group that mounts the
9
+ * WSL world plus the tools that consume it.
10
+ *
11
+ * The transform is pure text over the loader's entry list, so it is unit
12
+ * testable without the harness. Rows are matched by their top-level `- id:`
13
+ * boundary; only top-level (two-space indented) keys are rewritten, so a nested
14
+ * entry inside a group's `config:` is never touched.
15
+ * @module dsh-wsl-desktop/wsl/preset
16
+ */
17
+
18
+ import { join } from 'node:path'
19
+ import { pathToFileURL } from 'node:url'
20
+
21
+ /**
22
+ * Decide what happens to one directory inside this plugin's preset namespace.
23
+ *
24
+ * The namespace is a name prefix, so it is shared with anything a user chooses
25
+ * to call `wsl-…`. Only a directory this plugin wrote — one carrying its marker
26
+ * — may be withdrawn; an unmarked one is the user's and is reported instead of
27
+ * deleted.
28
+ * @param {{ name: string, prefix: string, expected: boolean, marked: boolean }} entry - one directory.
29
+ * @returns {'ignore' | 'keep' | 'withdraw' | 'unmanaged'} the disposition.
30
+ */
31
+ export function sweepDecision({ name, prefix, expected, marked }) {
32
+ if (!name.startsWith(prefix)) return 'ignore'
33
+ if (expected) return 'keep'
34
+ return marked ? 'withdraw' : 'unmanaged'
35
+ }
36
+
37
+ /** Rows that name the host execution world and must not survive in a WSL preset. */
38
+ export const WORLD_ROWS = new Set([
39
+ 'tool-bash',
40
+ 'tool-pwsh',
41
+ 'tool-fs',
42
+ 'tool-fs-search',
43
+ 'str-replace-editor',
44
+ ])
45
+
46
+ /**
47
+ * Host-execution-world MODULE names, matched against a row's `name` at every
48
+ * depth. Beyond the five canonical rows, the persistent/terminal family ships
49
+ * NESTED in the minimal preset's `persistent-shell` group with non-canonical
50
+ * ids — an enabled host PowerShell persistent tool inside a preset whose name
51
+ * and persona assert distro-contained execution. `tool-bash`/`tool-fs` are
52
+ * deliberately absent: those names are re-mounted in their WSL form inside
53
+ * the wsl-world group; their host-world forms are removed by id.
54
+ */
55
+ export const WORLD_MODULE_NAMES = new Set([
56
+ '@deepseek-ai/dsh-tool-pwsh',
57
+ '@deepseek-ai/dsh-tool-fs-search',
58
+ '@deepseek-ai/dsh-tool-str-replace-editor',
59
+ '@deepseek-ai/dsh-tool-bash-persistent',
60
+ '@deepseek-ai/dsh-tool-pwsh-persistent',
61
+ '@deepseek-ai/dsh-terminal-bash',
62
+ '@deepseek-ai/dsh-terminal',
63
+ ])
64
+
65
+ /**
66
+ * Whether one row `name` loads a host-execution-world module. Matches the
67
+ * bare package specifier and file-URL/path spellings ending in the package
68
+ * basename (with or without .js).
69
+ * @param {string} name - the row's module name.
70
+ * @returns {boolean} true when the module belongs to the host execution world.
71
+ */
72
+ export function isHostWorldModule(name) {
73
+ if (WORLD_MODULE_NAMES.has(name)) return true
74
+ const base = name.split(/[\\/]/).pop() ?? ''
75
+ const stem = base.replace(/\.js$/u, '')
76
+ return WORLD_MODULE_NAMES.has(stem)
77
+ }
78
+
79
+ /**
80
+ * Build the `wsl-world` isolate group as a plugin-entry OBJECT.
81
+ *
82
+ * 0.1.7 presets are registered programmatically
83
+ * (`agentPresets.register({ id, name, plugins })`) with entry objects rather
84
+ * than written as YAML files — the group's shape mirrors the loader's entry
85
+ * options: `{ id?, name, group?, isolate?, config? }`.
86
+ * @param {object} options - generation options.
87
+ * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
88
+ * @param {string} options.shellPath - module specifier of the WSL shell executor.
89
+ * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
90
+ * @param {string | undefined} options.distro - distribution for paths that do not name one.
91
+ * @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
92
+ * @returns {object} the group entry.
93
+ */
94
+ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }) {
95
+ const distroConfig = distro === undefined ? {} : { distro }
96
+ const config = [
97
+ { id: 'subprocess-wsl', name: subprocessPath, ...distroConfig },
98
+ { id: 'shell-wsl', name: shellPath, ...distroConfig },
99
+ { id: 'fs-wsl', name: fsPath, ...distroConfig },
100
+ // No terminal registry row: the Web terminal controller spawns through
101
+ // `agent.ctx.get('subprocess').spawnTerminal(...)`, so the realm's
102
+ // subprocess provider already puts the PTY inside the distribution.
103
+ { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash' },
104
+ { id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
105
+ ...(includeEditor ? [{ id: 'str-replace-editor', name: '@deepseek-ai/dsh-tool-str-replace-editor' }] : []),
106
+ ]
107
+ return {
108
+ id: 'wsl-world',
109
+ name: 'cordis:group',
110
+ group: true,
111
+ isolate: { shell: true, fs: true, subprocess: true },
112
+ config,
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Append the WSL path-dialect sentence to a persona row OBJECT.
118
+ * @param {object} row - the persona entry.
119
+ * @returns {boolean} whether the row was amended.
120
+ */
121
+ function appendPersonaSuffixObject(row) {
122
+ const config = row.config ?? (row.config = {})
123
+ for (const field of ['suffix', 'text', 'prefix']) {
124
+ if (typeof config[field] === 'string' && config[field].length > 0) {
125
+ config[field] = `${config[field]} ${WSL_PERSONA_SENTENCE}`
126
+ return true
127
+ }
128
+ }
129
+ config.suffix = WSL_PERSONA_SENTENCE
130
+ return true
131
+ }
132
+
133
+ /**
134
+ * Transform a base preset's plugin ENTRY OBJECTS into the WSL variant's list.
135
+ *
136
+ * Same semantics as the historical YAML rewrite: drop the rows that name the
137
+ * host execution world, amend the persona with the path-dialect sentence,
138
+ * rewrite relative row names against the source directory, and append the
139
+ * `wsl-world` isolate group.
140
+ * @param {readonly object[]} basePlugins - the base preset's plugin rows.
141
+ * @param {object} options - generation options.
142
+ * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
143
+ * @param {string} options.shellPath - module specifier of the WSL shell executor.
144
+ * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
145
+ * @param {string | undefined} options.distro - distribution for paths that do not name one.
146
+ * @param {string | undefined} options.sourceDir - directory the source preset lives in.
147
+ * @returns {{ plugins: object[], removed: string[] }} the variant rows and what was dropped.
148
+ */
149
+ /**
150
+ * Clone one entry row deeply enough to transform it safely. Group rows carry
151
+ * their children under `config` as an ARRAY (recursive tree), so the clone
152
+ * must preserve arrays as arrays — a naive `{...config}` spread would turn a
153
+ * group's child list into an index-keyed object and the entry-list validator
154
+ * would refuse the row ("must hold a list of plugin rows").
155
+ * @param {object} row - the row to clone.
156
+ * @returns {object} the cloned row.
157
+ */
158
+ function cloneRow(row) {
159
+ const clone = { ...row }
160
+ if (Array.isArray(clone.config)) {
161
+ clone.config = clone.config.map((child) => (child !== null && typeof child === 'object' ? cloneRow(child) : child))
162
+ } else if (clone.config !== null && typeof clone.config === 'object') {
163
+ clone.config = { ...clone.config }
164
+ }
165
+ return clone
166
+ }
167
+
168
+ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
169
+ const kept = []
170
+ const removed = []
171
+ let includeEditor = false
172
+
173
+ /**
174
+ * Recursively prune host-execution-world rows from one entry. Classified by
175
+ * module name at EVERY depth (row ids are optional and arbitrary — the
176
+ * shipped minimal preset nests enabled host PowerShell rows with
177
+ * non-canonical ids inside a group), with the id fast path kept for
178
+ * canonical rows. A group whose children are all pruned is dropped: it
179
+ * holds nothing. Returns the cloned+pruned row, or null when removed.
180
+ */
181
+ const prune = (row) => {
182
+ if (row === null || typeof row !== 'object') return row
183
+ if (row.id !== undefined && WORLD_ROWS.has(row.id)) {
184
+ removed.push(row.id)
185
+ if (row.id === 'str-replace-editor') includeEditor = true
186
+ return null
187
+ }
188
+ if (typeof row.name === 'string' && isHostWorldModule(row.name)) {
189
+ removed.push(row.id ?? row.name)
190
+ if (typeof row.name === 'string' && row.name.includes('str-replace-editor')) includeEditor = true
191
+ return null
192
+ }
193
+ // The registry stores the caller's objects BY REFERENCE — mutating a row
194
+ // here would pollute the shared base definition. Clone per row;
195
+ // structuredClone is not safe: loader rows may carry JsExpr nodes.
196
+ const clone = cloneRow(row)
197
+ if (clone.group === true && Array.isArray(clone.config)) {
198
+ const children = []
199
+ for (const child of clone.config) {
200
+ const prunedChild = prune(child)
201
+ if (prunedChild !== null) children.push(prunedChild)
202
+ }
203
+ if (children.length === 0) {
204
+ removed.push(clone.id ?? clone.name ?? 'empty group')
205
+ return null
206
+ }
207
+ clone.config = children
208
+ }
209
+ if (clone.id === 'persona') appendPersonaSuffixObject(clone)
210
+ if (sourceDir !== undefined && typeof clone.name === 'string' && clone.name.startsWith('./')) {
211
+ // The loader imports row names as URLs / bare specifiers; a relative
212
+ // row resolves to a file:// URL spelling of its absolute location.
213
+ clone.name = pathToFileURL(join(sourceDir, clone.name.slice(2))).href
214
+ }
215
+ return clone
216
+ }
217
+
218
+ for (const row of basePlugins) {
219
+ const pruned = prune(row)
220
+ if (pruned !== null) kept.push(pruned)
221
+ }
222
+ kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }))
223
+ return { plugins: kept, removed }
224
+ }
225
+
226
+ /**
227
+ * The sentence appended to the preset's persona.
228
+ *
229
+ * A session's recorded cwd is the UNC spelling (`\\wsl.localhost\<distro>\…`)
230
+ * because that is the only form the Windows-side harness accepts, while the
231
+ * shell in that session reports a Linux path. Without this the model is told one
232
+ * spelling and observes another.
233
+ */
234
+ export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are Windows spellings of directories inside that WSL distribution — \\\\wsl.localhost\\<distro>\\home\\me is /home/me there. Every command runs in the distribution, so use Linux paths, and reach Windows files as /mnt/<drive>/…'
235
+
236
+ /**
237
+ * Append the WSL sentence to a persona row's text field.
238
+ *
239
+ * Handles the three shapes a preset may use: an inline `suffix`, a block-scalar
240
+ * `suffix`, and a persona that carries only `text`. A row with none of them
241
+ * gains a `suffix` under `config:`.
242
+ * @param {{ id: string | null, lines: string[] }} block - the persona block.
243
+ * @returns {boolean} whether the block was amended.
244
+ */
245
+ export function appendPersonaSuffix(block) {
246
+ const lines = block.lines
247
+ const configIndex = lines.findIndex((line) => /^(\s*)config:\s*$/.test(line))
248
+ if (configIndex === -1) return false
249
+ const configIndent = (/^(\s*)config:\s*$/.exec(lines[configIndex])?.[1] ?? '').length
250
+ const childIndent = configIndent + 2
251
+ const child = new RegExp(`^ {${childIndent}}(suffix|text|prefix):(.*)$`)
252
+ for (const field of ['suffix', 'text', 'prefix']) {
253
+ const index = lines.findIndex((line, at) => at > configIndex && child.test(line) && new RegExp(`^ {${childIndent}}${field}:`).test(line))
254
+ if (index === -1) continue
255
+ const value = (new RegExp(`^ {${childIndent}}${field}:(.*)$`).exec(lines[index])?.[1] ?? '').trim()
256
+ // A block scalar (`>-`, `|`, `|-`, …) continues on the following lines.
257
+ if (value === '' || /^[|>][-+]?\d*$/.test(value)) {
258
+ lines.splice(index + 1, 0, `${' '.repeat(childIndent + 2)}${WSL_PERSONA_SENTENCE}`)
259
+ } else if ((value.startsWith("'") && value.endsWith("'") && value.length >= 2)
260
+ || (value.startsWith('"') && value.endsWith('"') && value.length >= 2)) {
261
+ // A quoted scalar must grow INSIDE its quotes; appending after a closing
262
+ // quote would produce invalid YAML.
263
+ const quote = value[0]
264
+ const sentence = quote === "'" ? WSL_PERSONA_SENTENCE.replace(/'/g, "''") : WSL_PERSONA_SENTENCE
265
+ lines[index] = `${lines[index].slice(0, -1)} ${sentence}${quote}`
266
+ } else {
267
+ lines[index] = `${lines[index]} ${WSL_PERSONA_SENTENCE}`
268
+ }
269
+ return true
270
+ }
271
+ // No text field of its own: gain one as the first key under `config:`.
272
+ lines.splice(configIndex + 1, 0, `${' '.repeat(childIndent)}suffix: ${WSL_PERSONA_SENTENCE}`)
273
+ return true
274
+ }
275
+
276
+ /**
277
+ * Quote one scalar for a YAML single-quoted value.
278
+ * @param {string} value - the raw value.
279
+ * @returns {string} the quoted scalar.
280
+ */
281
+ function quote(value) {
282
+ return `'${value.replace(/'/g, "''")}'`
283
+ }
284
+
285
+ /**
286
+ * Split an entry list into top-level blocks.
287
+ * @param {string} source - the loader entry list.
288
+ * @returns {Array<{ id: string | null, lines: string[] }>} the blocks in order.
289
+ */
290
+ export function splitBlocks(source) {
291
+ const blocks = []
292
+ let current = { id: null, lines: [] }
293
+ for (const line of source.split('\n')) {
294
+ const match = /^- id:\s*(.+?)\s*$/.exec(line)
295
+ if (match !== null) {
296
+ if (current.lines.length > 0 || current.id !== null) blocks.push(current)
297
+ // Normalise the captured id so every YAML spelling of the same row
298
+ // resolves to one canonical id for world-row removal: strip trailing
299
+ // comments, YAML anchors (`&a name`), and surrounding quotes.
300
+ let id = match[1].replace(/\s+#.*$/, '').trim()
301
+ if (id.startsWith('&')) id = id.replace(/^&\S+\s+/, '')
302
+ id = id.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
303
+ current = { id, lines: [line] }
304
+ continue
305
+ }
306
+ current.lines.push(line)
307
+ }
308
+ blocks.push(current)
309
+ return blocks
310
+ }
311
+
312
+ /**
313
+ * Build the `wsl-world` group that carries the WSL execution world.
314
+ *
315
+ * The group isolates every service a row inside it publishes, so one session
316
+ * can run in a distribution while the process keeps serving Windows sessions.
317
+ * `subprocess-wsl` precedes the consumers that inject it: the shell executor
318
+ * hands it a Linux argv and lets the provider own the `wsl.exe` wrapper.
319
+ * @param {object} options - generation options.
320
+ * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
321
+ * @param {string} options.shellPath - module specifier of the WSL shell executor.
322
+ * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
323
+ * @param {string | undefined} options.distro - distribution for paths that do not name one.
324
+ * @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
325
+ * @returns {string[]} the group's YAML lines.
326
+ */
327
+ function worldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }) {
328
+ const distroConfig = distro === undefined ? [] : [' config:', ` distro: ${quote(distro)}`]
329
+ return [
330
+ '- id: wsl-world',
331
+ ' name: cordis:group',
332
+ ' group: true',
333
+ ' isolate:',
334
+ ' shell: true',
335
+ ' fs: true',
336
+ ' subprocess: true',
337
+ ' config:',
338
+ ' - id: subprocess-wsl',
339
+ ` name: ${quote(subprocessPath)}`,
340
+ ...distroConfig,
341
+ ' - id: shell-wsl',
342
+ ` name: ${quote(shellPath)}`,
343
+ ...distroConfig,
344
+ ' - id: fs-wsl',
345
+ ` name: ${quote(fsPath)}`,
346
+ ...distroConfig,
347
+ // No terminal registry row: the Web terminal controller spawns through
348
+ // `agent.ctx.get('subprocess').spawnTerminal(...)`
349
+ // (packages/api/terminal-controller/src/index.ts:333,346), so the realm's
350
+ // subprocess provider already puts the PTY inside the distribution. A
351
+ // `terminals` realm would only serve the model's persistent-shell tool,
352
+ // which this preset does not mount.
353
+ ' - id: tool-bash',
354
+ " name: '@deepseek-ai/dsh-tool-bash'",
355
+ ' - id: tool-fs',
356
+ " name: '@deepseek-ai/dsh-tool-fs'",
357
+ ...includeEditor
358
+ ? [' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'"]
359
+ : [],
360
+ ]
361
+ }
362
+
363
+ /**
364
+ * Rewrite relative row names so they still resolve from the variant directory.
365
+ *
366
+ * A preset may name a row by a path relative to its own directory
367
+ * (`./tool-bootstrap.mjs`). The variant lives in a different directory, so the
368
+ * relative spelling would resolve against the wrong base and the preset would
369
+ * fail to mount.
370
+ * @param {{ lines: string[] }} block - one top-level entry.
371
+ * @param {string} sourceDir - directory of the preset being derived from.
372
+ * @returns {number} how many names were rewritten.
373
+ */
374
+ export function rewriteRelativeNames(block, sourceDir) {
375
+ let rewritten = 0
376
+ block.lines = block.lines.map((line) => {
377
+ const match = /^(\s*)name:\s*['"]?\.\/([^'"]+)['"]?\s*$/.exec(line)
378
+ if (match === null) return line
379
+ const absolute = join(sourceDir, match[2]).replace(/\\/g, '/')
380
+ rewritten += 1
381
+ return `${match[1]}name: '${absolute}'`
382
+ })
383
+ return rewritten
384
+ }
385
+
386
+ /**
387
+ * Rewrite one shipped preset into its WSL variant.
388
+ *
389
+ * The whole preset is wrapped in the realm rather than sitting beside it. A
390
+ * sibling group would leave every consumer outside it — a persistent-shell
391
+ * group, a hand-written tool row — resolving the host's providers, which is the
392
+ * Windows execution world; wrapping makes the realm the preset's own scope, so
393
+ * every row resolves the WSL providers. Rows that hardcode Windows tooling are
394
+ * dropped and re-mounted in their WSL form.
395
+ * @param {string} source - the shipped preset's `agent.cordis.yml` text.
396
+ * @param {object} options - generation options.
397
+ * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
398
+ * @param {string} options.shellPath - module specifier of the WSL shell executor.
399
+ * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
400
+ * @param {string} [options.distro] - distribution for paths that do not name one.
401
+ * @param {string} [options.sourceDir] - directory the source preset lives in.
402
+ * @returns {{ yaml: string, removed: string[], added: boolean }} the rewritten preset and what changed.
403
+ */
404
+ export function renderWslPreset(source, options) {
405
+ const blocks = splitBlocks(source)
406
+ const kept = []
407
+ const removed = []
408
+ let includeEditor = false
409
+ let personaAmended = false
410
+ for (const block of blocks) {
411
+ if (block.id !== null && WORLD_ROWS.has(block.id)) {
412
+ removed.push(block.id)
413
+ if (block.id === 'str-replace-editor') includeEditor = true
414
+ continue
415
+ }
416
+ if (block.id === 'persona') personaAmended = appendPersonaSuffix(block)
417
+ if (options.sourceDir !== undefined) rewriteRelativeNames(block, options.sourceDir)
418
+ kept.push(block)
419
+ }
420
+ // `split('\n')` consumed the separators, so blocks rejoin with one newline;
421
+ // joining with '' would glue the next `- id:` onto the previous line.
422
+ const body = kept.map((block) => block.lines.join('\n')).join('\n')
423
+ // One level down: the original entry list becomes the group's `config` list.
424
+ const nested = body
425
+ .split('\n')
426
+ .map((line) => (line.trim() === '' ? line : ` ${line}`))
427
+ .join('\n')
428
+ const group = worldGroup({
429
+ subprocessPath: options.subprocessPath,
430
+ shellPath: options.shellPath,
431
+ fsPath: options.fsPath,
432
+ distro: options.distro,
433
+ includeEditor,
434
+ })
435
+ return {
436
+ yaml: `${group.join('\n')}\n${nested}\n`,
437
+ removed,
438
+ added: true,
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Render the preset's display metadata.
444
+ * @param {{ name: string, description: string }} metadata - display name and description.
445
+ * @returns {string} the `preset.yml` text.
446
+ */
447
+ export function renderPresetMetadata(metadata) {
448
+ // Quote only when the value contains characters that YAML would
449
+ // misinterpret; plain alphanumeric + CJK + basic punctuation stays unquoted.
450
+ const scalar = (value) => {
451
+ const s = String(value)
452
+ if (/^[\w.\-\u4e00-\u9fff\u3000-\u303f\u00b7(): ]+$/u.test(s) && !/^\s|\s$/.test(s) && !s.includes(': ') && !s.startsWith('#')) return s
453
+ return `'${s.replace(/'/g, "''")}'`
454
+ }
455
+ return `name: ${scalar(metadata.name)}\ndescription: ${scalar(metadata.description)}\n`
456
+ }