@chronosun/dsh-wsl 0.0.0-stage → 1.5.1
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/LICENSE +21 -0
- package/README.md +233 -2
- package/client.js +645 -0
- package/cordis.patch.yml +79 -0
- package/fs-service.mjs +57 -0
- package/host.js +262 -0
- package/icon.svg +11 -0
- package/index.js +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +86 -4
- package/prompt.mjs +25 -0
- package/search-tools.mjs +91 -0
- package/src/broker.mjs +373 -0
- package/src/changes-paths.mjs +92 -0
- package/src/collector.mjs +47 -0
- package/src/doctor.mjs +237 -0
- package/src/file-references.mjs +214 -0
- package/src/fs.mjs +312 -0
- package/src/helper.mjs +455 -0
- package/src/launch.mjs +54 -0
- package/src/path-user.mjs +13 -0
- package/src/preset-default.mjs +277 -0
- package/src/registry.mjs +56 -0
- package/src/settings.mjs +51 -0
- package/src/subprocess.mjs +180 -0
- package/src/wsl-diff-v2.mjs +315 -0
- package/terminal-launcher.mjs +59 -0
- package/worktree-tool-v2.mjs +102 -0
- package/world.js +112 -0
|
@@ -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
|
+
}
|