@chronosun/dsh-wsl 0.0.0-stage → 1.5.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,79 @@
1
+ # dsh-wsl — M1a slice:
2
+ # 1) a row named after the package: the panel's host routes + the client half;
3
+ # 2) the WSL preset (bash/terminal/jobs run inside a local WSL distribution);
4
+ # 3) a diagnostic preset with no world, to tell a declaration problem from a
5
+ # world-activation problem.
6
+ - insert:
7
+ - id: dsh-wsl
8
+ name: '@chronosun/dsh-wsl'
9
+ - id: preset-wsl
10
+ name: '@deepseek-ai/dsh-agent-preset'
11
+ config:
12
+ id: wsl
13
+ name: WSL
14
+ description: Linux execution world inside a local WSL distribution.
15
+ order: 5
16
+ plugins:
17
+ - id: persona
18
+ name: '@deepseek-ai/dsh-persona'
19
+ config:
20
+ prefix: You are a coding agent powered by the {{model}} model.
21
+ - id: time-context
22
+ name: '@deepseek-ai/dsh-time-context'
23
+ - id: wsl-world
24
+ name: cordis:group
25
+ group: true
26
+ isolate:
27
+ subprocess: true
28
+ shell: true
29
+ fs: true
30
+ config:
31
+ - id: wsl-subprocess
32
+ name: '@chronosun/dsh-wsl/world'
33
+ - id: fs-wsl
34
+ name: '@chronosun/dsh-wsl/fs'
35
+ - id: bash-local
36
+ name: '@deepseek-ai/dsh-bash-local'
37
+ - id: tool-bash
38
+ name: '@deepseek-ai/dsh-tool-bash'
39
+ - id: tool-fs
40
+ name: '@deepseek-ai/dsh-tool-fs'
41
+ config:
42
+ applyPatch: true
43
+ - id: search-tools
44
+ name: '@chronosun/dsh-wsl/search'
45
+ - id: wsl-prompt
46
+ name: '@chronosun/dsh-wsl/prompt'
47
+ - id: agent-instructions
48
+ name: '@deepseek-ai/dsh-agent-instructions'
49
+ config:
50
+ maxBytes: 65536
51
+ - id: skill-filesystem
52
+ name: '@deepseek-ai/dsh-skill-filesystem'
53
+ config:
54
+ # The WSL filesystem exposes no watcher, so the catalog is
55
+ # collected once instead of being invalidated live.
56
+ watch: false
57
+ - id: tool-skill
58
+ name: '@deepseek-ai/dsh-tool-skill'
59
+ - id: tool-todo
60
+ name: '@deepseek-ai/dsh-tool-todo'
61
+ config:
62
+ allowParallelInProgress: true
63
+ - id: tool-ask-user
64
+ name: '@deepseek-ai/dsh-tool-ask-user'
65
+ - id: tool-web
66
+ name: '@deepseek-ai/dsh-tool-web'
67
+ config:
68
+ fetch: true
69
+ searchTimeoutMs: 60000
70
+ - id: present
71
+ name: '@deepseek-ai/dsh-tool-present'
72
+ - id: command-goal
73
+ name: '@deepseek-ai/dsh-command-goal'
74
+ - id: tool-goal
75
+ name: '@deepseek-ai/dsh-tool-goal'
76
+ - id: tool-worktree-wsl
77
+ name: '@chronosun/dsh-wsl/worktree'
78
+ - id: tool-jobs
79
+ name: '@deepseek-ai/dsh-tool-jobs'
package/fs-service.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // `ctx.fs` for the WSL world. Mirrors world.js: the host's FileSystem base is
2
+ // used when resolvable, otherwise the service is registered through Context.
3
+ // Delegation (not inheritance) keeps the factory free of host imports and
4
+ // avoids `#private` brand checks through Cordis' service proxy.
5
+ import { registry } from './src/registry.mjs'
6
+ import { createWslFileSystem } from './src/fs.mjs'
7
+
8
+ let Base = null
9
+ try {
10
+ const mod = await import('@deepseek-ai/dsh-fs')
11
+ Base = mod.FileSystem ?? mod.default ?? null
12
+ } catch {
13
+ Base = null
14
+ }
15
+
16
+ class ServiceShim {
17
+ constructor(ctx) {
18
+ this.ctx = ctx
19
+ if (typeof ctx.set !== 'function') {
20
+ throw new Error('fs-wsl: this Harness build exposes neither the FileSystem base class nor ctx.set')
21
+ }
22
+ ctx.provide?.('fs')
23
+ ctx.set('fs', this)
24
+ }
25
+ }
26
+
27
+ const Parent = Base ?? ServiceShim
28
+ const METHODS = [
29
+ 'resolve', 'processPath', 'processPathFromHostPath', 'fileUrl', 'contains', 'stat', 'lstat',
30
+ 'readText', 'streamText', 'readBytes', 'readByteRange', 'listDir', 'writeText', 'editText',
31
+ 'patchText', 'deleteFile', 'moveFile', 'watch',
32
+ ]
33
+
34
+ export default class WslFileSystemService extends Parent {
35
+ constructor(ctx, config = {}) {
36
+ super(ctx)
37
+ if (!this.ctx) this.ctx = ctx
38
+ const user = config.user ?? ''
39
+ // One provider for every distribution: each target carries its own, so a
40
+ // workspace in another distro resolves through that distro's broker.
41
+ this.impl = createWslFileSystem({
42
+ brokerFor: (distro) => registry.get(distro ?? '', user),
43
+ defaultDistro: config.distro ?? '',
44
+ defaultCwd: config.defaultCwd ?? '/',
45
+ })
46
+ }
47
+ }
48
+
49
+ for (const method of METHODS) {
50
+ Object.defineProperty(WslFileSystemService.prototype, method, {
51
+ configurable: true,
52
+ writable: true,
53
+ value: function delegate(...args) {
54
+ return this.impl[method](...args)
55
+ },
56
+ })
57
+ }
package/host.js ADDED
@@ -0,0 +1,262 @@
1
+ // Host half: the WSL panel's data routes.
2
+ // Each endpoint is an exact route: prefix matching was measured to not cover
3
+ // sub-paths here, and exact routes are unambiguous. Handler routes (not
4
+ // resources) are used because resources answer GET/HEAD only.
5
+ import { spawn } from 'node:child_process'
6
+ import { readFileSync } from 'node:fs'
7
+ import { registry } from './src/registry.mjs'
8
+ import { installModelCwdTranslation, installSessionPresetDefault, WSL_PRESET } from './src/preset-default.mjs'
9
+ import { installWslFileReferences } from './src/file-references.mjs'
10
+ import { installWorkspaceChangesPaths } from './src/changes-paths.mjs'
11
+ import { installWslDiff } from './src/wsl-diff-v2.mjs'
12
+ import { readSettings, userFor, writeSettings } from './src/settings.mjs'
13
+ import { runDoctor, runFix, detectUsers, listWslDistros } from './src/doctor.mjs'
14
+
15
+ const BASE = '/dsh-wsl'
16
+ const VERSION = (() => {
17
+ try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '' } catch { return '' }
18
+ })()
19
+
20
+ // A live profile reconcile starts the new row before the previous incarnation
21
+ // is always gone; its exact routes then still sit in webServer. Reinstalling
22
+ // during development must not fail with `duplicate exact route` and must not
23
+ // leave the panel on the old handler, so take the route over when the exact
24
+ // path is already owned. Our disposer removes the path only while it still
25
+ // holds this route, so a later reload cannot delete a successor's handler.
26
+ const ORIGINAL = Symbol.for('cordis.original')
27
+
28
+ function registerExactRoute(webServer, route) {
29
+ try {
30
+ return webServer.register(route)
31
+ } catch (error) {
32
+ const message = String(error?.message ?? error)
33
+ if (!message.includes('duplicate')) throw error
34
+ const raw = webServer?.[ORIGINAL] ?? webServer
35
+ const table = raw?.exact
36
+ if (!(table instanceof Map)) throw error
37
+ table.set(route.path, route)
38
+ console.warn(`[dsh-wsl] took over ${route.path} from a previous plugin instance`)
39
+ return () => { if (table.get(route.path) === route) table.delete(route.path) }
40
+ }
41
+ }
42
+
43
+ function send(res, status, body) {
44
+ res.statusCode = status
45
+ res.setHeader('content-type', 'application/json; charset=utf-8')
46
+ res.setHeader('cache-control', 'no-store')
47
+ res.end(JSON.stringify(body))
48
+ }
49
+
50
+ function readBody(req) {
51
+ return new Promise((resolve) => {
52
+ let data = ''
53
+ req.on('data', (chunk) => { data += chunk; if (data.length > 1 << 20) req.destroy() })
54
+ req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) } })
55
+ req.on('error', () => resolve({}))
56
+ })
57
+ }
58
+
59
+ function listDistros() {
60
+ return new Promise((resolve) => {
61
+ const child = spawn('wsl.exe', ['-l', '-q'], { windowsHide: true })
62
+ let out = ''
63
+ child.stdout.on('data', (d) => (out += d.toString('utf16le')))
64
+ child.on('error', () => resolve([]))
65
+ child.on('close', () => {
66
+ const names = out
67
+ .replace(/\0/g, '')
68
+ .split(/\r?\n/)
69
+ .map((s) => s.trim())
70
+ .filter(Boolean)
71
+ resolve([...new Set(names)])
72
+ })
73
+ })
74
+ }
75
+
76
+ export const name = 'dsh-wsl'
77
+ export const inject = ['webServer']
78
+
79
+ // Must be an arrow function: Cordis treats a `function` declaration as a
80
+ // constructible class (`new apply(ctx)`), and then discards the cleanup this
81
+ // function returns. Without that cleanup the exact routes survive a reload and
82
+ // every reinstall fails with `duplicate exact route`.
83
+ export const apply = (ctx) => {
84
+ const webServer = ctx.webServer
85
+ if (!webServer) return
86
+
87
+ // Every New Session / Workspace-open request in a WSL workspace omits the
88
+ // preset; without this patch those sessions run on Windows. Installed before
89
+ // the routes so `/state` can report whether the seam is live.
90
+ const presetDefault = installSessionPresetDefault(ctx)
91
+ // Keep the durable UNC cwd for Host features, but show the model the Linux
92
+ // directory its tools actually use. No-op for non-WSL sessions.
93
+ const modelCwd = installModelCwdTranslation(ctx)
94
+ // `@file` discovery: WSL Agents index the distribution instead of the UNC
95
+ // share. Diff labels translate absolute UNC display paths to Linux while the
96
+ // underlying Host summary/diff still serves the open/compare coordinates.
97
+ const fileReferences = installWslFileReferences(ctx)
98
+ const changesPaths = installWorkspaceChangesPaths(ctx)
99
+ const wslDiff = installWslDiff(ctx)
100
+
101
+ const bootSettings = readSettings()
102
+ registry.idleMinutes = bootSettings.idleDays * 24 * 60
103
+
104
+ const hasService = (name) => {
105
+ try { return ctx.get(name) !== undefined } catch { return false }
106
+ }
107
+ const capabilities = () => ({
108
+ webServer: true,
109
+ sessionController: hasService('sessionController'),
110
+ workingDirectory: hasService('workingDirectory'),
111
+ workspaceRegistry: hasService('workspaceRegistry'),
112
+ agentPresets: hasService('agentPresets'),
113
+ fileReferences: hasService('fileReferences'),
114
+ workspaceChanges: hasService('workspaceChanges'),
115
+ agents: hasService('agents'),
116
+ })
117
+
118
+ const routes = [
119
+ ['GET', BASE, async (req, res) => send(res, 200, { ok: true, name, version: VERSION })],
120
+ ['GET', `${BASE}/distros`, async (req, res) => send(res, 200, { distros: await listDistros() })],
121
+ ['GET', `${BASE}/state`, async (req, res) => send(res, 200, {
122
+ version: VERSION,
123
+ idleMinutes: registry.idleMinutes,
124
+ idleDays: readSettings().idleDays,
125
+ brokers: registry.list(),
126
+ settings: readSettings(),
127
+ capabilities: capabilities(),
128
+ presetDefault: { installed: presetDefault.installed, preset: WSL_PRESET },
129
+ modelCwd: { installed: modelCwd.installed },
130
+ fileReferences: { installed: fileReferences.installed },
131
+ changesPaths: { installed: changesPaths.installed },
132
+ wslDiff: { installed: wslDiff.installed, stats: wslDiff.stats },
133
+ })],
134
+ ['*', `${BASE}/settings`, async (req, res) => {
135
+ if ((req.method ?? 'GET') === 'GET') return send(res, 200, readSettings())
136
+ const body = await readBody(req)
137
+ send(res, 200, writeSettings(body))
138
+ }], ['GET', `${BASE}/doctor`, async (req, res) => send(res, 200, await runDoctor(ctx, {
139
+ version: VERSION,
140
+ capabilities: capabilities(),
141
+ flags: {
142
+ presetDefault: presetDefault.installed,
143
+ modelCwd: modelCwd.installed,
144
+ fileReferences: fileReferences.installed,
145
+ changesPaths: changesPaths.installed,
146
+ wslDiff: wslDiff.installed,
147
+ },
148
+ }))],
149
+ ['POST', `${BASE}/doctor/fix`, async (req, res) => {
150
+ const body = await readBody(req)
151
+ send(res, 200, await runFix(body.distro ?? '', body.action ?? '', body.user ?? '', body.name ?? ''))
152
+ }],
153
+ ['GET', `${BASE}/changes`, async (req, res) => {
154
+ const url = new URL(req.url, 'http://localhost')
155
+ const sessionId = url.searchParams.get('sessionId') ?? ''
156
+ const seq = Number(url.searchParams.get('seq') ?? -1)
157
+ const summary = ctx.get('workspaceChanges')?.summary(sessionId, seq)
158
+ if (summary === undefined) return send(res, 404, { error: 'change summary unavailable' })
159
+ send(res, 200, summary)
160
+ }],
161
+ ['GET', `${BASE}/changes/diff`, async (req, res) => {
162
+ const url = new URL(req.url, 'http://localhost')
163
+ const sessionId = url.searchParams.get('sessionId') ?? ''
164
+ const seq = Number(url.searchParams.get('seq') ?? -1)
165
+ const index = Number(url.searchParams.get('index') ?? -1)
166
+ const diff = await ctx.get('workspaceChanges')?.diff(sessionId, seq, index, AbortSignal.timeout(30_000))
167
+ if (diff === undefined) return send(res, 404, { error: 'change comparison unavailable' })
168
+ send(res, 200, diff)
169
+ }],
170
+ ['GET', `${BASE}/workspaces`, async (req, res) => {
171
+ // `ctx.get` does not require an inject declaration, unlike `ctx.<name>`;
172
+ // this row only injects webServer and must not fail at request time.
173
+ const list = ctx.get('workspaceRegistry')?.list?.() ?? []
174
+ send(res, 200, {
175
+ workspaces: list.map((w) => ({
176
+ id: w.id ?? w.workspaceId,
177
+ path: w.path ?? w.directory ?? '',
178
+ title: w.title ?? w.name ?? '',
179
+ })),
180
+ })
181
+ }],
182
+ ['GET', `${BASE}/browse`, async (req, res) => {
183
+ const url = new URL(req.url, 'http://localhost')
184
+ const distro = url.searchParams.get('distro') ?? ''
185
+ const user = url.searchParams.get('user') ?? userFor(distro)
186
+ const wanted = url.searchParams.get('path') ?? ''
187
+ const broker = registry.get(distro, user)
188
+ const hello = await broker.connect().then(() => broker.hello)
189
+ const target = wanted || hello.home
190
+ const listing = await broker.call('list', { path: target })
191
+ const dirs = listing.entries
192
+ .filter((e) => e.dir)
193
+ .sort((a, b) => a.name.localeCompare(b.name))
194
+ .map((e) => ({ name: e.name, path: target.replace(/\/$/, '') + '/' + e.name }))
195
+ const parent = target.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
196
+ send(res, 200, { distro, user, path: target, parent, home: hello.home, dirs })
197
+ }],
198
+ ['POST', `${BASE}/connect`, async (req, res) => {
199
+ const body = await readBody(req)
200
+ const distro = body.distro ?? ''
201
+ const broker = registry.get(distro, body.user ?? userFor(distro))
202
+ await broker.connect()
203
+ send(res, 200, { ok: true, broker: broker.info, hello: broker.hello })
204
+ }],
205
+ ['POST', `${BASE}/disconnect`, async (req, res) => {
206
+ const body = await readBody(req)
207
+ await registry.peek(body.distro ?? '', body.user ?? userFor(body.distro ?? ''))?.disconnect('panel')
208
+ send(res, 200, { ok: true })
209
+ }],
210
+ ['POST', `${BASE}/idle`, async (req, res) => {
211
+ const body = await readBody(req)
212
+ const days = body.days !== undefined ? Number(body.days) : Number(body.minutes) / (24 * 60)
213
+ if (!Number.isFinite(days) || days < 0) return send(res, 400, { error: 'days must be a non-negative number' })
214
+ const saved = writeSettings({ idleDays: days })
215
+ registry.idleMinutes = days * 24 * 60
216
+ send(res, 200, { ok: true, idleDays: saved.idleDays, idleMinutes: registry.idleMinutes })
217
+ }],
218
+ ]
219
+
220
+ const disposers = routes.map(([method, path, run]) => registerExactRoute(webServer, {
221
+ kind: 'exact',
222
+ path,
223
+ handler: async (req, res) => {
224
+ if (method !== '*' && (req.method ?? 'GET') !== method) return send(res, 405, { error: 'method not allowed', method: req.method })
225
+ try {
226
+ await run(req, res)
227
+ } catch (error) {
228
+ send(res, 500, { error: String(error?.message ?? error) })
229
+ }
230
+ },
231
+ }))
232
+
233
+ // A distro whose default user is root should not silently run the Agent as
234
+ // root. Detect the first normal user at Host boot and persist it as the
235
+ // default for that distro (no-op when the distro default is already normal).
236
+ const applyDefaultUsers = async () => {
237
+ try {
238
+ const settings = readSettings()
239
+ let changed = false
240
+ for (const distro of await listWslDistros()) {
241
+ if (settings.users[distro]) continue
242
+ const info = await detectUsers(distro)
243
+ if (info.root && info.users.length > 0 && !settings.users[distro]) {
244
+ settings.users[distro] = info.users[0].name
245
+ changed = true
246
+ }
247
+ }
248
+ if (changed) writeSettings({ users: settings.users })
249
+ } catch {}
250
+ }
251
+ void applyDefaultUsers()
252
+
253
+ return () => {
254
+ presetDefault.dispose()
255
+ modelCwd.dispose()
256
+ fileReferences.dispose()
257
+ changesPaths.dispose()
258
+ wslDiff.dispose()
259
+ for (const dispose of disposers) dispose()
260
+ }
261
+ }
262
+
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="256" height="256" role="img" aria-label="WSL penguin outline">
2
+ <g fill="none" stroke="#334155" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
3
+ <path d="M12 2.5c-2.3 0-4.1 1.9-4.1 4.2 0 1.4.6 2.6 1.6 3.4-1.1 1.2-1.9 2.9-2.2 4.7-.4 2.5.1 4.6 1.8 5.5 1.6.9 4.3.9 5.9 0 1.7-.9 2.2-3 1.8-5.5-.3-1.8-1.1-3.5-2.2-4.7 1-.8 1.6-2 1.6-3.4 0-2.3-1.8-4.2-4.2-4.2Z" fill="#ffffff"/>
4
+ <path d="M7.4 11.3c-1.5 1-2.4 2.7-2.3 4.5.1 1 .6 1.7 1.3 2.1"/>
5
+ <path d="M16.6 11.3c1.5 1 2.4 2.7 2.3 4.5-.1 1-.6 1.7-1.3 2.1"/>
6
+ <path d="M8.4 21.2c-.8 0-1.5.3-2 .8M15.6 21.2c.8 0 1.5.3 2 .8"/>
7
+ </g>
8
+ <circle cx="10.2" cy="7.8" r="0.7" fill="#334155"/>
9
+ <circle cx="13.8" cy="7.8" r="0.7" fill="#334155"/>
10
+ <path d="M10.9 9.1l1.1 1.5 1.1-1.5z" fill="#334155"/>
11
+ </svg>
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // Bundle entry: the patch mounts a row named after this package, which is how
2
+ // the client scanner discovers `dsh.client` and how the panel's host routes
3
+ // come up. The WSL execution world lives in ./world.js and is mounted by the
4
+ // preset instead.
5
+ export { name, inject, apply } from './host.js'
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL",
4
+ "description": "Run a DeepSeek Harness session inside a local WSL distribution: Linux tools, Linux paths, a clean login PATH, WSL-native search and @ references, and a doctor for Node, SSH and networking."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL",
4
+ "description": "让 DeepSeek Harness 会话在本地 WSL 发行版里运行:Linux 工具与路径、干净的登录 PATH、WSL 原生搜索与 @ 引用,以及 Node/SSH/网络的诊断面板。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,88 @@
1
1
  {
2
2
  "name": "@chronosun/dsh-wsl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.5.0",
4
+ "type": "module",
5
+ "description": "WSL execution world for DeepSeek Harness: a Linux bash/terminal/fs world inside a local WSL distribution, plus a connection panel, Doctor, and a WSL workspace picker.",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "dsh",
12
+ "dsh-plugin",
13
+ "deepseek-harness",
14
+ "wsl",
15
+ "wsl2",
16
+ "linux",
17
+ "agent",
18
+ "coding-agent"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "main": "index.js",
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./host": "./host.js",
27
+ "./world": "./world.js",
28
+ "./fs": "./fs-service.mjs",
29
+ "./search": "./search-tools.mjs",
30
+ "./prompt": "./prompt.mjs",
31
+ "./terminal-launcher": "./terminal-launcher.mjs",
32
+ "./worktree": "./worktree-tool-v2.mjs",
33
+ "./client": "./client.js",
34
+ "./icon": "./icon.svg",
35
+ "./locale/*.json": "./locale/*.json",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "index.js",
40
+ "host.js",
41
+ "world.js",
42
+ "fs-service.mjs",
43
+ "search-tools.mjs",
44
+ "prompt.mjs",
45
+ "terminal-launcher.mjs",
46
+ "worktree-tool-v2.mjs",
47
+ "client.js",
48
+ "README.md",
49
+ "LICENSE",
50
+ "cordis.patch.yml",
51
+ "icon.svg",
52
+ "locale/*.json",
53
+ "src/"
54
+ ],
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "platform": "web",
61
+ "immediately": true,
62
+ "inject": [
63
+ "@deepseek-ai/dsh-client-runtime",
64
+ "@deepseek-ai/dsh-client-ui-sidebar"
65
+ ]
66
+ },
67
+ "compatibility": {
68
+ "dshReleases": {
69
+ "0.2.2-rc.1": "compatible"
70
+ }
71
+ }
72
+ },
73
+ "engines": {
74
+ "dsh": ">=0.2.2-rc.1"
75
+ },
76
+ "peerDependencies": {
77
+ "@deepseek-ai/cordis": "*",
78
+ "@deepseek-ai/dsh-subprocess": "*"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "@deepseek-ai/cordis": {
82
+ "optional": true
83
+ },
84
+ "@deepseek-ai/dsh-subprocess": {
85
+ "optional": true
86
+ }
87
+ }
88
+ }
package/prompt.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // Prompt section: tell the model it is in Linux, and how to translate the
2
+ // Windows-shaped working directory it is still shown.
3
+ //
4
+ // A stable system section (not per-turn context) so the cached prefix is not
5
+ // perturbed; the text is static, so it never invalidates mid-session.
6
+ export const name = 'dsh-wsl/prompt'
7
+ export const inject = ['systemPrompt']
8
+
9
+ export function apply(ctx, config = {}) {
10
+ const distro = config.distro ? ` (distribution: ${config.distro})` : ''
11
+ const text = [
12
+ '# Execution environment',
13
+ `This session executes inside a WSL distribution${distro} on the user's machine, so the execution world is **Linux, not Windows**.`,
14
+ '',
15
+ '- **Every tool takes Linux paths.** `Current working directory` is a Linux path such as `/home/me/proj`. If a Windows view shows the same directory as `\\\\wsl.localhost\\<distro>\\...`, the Linux equivalent is that path with the prefix removed and backslashes turned into slashes; always use the Linux form in tool calls and in code you write.',
16
+ '- Windows drives are mounted at `/mnt/<drive>`, so `C:\\Users\\me` is `/mnt/c/Users/me`.',
17
+ '- `bash` runs `/bin/bash` inside the distribution with a PATH that contains no `/mnt/*` entries, so Windows binaries are deliberately unreachable; use the distribution\'s own `node`, `npm`, `git`, `python3`.',
18
+ '- `ssh`, `scp`, `rsync` and `curl` are the distribution\'s own binaries; keys and `~/.ssh/config` are the distribution user\'s. The Agent has no TTY, so use key-based auth (`ssh -o BatchMode=yes host \'command\'`) or `ControlMaster` instead of an interactive password prompt; use the sidebar terminal for interactive logins.',
19
+ '- `read`, `write`, `edit`, `glob` and `grep` operate directly on the Linux filesystem. `glob` and `grep` require an explicit `path`.',
20
+ '- `web_fetch` / `web_search` run on the host but share the network. A service started inside the distribution is reachable at `http://localhost:<port>` (WSL2 localhost forwarding); use `curl` in `bash` when it must run from Linux or through an SSH tunnel.',
21
+ '- Quote shell variables (`"$VAR"`): this Harness build parses some commands twice, and an unquoted value containing spaces or parentheses can break the command.',
22
+ ].join('\n')
23
+
24
+ return ctx.systemPrompt.section({ name: 'wsl-world', order: 820, text })
25
+ }
@@ -0,0 +1,91 @@
1
+ // `grep` and `glob` inside the WSL world.
2
+ //
3
+ // The host's own search package spawns a bundled **Windows** ripgrep, which a
4
+ // Linux world cannot run. These tools walk the tree inside the distribution
5
+ // instead, through the broker, with no external binary at all.
6
+ import { registry } from './src/registry.mjs'
7
+ import { userFor } from './src/settings.mjs'
8
+ import { userFromLinuxPath } from './src/path-user.mjs'
9
+
10
+ const text = (value) => [{ type: 'text', text: value }]
11
+
12
+ /** The model still sees a UNC cwd, so a host path may arrive here. */
13
+ function toLinuxPath(value, fallback) {
14
+ const raw = String(value ?? '')
15
+ if (!raw) return fallback ?? '/'
16
+ if (raw.startsWith('/')) return raw
17
+ const unc = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(raw)
18
+ if (unc) return `/${unc[2].replace(/\\/g, '/')}`
19
+ const drive = /^([A-Za-z]):[\\/](.*)$/.exec(raw)
20
+ if (drive) return `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`
21
+ return fallback ?? raw
22
+ }
23
+
24
+ function brokerFor(config, cwd = '') {
25
+ const distro = config?.distro ?? ''
26
+ return registry.get(distro, config?.user || userFromLinuxPath(cwd) || userFor(distro))
27
+ }
28
+
29
+ export const name = 'dsh-wsl/search'
30
+ export const inject = ['tools']
31
+
32
+ export function apply(ctx, config = {}) {
33
+ const disposers = []
34
+
35
+ disposers.push(ctx.tools.register({
36
+ name: 'glob',
37
+ description: 'Find files by glob pattern inside the WSL distribution. Returns paths, oldest-first is not guaranteed; hidden files are included. In a Git worktree, files ignored by .gitignore are skipped; otherwise .git and node_modules are pruned. A pattern without "/" matches basenames at any depth.',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: {
41
+ pattern: { type: 'string', description: 'Glob such as "**/*.ts" or "*.json".' },
42
+ path: { type: 'string', description: 'Directory to search (Linux path). Required: pass the session working directory.' },
43
+ },
44
+ required: ['pattern', 'path'],
45
+ additionalProperties: false,
46
+ },
47
+ output: {
48
+ schema: { type: 'object', properties: { paths: { type: 'array' }, truncated: { type: 'boolean' } }, additionalProperties: true },
49
+ render: (_args, value) => text((value.paths ?? []).join('\n') || 'no files matched'),
50
+ },
51
+ async execute(args, exec) {
52
+ const cwd = exec?.agent?.cwd ?? config.defaultCwd ?? '/'
53
+ const root = toLinuxPath(args.path, cwd)
54
+ return brokerFor(config, cwd).call('glob', { path: root, pattern: args.pattern, max: 1000 })
55
+ },
56
+ }))
57
+
58
+ disposers.push(ctx.tools.register({
59
+ name: 'grep',
60
+ description: 'Search file contents with a JavaScript regular expression inside the WSL distribution. Results are grouped by file with line numbers. In a Git worktree, files ignored by .gitignore are skipped; otherwise .git and node_modules are pruned; files over 2 MiB are skipped.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ pattern: { type: 'string', description: 'JavaScript regular expression.' },
65
+ path: { type: 'string', description: 'Directory to search (Linux path). Required: pass the session working directory.' },
66
+ include: { type: 'string', description: 'Optional file-name glob filter, such as "*.ts".' },
67
+ },
68
+ required: ['pattern', 'path'],
69
+ additionalProperties: false,
70
+ },
71
+ output: {
72
+ schema: { type: 'object', properties: { files: { type: 'array' }, truncated: { type: 'boolean' } }, additionalProperties: true },
73
+ render: (_args, value) => {
74
+ const lines = []
75
+ for (const file of value.files ?? []) {
76
+ for (const match of file.matches ?? []) lines.push(`${file.path}:${match.lineNumber}: ${match.line}`)
77
+ }
78
+ if (value.truncated) lines.push('[truncated]')
79
+ return text(lines.join('\n') || 'no matches')
80
+ },
81
+ },
82
+ async execute(args, exec) {
83
+ const cwd = exec?.agent?.cwd ?? config.defaultCwd ?? '/'
84
+ const root = toLinuxPath(args.path, cwd)
85
+ return brokerFor(config, cwd).call('grep', { path: root, pattern: args.pattern, include: args.include, max: 250 })
86
+ },
87
+ }))
88
+
89
+ return () => { for (const dispose of disposers) dispose() }
90
+ }
91
+