@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,315 @@
1
+ // WSL-side workspace-change summaries and diffs.
2
+ //
3
+ // The shipped `workspaceChanges` recorder is a Host service: it runs Windows
4
+ // git against the UNC worktree. This companion takes a snapshot inside the
5
+ // distribution at `turn/start` and `turn/end` (private GIT_INDEX_FILE, then
6
+ // write-tree/diff-tree), stores the summary by the `workspace/changes` event's
7
+ // seq, and serves it before the Host fallback. The Host summary's `cwd`/`path`
8
+ // stay usable for `/api/changes.open`; only the comparison work moves to Linux.
9
+ import { registry } from './registry.mjs'
10
+ import { linuxPathFromUnc } from './preset-default.mjs'
11
+ import { userFor } from './settings.mjs'
12
+
13
+ export const WSL_PRESET = 'wsl'
14
+ const PATCH = Symbol.for('dsh-wsl/wsl-diff')
15
+ const MAX_ENTRIES = 500
16
+
17
+ function unwrap(value) {
18
+ if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
19
+ try { return value[Symbol.for('cordis.original')] ?? value } catch { return value }
20
+ }
21
+
22
+ function linuxCwd(session) {
23
+ const cwd = session?.header?.cwd
24
+ return linuxPathFromUnc(cwd) ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : undefined)
25
+ }
26
+
27
+ function brokerFor(cwd) {
28
+ const match = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\/i.exec(String(cwd ?? ''))
29
+ const distro = match ? match[1] : ''
30
+ return registry.get(distro, userFor(distro))
31
+ }
32
+
33
+ /** A running helper from before the git ops were added still has `exec`. */
34
+ async function exec(broker, argv, cwd) {
35
+ return broker.call('exec', { argv, cwd })
36
+ }
37
+
38
+ /**
39
+ * `gitSnapshot` with a shell fallback so a Session whose helper predates the
40
+ * operation still gets a WSL snapshot instead of the Host fallback.
41
+ */
42
+ async function snapshotInWsl(broker, cwd) {
43
+ const script = [
44
+ 'set -e',
45
+ 'd=$(mktemp -d)',
46
+ 'trap \'rm -rf "$d"\' EXIT',
47
+ 'export GIT_INDEX_FILE="$d/index"',
48
+ 'git -C "$PWD" read-tree --empty',
49
+ "git -C \"$PWD\" add -A -- . ':(exclude).agents/worktrees'",
50
+ 'git -C "$PWD" write-tree',
51
+ ].join('\n')
52
+ const result = await exec(broker, ['bash', '-c', script], cwd)
53
+ if (result.code !== 0) throw new Error(`wsl-diff: snapshot failed: ${result.err || result.out}`)
54
+ const lines = result.out.trim().split(/\r?\n/)
55
+ const tree = lines[lines.length - 1]?.trim()
56
+ if (!tree) throw new Error('wsl-diff: snapshot produced no tree')
57
+ return tree
58
+ }
59
+
60
+ async function summaryInWsl(broker, cwd, before, after) {
61
+ const prefix = (await exec(broker, ['git', '-C', cwd, 'rev-parse', '--show-prefix'], cwd)).out.trim()
62
+ const raw = await exec(broker, ['git', '-C', cwd, 'diff-tree', '-r', '--no-commit-id', '--numstat', '--no-renames', before, after], cwd)
63
+ if (raw.code !== 0) throw new Error(`wsl-diff: summary failed: ${raw.err || raw.out}`)
64
+ const files = []
65
+ for (const line of raw.out.split(/\r?\n/)) {
66
+ const match = /^(\S+)\t(\S+)\t(.*)$/.exec(line)
67
+ if (!match) continue
68
+ let file = match[3]
69
+ if (prefix && file.startsWith(prefix)) file = file.slice(prefix.length)
70
+ const binary = match[1] === '-' && match[2] === '-'
71
+ files.push({ path: file, added: binary ? 0 : Number(match[1]) || 0, deleted: binary ? 0 : Number(match[2]) || 0, ...(binary ? { binary: true } : {}) })
72
+ }
73
+ return files
74
+ }
75
+
76
+ function parseHunks(raw, pathValue, before, after) {
77
+ const hunks = []
78
+ let current = null
79
+ for (const line of raw.split(/\r?\n/)) {
80
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
81
+ if (header) {
82
+ if (current) hunks.push(current)
83
+ current = {
84
+ oldStart: Number(header[1]),
85
+ oldLines: Number(header[2] ?? 1),
86
+ newStart: Number(header[3]),
87
+ newLines: Number(header[4] ?? 1),
88
+ lines: [],
89
+ }
90
+ continue
91
+ }
92
+ if (!current) continue
93
+ if (line.startsWith('\\ No newline')) { current.lines.push(line); continue }
94
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) current.lines.push(line)
95
+ }
96
+ if (current) hunks.push(current)
97
+ return { kind: 'text', path: pathValue, display: pathValue, before, after, hunks, coarse: false }
98
+ }
99
+
100
+ async function diffInWsl(broker, cwd, before, after, file) {
101
+ const prefix = (await exec(broker, ['git', '-C', cwd, 'rev-parse', '--show-prefix'], cwd)).out.trim()
102
+ const full = `${prefix}${file}`
103
+ const exists = async (tree) => {
104
+ const result = await exec(broker, ['git', '-C', cwd, 'cat-file', '-e', `${tree}:${full}`], cwd)
105
+ return result.code === 0
106
+ }
107
+ const beforeExists = await exists(before)
108
+ const afterExists = await exists(after)
109
+ if (afterExists) {
110
+ const size = await exec(broker, ['git', '-C', cwd, 'cat-file', '-s', `${after}:${full}`], cwd)
111
+ if (Number(size.out) > 2 * 1024 * 1024) return { kind: 'oversized', path: file, display: file }
112
+ }
113
+ const raw = await exec(broker, ['git', '-C', cwd, 'diff', '--no-color', '--unified=3', before, after, '--', full], cwd)
114
+ if (raw.code !== 0) throw new Error(`wsl-diff: diff failed: ${raw.err || raw.out}`)
115
+ if (/^Binary files /m.test(raw.out)) return { kind: 'binary', path: file, display: file }
116
+ return parseHunks(raw.out, file, beforeExists, afterExists)
117
+ }
118
+
119
+ /**
120
+ * Install the WSL recorder companion. Sessions outside `wsl` and sessions
121
+ * without a Linux seed are untouched; every failure falls back to the Host.
122
+ *
123
+ * @returns `{ installed, dispose }`; `installed` is a live getter.
124
+ */
125
+ export function installWslDiff(ctx) {
126
+ const disposers = []
127
+ const states = new Map()
128
+ const bySeq = new Map()
129
+ const seqByTurn = new Map()
130
+ const stats = { events: 0, wslEvents: 0, starts: 0, ends: 0, summaries: 0, late: 0, errors: 0, lastError: '' }
131
+ let installed = false
132
+
133
+ const presetOf = (session) => {
134
+ try { return ctx.get('agentPresets')?.presetForSession?.(session) } catch { return undefined }
135
+ }
136
+ const isWsl = (session) => presetOf(session) === WSL_PRESET && linuxCwd(session) !== undefined
137
+
138
+ const stateFor = (session) => {
139
+ let state = states.get(session.id)
140
+ if (state === undefined) {
141
+ state = { beforeTree: null, pending: null, busy: false }
142
+ states.set(session.id, state)
143
+ }
144
+ return state
145
+ }
146
+
147
+ const snapshot = async (session) => {
148
+ const cwd = linuxCwd(session)
149
+ return snapshotInWsl(brokerFor(session.header.cwd), cwd)
150
+ }
151
+
152
+ const remember = (sessionId, seq, pending) => {
153
+ bySeq.set(`${sessionId}|${seq}`, pending)
154
+ if (bySeq.size > MAX_ENTRIES) {
155
+ const first = bySeq.keys().next().value
156
+ if (first !== undefined) bySeq.delete(first)
157
+ }
158
+ }
159
+
160
+ const onSessionEvent = (session, event) => {
161
+ stats.events += 1
162
+ if (!isWsl(session)) return
163
+ stats.wslEvents += 1
164
+ const turn = Number(event?.data?.turn)
165
+ if (!Number.isFinite(turn)) return
166
+ if (event.type === 'turn/start') {
167
+ stats.starts += 1
168
+ const state = stateFor(session)
169
+ state.beforeTree = null
170
+ state.pending = null
171
+ const tree = snapshot(session)
172
+ tree.then((value) => { state.beforeTree = value }).catch((error) => {
173
+ stats.errors += 1
174
+ stats.lastError = String(error?.message ?? error)
175
+ })
176
+ return
177
+ }
178
+ if (event.type === 'turn/end') {
179
+ stats.ends += 1
180
+ const state = stateFor(session)
181
+ if (state.busy) return
182
+ state.busy = true
183
+ const cwd = session.header.cwd
184
+ const linux = linuxCwd(session)
185
+ const run = (async () => {
186
+ // Wait for the start tree; the Agent's own turn start usually lands
187
+ // well before its first tool call, but a slow git start must not make
188
+ // an empty diff.
189
+ for (let i = 0; i < 50 && state.beforeTree === null; i += 1) await new Promise((resolve) => setTimeout(resolve, 20))
190
+ const after = await snapshot(session)
191
+ if (state.beforeTree === null) throw new Error('wsl-diff: no start tree')
192
+ const files = await summaryInWsl(brokerFor(cwd), linux, state.beforeTree, after)
193
+ const pending = { turn, before: state.beforeTree, after, cwd, files }
194
+ state.pending = pending
195
+ stats.summaries += 1
196
+ const key = `${session.id}|${turn}`
197
+ const seq = seqByTurn.get(key)
198
+ if (seq !== undefined) {
199
+ seqByTurn.delete(key)
200
+ state.pending = null
201
+ remember(session.id, seq, pending)
202
+ stats.late += 1
203
+ }
204
+ })().catch((error) => {
205
+ stats.errors += 1
206
+ stats.lastError = String(error?.message ?? error)
207
+ }).finally(() => { state.busy = false })
208
+ void run
209
+ return
210
+ }
211
+ if (event.type === 'workspace/changes') {
212
+ const key = `${session.id}|${turn}`
213
+ seqByTurn.set(key, event.seq)
214
+ if (seqByTurn.size > MAX_ENTRIES) {
215
+ const first = seqByTurn.keys().next().value
216
+ if (first !== undefined) seqByTurn.delete(first)
217
+ }
218
+ const state = states.get(session.id)
219
+ const pending = state?.pending
220
+ if (pending === undefined || pending === null || pending.turn !== turn) return
221
+ seqByTurn.delete(key)
222
+ state.pending = null
223
+ remember(session.id, event.seq, pending)
224
+ }
225
+ }
226
+ // Prepended so the WSL snapshot starts before the Host recorder's Windows
227
+ // git runs over the UNC worktree; the `workspace/changes` event is appended
228
+ // after that Host work, so the summary is usually ready by then.
229
+ ctx.on('session/event', onSessionEvent, { prepend: true })
230
+
231
+ const install = (service) => {
232
+ if (installed) return true
233
+ const raw = unwrap(service)
234
+ if (!raw || typeof raw.summary !== 'function' || typeof raw.diff !== 'function') return false
235
+ if (raw[PATCH]) { installed = true; return true }
236
+ const summaryDescriptor = Object.getOwnPropertyDescriptor(raw, 'summary')
237
+ const diffDescriptor = Object.getOwnPropertyDescriptor(raw, 'diff')
238
+ const originalSummary = raw.summary
239
+ const originalDiff = raw.diff
240
+
241
+ const summary = function summaryPatched(sessionId, seq) {
242
+ const ours = bySeq.get(`${sessionId}|${seq}`)
243
+ if (ours === undefined) return originalSummary.call(raw, sessionId, seq)
244
+ const files = ours.files.map((file) => ({
245
+ path: file.path,
246
+ display: file.path,
247
+ added: file.added,
248
+ deleted: file.deleted,
249
+ ...(file.binary === true ? { binary: true } : {}),
250
+ }))
251
+ return {
252
+ turn: ours.turn,
253
+ cwd: ours.cwd,
254
+ source: 'wsl',
255
+ files,
256
+ total: files.length,
257
+ added: files.reduce((sum, file) => sum + file.added, 0),
258
+ deleted: files.reduce((sum, file) => sum + file.deleted, 0),
259
+ }
260
+ }
261
+
262
+ const diff = async function diffPatched(sessionId, seq, index, signal) {
263
+ const ours = bySeq.get(`${sessionId}|${seq}`)
264
+ if (ours === undefined) return originalDiff.call(raw, sessionId, seq, index, signal)
265
+ const file = ours.files[index]
266
+ if (file === undefined) return undefined
267
+ signal?.throwIfAborted?.()
268
+ try {
269
+ const broker = brokerFor(ours.cwd)
270
+ const cwd = linuxPathFromUnc(ours.cwd)
271
+ return await diffInWsl(broker, cwd, ours.before, ours.after, file.path)
272
+ } catch {
273
+ return originalDiff.call(raw, sessionId, seq, index, signal)
274
+ }
275
+ }
276
+
277
+ try {
278
+ Object.defineProperty(raw, 'summary', { configurable: true, enumerable: false, writable: true, value: summary })
279
+ Object.defineProperty(raw, 'diff', { configurable: true, enumerable: false, writable: true, value: diff })
280
+ } catch {
281
+ return false
282
+ }
283
+ const dispose = () => {
284
+ try { if (summaryDescriptor) Object.defineProperty(raw, 'summary', summaryDescriptor) } catch {}
285
+ try { if (diffDescriptor) Object.defineProperty(raw, 'diff', diffDescriptor) } catch {}
286
+ try { delete raw[PATCH] } catch {}
287
+ }
288
+ Object.defineProperty(raw, PATCH, { configurable: true, enumerable: false, writable: true, value: dispose })
289
+ disposers.push(dispose)
290
+ installed = true
291
+ return true
292
+ }
293
+
294
+ const current = ctx.get('workspaceChanges')
295
+ if (current) install(current)
296
+ else ctx.on('internal/service', (name, value) => { if (name === 'workspaceChanges') install(value) })
297
+
298
+ ctx.on('agent/disposed', ({ agent }) => {
299
+ const id = agent?.session?.id
300
+ if (id === undefined) return
301
+ states.delete(id)
302
+ for (const key of bySeq.keys()) if (key.startsWith(`${id}|`)) bySeq.delete(key)
303
+ })
304
+
305
+ return {
306
+ get installed() { return installed },
307
+ get stats() { return { ...stats, sessions: states.size, entries: bySeq.size } },
308
+ dispose() {
309
+ for (const dispose of disposers.splice(0)) dispose()
310
+ states.clear()
311
+ bySeq.clear()
312
+ installed = false
313
+ },
314
+ }
315
+ }
@@ -0,0 +1,59 @@
1
+ // Sidebar-terminal launcher.
2
+ //
3
+ // `terminal-controller` is a Host service: it spawns the terminal process
4
+ // itself, outside any agent preset, so a WSL preset cannot influence it. This
5
+ // launcher is the supported way in: point the controller's `shell.path` at node
6
+ // and this file, and pick the shell from the working directory the controller
7
+ // hands us. Windows workspaces keep their normal shell — nothing changes there.
8
+ import { execFileSync, spawn } from 'node:child_process'
9
+ import { appendFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { userFor } from './src/settings.mjs'
13
+ import { userFromLinuxPath } from './src/path-user.mjs'
14
+
15
+ const cwd = process.cwd()
16
+ const unc = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(cwd)
17
+
18
+ // Terminals open rarely; one line here makes "it opened cmd again" debuggable.
19
+ const LOG = join(tmpdir(), 'dsh-wsl-terminal.log')
20
+ try { appendFileSync(LOG, `${new Date().toISOString()} cwd=${JSON.stringify(cwd)} branch=${unc ? 'wsl:' + unc[1] : 'windows'}\n`) } catch {}
21
+
22
+ function run(file, args, options) {
23
+ const child = spawn(file, args, { stdio: 'inherit', windowsHide: false, ...options })
24
+ child.on('error', (error) => {
25
+ process.stderr.write(`terminal-launcher: ${error.message}\r\n`)
26
+ process.exit(1)
27
+ })
28
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
29
+ }
30
+
31
+ if (unc) {
32
+ const distro = unc[1]
33
+ const linuxPath = '/' + unc[2].replace(/\\/g, '/')
34
+ const user = userFromLinuxPath(linuxPath) || userFor(distro)
35
+ const userArgs = user ? ['-u', user] : []
36
+ // Match the Agent's clean environment: keep the login PATH but drop every
37
+ // `/mnt/*` interop entry. A probe failure falls back to the normal login shell.
38
+ let cleanPath = ''
39
+ try {
40
+ const login = execFileSync('wsl.exe', ['-d', distro, ...userArgs, '-e', 'bash', '-lc', 'printenv PATH'], {
41
+ encoding: 'utf8', windowsHide: true, timeout: 10_000,
42
+ }).trim()
43
+ cleanPath = login.split(':').filter((entry) => entry && !/^\/mnt(\/|$)/i.test(entry)).join(':')
44
+ appendFileSync(LOG, `${new Date().toISOString()} user=${user || '(default)'} cleanPath mnt=${login.split(':').filter((entry) => /^\/mnt(\/|$)/i.test(entry)).length}\n`)
45
+ } catch {}
46
+ // Do not hand wsl.exe a UNC cwd of its own: --cd already points at the
47
+ // directory, and a UNC process cwd makes Windows tools complain.
48
+ const args = ['-d', distro, ...userArgs, '--cd', linuxPath]
49
+ if (cleanPath) {
50
+ const script = `export PATH='${cleanPath.replace(/'/g, `'\\''`)}'; exec bash -i`
51
+ args.push('-e', 'bash', '-lc', script)
52
+ } else {
53
+ args.push('-e', 'bash', '-l')
54
+ }
55
+ run('wsl.exe', args, { cwd: process.env.SystemRoot ?? 'C:\\Windows' })
56
+ } else {
57
+ // Leave Windows workspaces exactly as they were: their configured shell.
58
+ run(process.env.DSH_WSL_WIN_SHELL ?? 'pwsh.exe', [], { cwd })
59
+ }
@@ -0,0 +1,102 @@
1
+ // Model-facing worktree creation inside the WSL world.
2
+ //
3
+ // The shipped `create_worktree` tool is a Host runtime: it enters the checkout
4
+ // by changing the Session working directory, which resolves through the Host
5
+ // filesystem. That cannot work for a Linux path. This tool creates the same
6
+ // kind of retained checkout with Linux git and returns its path; the Session
7
+ // cwd stays unchanged, so callers pass `workdir`/the explicit path instead.
8
+ import { registry } from './src/registry.mjs'
9
+ import { linuxPathFromUnc } from './src/preset-default.mjs'
10
+ import { userFor } from './src/settings.mjs'
11
+
12
+ export const name = 'dsh-wsl/worktree'
13
+ export const inject = ['tools']
14
+
15
+ function brokerFor(cwd) {
16
+ const match = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\/i.exec(String(cwd ?? ''))
17
+ const distro = match ? match[1] : ''
18
+ return registry.get(distro, userFor(distro))
19
+ }
20
+
21
+ function sanitize(name) {
22
+ return String(name ?? '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
23
+ }
24
+
25
+ function shellQuote(value) {
26
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
27
+ }
28
+
29
+ export function apply(ctx) {
30
+ const tool = {
31
+ name: 'create_wsl_worktree',
32
+ description: 'Create a retained Git worktree inside the WSL distribution. Returns its Linux path, branch and base commit. The Session working directory does not change: pass the returned path explicitly (or as workdir) to later tools.',
33
+ parameters: {
34
+ type: 'object',
35
+ properties: {
36
+ name: { type: 'string', description: 'Optional worktree/branch suffix; defaults to a timestamp.' },
37
+ from: { type: 'string', description: 'Optional commit/ref to start from; defaults to HEAD.' },
38
+ },
39
+ additionalProperties: false,
40
+ },
41
+ output: {
42
+ schema: {
43
+ type: 'object',
44
+ properties: {
45
+ path: { type: 'string' },
46
+ branch: { type: 'string' },
47
+ baseCommit: { type: 'string' },
48
+ repositoryRoot: { type: 'string' },
49
+ },
50
+ additionalProperties: true,
51
+ },
52
+ render: (_args, value) => [{ type: 'text', text: `worktree ${value.path}\nbranch ${value.branch}\nbase ${value.baseCommit}` }],
53
+ },
54
+ async execute(args, exec) {
55
+ const session = exec?.agent?.session
56
+ const agentCwd = exec?.agent?.cwd
57
+ const headerCwd = session?.header?.cwd ?? ctx.get('workingDirectory')?.get?.(session)
58
+ // `agent.cwd` can be `/` in the tool context even when the Session is in
59
+ // a WSL workspace; prefer the recorded UNC cwd in that case.
60
+ const source = (typeof agentCwd === 'string' && agentCwd !== '' && agentCwd !== '/') ? agentCwd : (headerCwd || agentCwd || '/')
61
+ const linux = linuxPathFromUnc(source) ?? (source.startsWith('/') ? source : undefined)
62
+ if (linux === undefined) throw new Error(`create_wsl_worktree: the Session is not in a WSL workspace (cwd=${source})`)
63
+ const broker = brokerFor(source)
64
+ const rootResult = await broker.call('exec', { argv: ['git', '-C', linux, 'rev-parse', '--show-toplevel'], cwd: linux })
65
+ if (rootResult.code !== 0) {
66
+ throw new Error(`create_wsl_worktree: not a Git repository: ${rootResult.err || rootResult.out} (source=${source} linux=${linux} session=${session?.id ?? ''} header=${headerCwd ?? ''} agentCwd=${agentCwd ?? ''} keys=${Object.keys(exec?.agent ?? {}).join(',')})`)
67
+ }
68
+ const repositoryRoot = rootResult.out.trim()
69
+ // Keep the retained checkouts out of the parent's status/snapshots; the
70
+ // diff recorder also excludes this path, but the Host recorder reads Git
71
+ // directly and needs the exclude file.
72
+ try {
73
+ const exclude = await broker.call('exec', {
74
+ argv: ['git', '-C', repositoryRoot, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'],
75
+ cwd: repositoryRoot,
76
+ })
77
+ let excludePath = exclude.out.trim()
78
+ if (!excludePath.startsWith('/')) excludePath = `${repositoryRoot.replace(/\/+$/, '')}/${excludePath}`
79
+ if (excludePath) {
80
+ await broker.call('exec', {
81
+ argv: ['bash', '-c', `grep -qxF '.agents/worktrees/' ${shellQuote(excludePath)} 2>/dev/null || printf '%s\\n' '.agents/worktrees/' >> ${shellQuote(excludePath)}`],
82
+ cwd: repositoryRoot,
83
+ })
84
+ }
85
+ } catch {}
86
+ const suffix = sanitize(args?.name) || `wt-${Date.now().toString(36)}`
87
+ const branch = `dsh/${suffix}`
88
+ const target = `${repositoryRoot.replace(/\/+$/, '')}/.agents/worktrees/${suffix}`
89
+ const from = args?.from ? String(args.from) : 'HEAD'
90
+ const created = await broker.call('exec', {
91
+ argv: ['git', '-C', repositoryRoot, 'worktree', 'add', '-b', branch, target, from],
92
+ cwd: repositoryRoot,
93
+ })
94
+ if (created.code !== 0) throw new Error(`create_wsl_worktree: ${created.err || created.out || `git exited ${created.code}`}`)
95
+ const base = await broker.call('exec', { argv: ['git', '-C', target, 'rev-parse', 'HEAD'], cwd: target })
96
+ return { path: target, branch, baseCommit: base.out.trim(), repositoryRoot }
97
+ },
98
+ }
99
+
100
+ const dispose = ctx.tools.register(tool)
101
+ return () => { dispose() }
102
+ }
package/world.js ADDED
@@ -0,0 +1,112 @@
1
+ // The WSL execution world: one preset-scoped `ctx.subprocess` for Linux.
2
+ //
3
+ // NOTE: no `#private` members here on purpose. Cordis wraps a Service instance
4
+ // in a context proxy, so methods can run with a `this` that is not the branded
5
+ // original; V8 then throws "Receiver must be an instance of class ...". Plain
6
+ // underscore properties survive that indirection.
7
+ //
8
+ // It also does not hard-depend on `@deepseek-ai/dsh-subprocess`: a
9
+ // profile-installed plugin cannot resolve the harness' own packages (verified:
10
+ // ERR_MODULE_NOT_FOUND from the profile), so the Service base is imported when
11
+ // available and otherwise replaced by a shim that registers through Context.
12
+ import { appendFileSync } from 'node:fs'
13
+ import { registry } from './src/registry.mjs'
14
+ import { parseWslUnc } from './src/broker.mjs'
15
+ import { createWslSubprocess } from './src/subprocess.mjs'
16
+ import { defaultDistro, userFor } from './src/settings.mjs'
17
+ import { userFromLinuxPath } from './src/path-user.mjs'
18
+
19
+ const LOG = 'C:\\Users\\Yunson\\dsh-wsl-plugin\\wsl-world.log'
20
+ const DEBUG = process.env.DSH_WSL_DEBUG === '1'
21
+ function log(line) {
22
+ if (DEBUG) try { appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`) } catch {}
23
+ }
24
+
25
+ let Base = null
26
+ let baseError = ''
27
+ try {
28
+ const mod = await import('@deepseek-ai/dsh-subprocess')
29
+ Base = mod.SubprocessRuntime ?? mod.default ?? null
30
+ } catch (error) {
31
+ baseError = String(error?.code ?? error?.message ?? error)
32
+ Base = null
33
+ }
34
+ log(`import base=${Base ? 'real' : 'shim'}${baseError ? ` (${baseError})` : ''}`)
35
+
36
+ class ServiceShim {
37
+ constructor(ctx) {
38
+ this.ctx = ctx
39
+ if (typeof ctx.set !== 'function') {
40
+ throw new Error('subprocess-wsl: this Harness build exposes neither the Service base class nor ctx.set')
41
+ }
42
+ ctx.provide?.('subprocess')
43
+ ctx.set('subprocess', this)
44
+ log('shim installed ctx.subprocess')
45
+ }
46
+ }
47
+
48
+ const Parent = Base ?? ServiceShim
49
+
50
+ export default class WslSubprocessWorld extends Parent {
51
+ _impls = new Map()
52
+ _config = {}
53
+
54
+ constructor(ctx, config = {}) {
55
+ super(ctx)
56
+ if (!this.ctx) this.ctx = ctx
57
+ this._config = config ?? {}
58
+ log(`ctor ok base=${Base ? 'real' : 'shim'} distro=${this._config.distro ?? ''}`)
59
+ }
60
+
61
+ /** One implementation per (distro, user); sessions in different distros coexist. */
62
+ _for(cwd) {
63
+ const parsed = parseWslUnc(cwd)
64
+ const distro = parsed?.distro ?? (this._config.distro || defaultDistro())
65
+ // The workspace path wins: /home/alice means alice, /root means root.
66
+ const linux = parsed?.linuxPath ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : '')
67
+ const user = this._config.user || userFromLinuxPath(linux) || userFor(distro)
68
+ const key = `${distro}|${user}`
69
+ if (!this._impls) this._impls = new Map()
70
+ let impl = this._impls.get(key)
71
+ if (!impl) {
72
+ // Shared with the panel, so its connection state is the session's state.
73
+ const broker = registry.get(distro, user)
74
+ // Resolve the login PATH (minus /mnt) before the first command: the
75
+ // spawn path is synchronous, so it falls back until this connect lands.
76
+ broker.connect().catch(() => {})
77
+ impl = createWslSubprocess({ broker, distro, user })
78
+ this._impls.set(key, impl)
79
+ log(`impl created for ${key}`)
80
+ }
81
+ return impl
82
+ }
83
+
84
+ resolveExecutable(command, env, signal) {
85
+ log(`resolveExecutable ${command}`)
86
+ return this._for(undefined).resolveExecutable(command, env, signal)
87
+ }
88
+
89
+ terminalEnvironment(signal) {
90
+ return this._for(undefined).terminalEnvironment(signal)
91
+ }
92
+
93
+ spawn(spec) {
94
+ log(`spawn cwd=${spec?.cwd} argv=${JSON.stringify(spec?.argv)}`)
95
+ return this._for(spec?.cwd).spawn(spec)
96
+ }
97
+
98
+ async spawnTerminal(spec, delegate) {
99
+ // Delegate to the host PTY: wsl.exe allocates the tty, Linux bash inherits
100
+ // it. `root` is outside this isolate realm, so it is the local provider.
101
+ const host = delegate ?? this.ctx?.root?.get?.('subprocess')
102
+ if (!host || host === this) {
103
+ throw Object.assign(new Error('subprocess-wsl: host PTY provider unavailable'), { code: 'SUBPROCESS_UNSUPPORTED' })
104
+ }
105
+ return this._for(spec?.cwd).spawnTerminal(spec, host)
106
+ }
107
+
108
+ async dispose() {
109
+ for (const impl of this._impls?.values() ?? []) await impl.dispose?.().catch(() => {})
110
+ this._impls?.clear()
111
+ }
112
+ }