@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.
- 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
package/src/broker.mjs
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// One WslBroker per (distro, user), shared by every session and workspace.
|
|
2
|
+
// Plain ESM, no DSH dependency — testable on its own (see test/broker.test.mjs).
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { EventEmitter } from 'node:events'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const HELPER_VERSION = 1
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Enter the Linux world with a PATH that cannot resolve Windows binaries.
|
|
14
|
+
* Measured on this machine: without this, every wsl.exe-launched process sees
|
|
15
|
+
* ~20 `/mnt/...` entries (Windows node, npm, git, rg, powershell), and
|
|
16
|
+
* `env PATH=` / `env -i` do NOT survive WSL's interop PATH rewrite — only an
|
|
17
|
+
* in-process assignment before exec does.
|
|
18
|
+
*/
|
|
19
|
+
export const EXEC_WRAPPER = `#!/bin/bash
|
|
20
|
+
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
|
21
|
+
export PATH
|
|
22
|
+
while [ "$1" = "-e" ]; do export "$2" || exit 125; shift 2; done
|
|
23
|
+
exec "$@"
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
/** Parse a `\\wsl.localhost\<distro>\<path>` (or `\\wsl$\...`) Windows path. */
|
|
27
|
+
export function parseWslUnc(winPath) {
|
|
28
|
+
const m = /^\\\\[?]?(wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(winPath)
|
|
29
|
+
if (!m) return null
|
|
30
|
+
return { distro: m[2], linuxPath: '/' + m[3].replace(/\\/g, '/') }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function linuxToUnc(distro, linuxPath) {
|
|
34
|
+
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Binaries on a Windows drive are interop, not Linux: never execute them. */
|
|
38
|
+
export function isInteropBinary(p) {
|
|
39
|
+
return /^\/mnt\/[a-z]\//i.test(p)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keep the distribution's login PATH but drop every `/mnt/*` entry. The login
|
|
44
|
+
* PATH is what makes user toolchains (`~/.local/bin`, nvm, cargo, …) visible;
|
|
45
|
+
* dropping `/mnt` is what keeps Windows interop binaries unreachable.
|
|
46
|
+
*/
|
|
47
|
+
export function cleanLoginPath(raw) {
|
|
48
|
+
// A login profile may print a banner; the PATH itself is the last line.
|
|
49
|
+
const line = String(raw ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? ''
|
|
50
|
+
const entries = line
|
|
51
|
+
.split(':')
|
|
52
|
+
.map((entry) => entry.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.filter((entry) => !/^\/mnt(\/|$)/i.test(entry))
|
|
55
|
+
for (const dir of ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin']) {
|
|
56
|
+
if (!entries.includes(dir)) entries.push(dir)
|
|
57
|
+
}
|
|
58
|
+
return [...new Set(entries)].join(':') || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class WslBroker extends EventEmitter {
|
|
62
|
+
#distro
|
|
63
|
+
#user
|
|
64
|
+
#idleMs
|
|
65
|
+
#helperSource
|
|
66
|
+
#child = null
|
|
67
|
+
#state = 'detached'
|
|
68
|
+
#buf = ''
|
|
69
|
+
#seq = 0
|
|
70
|
+
#pending = new Map()
|
|
71
|
+
#connecting = null
|
|
72
|
+
#retains = new Map()
|
|
73
|
+
#idleTimer = null
|
|
74
|
+
#nodePath = null
|
|
75
|
+
#helperPath = null
|
|
76
|
+
#cleanPath = null
|
|
77
|
+
#lastError = null
|
|
78
|
+
#retried = false
|
|
79
|
+
#forceProbe = false
|
|
80
|
+
#hello = null
|
|
81
|
+
|
|
82
|
+
constructor({ distro, user = '', idleMs = 15 * 60_000, helperSource }) {
|
|
83
|
+
super()
|
|
84
|
+
this.#distro = distro
|
|
85
|
+
this.#user = user
|
|
86
|
+
this.#idleMs = idleMs
|
|
87
|
+
this.#helperSource = helperSource
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Apply a new idle timeout to an already-created broker. */
|
|
91
|
+
setIdleMs(ms) {
|
|
92
|
+
const n = Number(ms)
|
|
93
|
+
if (!Number.isFinite(n) || n < 0) return
|
|
94
|
+
this.#idleMs = n
|
|
95
|
+
this.#armIdleIfIdle()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get state() { return this.#state }
|
|
99
|
+
get hello() { return this.#hello }
|
|
100
|
+
get refcount() { return [...this.#retains.values()].reduce((a, b) => a + b, 0) }
|
|
101
|
+
/**
|
|
102
|
+
* The distribution's login-shell PATH without `/mnt/*` entries. Tools use it
|
|
103
|
+
* so a user-space toolchain (`~/.local/bin`, cargo, …) stays reachable while
|
|
104
|
+
* Windows interop binaries stay unreachable. Resolved on connect.
|
|
105
|
+
*/
|
|
106
|
+
get cleanPath() { return this.#cleanPath }
|
|
107
|
+
get info() {
|
|
108
|
+
return { distro: this.#distro, user: this.#user, state: this.#state, refcount: this.refcount, lastError: this.#lastError }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#setState(next, detail) {
|
|
112
|
+
if (this.#state === next) return
|
|
113
|
+
this.#state = next
|
|
114
|
+
this.emit('state', { ...this.info, detail })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Park a reference so the idle timer cannot fire while work is in flight. */
|
|
118
|
+
retain(reason) {
|
|
119
|
+
this.#retains.set(reason, (this.#retains.get(reason) ?? 0) + 1)
|
|
120
|
+
this.#disarmIdle()
|
|
121
|
+
return () => this.release(reason)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
release(reason) {
|
|
125
|
+
const n = (this.#retains.get(reason) ?? 0) - 1
|
|
126
|
+
if (n <= 0) this.#retains.delete(reason)
|
|
127
|
+
else this.#retains.set(reason, n)
|
|
128
|
+
this.#armIdleIfIdle()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#disarmIdle() {
|
|
132
|
+
if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#armIdleIfIdle() {
|
|
136
|
+
this.#disarmIdle()
|
|
137
|
+
if (this.#idleMs <= 0 || this.#state !== 'ready' || this.refcount > 0) return
|
|
138
|
+
this.#idleTimer = setTimeout(() => {
|
|
139
|
+
this.#idleTimer = null
|
|
140
|
+
this.disconnect('idle').catch(() => {})
|
|
141
|
+
}, this.#idleMs)
|
|
142
|
+
this.#idleTimer.unref?.()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Idempotent, single-flight connect. */
|
|
146
|
+
async connect() {
|
|
147
|
+
if (this.#state === 'ready') return this.info
|
|
148
|
+
if (this.#connecting) return this.#connecting
|
|
149
|
+
this.#connecting = this.#doConnect().finally(() => { this.#connecting = null })
|
|
150
|
+
return this.#connecting
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #doConnect() {
|
|
154
|
+
this.#setState('starting')
|
|
155
|
+
this.#lastError = null
|
|
156
|
+
try {
|
|
157
|
+
await this.#resolveCleanPath()
|
|
158
|
+
const { node, helper } = await this.#ensureHelper()
|
|
159
|
+
const hello = await this.#spawnHelper(node, helper)
|
|
160
|
+
this.#hello = hello
|
|
161
|
+
this.#setState('ready', hello)
|
|
162
|
+
this.#armIdleIfIdle()
|
|
163
|
+
return this.info
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// A stale cache entry (distro reinstall, nvm switch) must self-heal once
|
|
166
|
+
// with a real probe before the error reaches the caller.
|
|
167
|
+
if (!this.#forceProbe && this.#readCache(this.#hashOfSource())) {
|
|
168
|
+
this.#forceProbe = true
|
|
169
|
+
this.#nodePath = null
|
|
170
|
+
this.#helperPath = null
|
|
171
|
+
return this.#doConnect()
|
|
172
|
+
}
|
|
173
|
+
this.#lastError = error.message
|
|
174
|
+
this.#setState('error', { message: error.message })
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#spawnHelper(node, helper) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const args = [...this.#distroArgs(), ...this.#userArgs()]
|
|
182
|
+
args.push('--', node, helper)
|
|
183
|
+
const child = spawn('wsl.exe', args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
184
|
+
this.#child = child
|
|
185
|
+
let settled = false
|
|
186
|
+
const fail = (err) => { if (!settled) { settled = true; reject(err) } }
|
|
187
|
+
const timer = setTimeout(() => fail(new Error('helper handshake timed out after 15s')), 15_000)
|
|
188
|
+
child.on('error', fail)
|
|
189
|
+
child.on('exit', (code, signal) => {
|
|
190
|
+
clearTimeout(timer)
|
|
191
|
+
this.#onExit(code, signal)
|
|
192
|
+
if (!settled) fail(new Error(`helper exited before handshake (code=${code} signal=${signal})`))
|
|
193
|
+
})
|
|
194
|
+
child.stdout.on('data', (d) => { this.#buf += d.toString('utf8'); this.#drain() })
|
|
195
|
+
child.stderr.on('data', (d) => this.emit('stderr', d.toString('utf8')))
|
|
196
|
+
child.stdin.on('error', () => {})
|
|
197
|
+
this.once('hello', (h) => {
|
|
198
|
+
clearTimeout(timer)
|
|
199
|
+
settled = true
|
|
200
|
+
resolve(h)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#onExit(code, signal) {
|
|
206
|
+
const hadChild = this.#child !== null
|
|
207
|
+
this.#child = null
|
|
208
|
+
if (!hadChild) return
|
|
209
|
+
for (const [, p] of this.#pending) p.reject(new Error(`WSL helper exited (code=${code} signal=${signal})`))
|
|
210
|
+
this.#pending.clear()
|
|
211
|
+
this.#hello = null
|
|
212
|
+
if (this.#state === 'stopping' || this.#state === 'detached') return
|
|
213
|
+
this.#lastError = `helper exited unexpectedly (code=${code} signal=${signal})`
|
|
214
|
+
this.#setState('error', { message: this.#lastError })
|
|
215
|
+
if (this.refcount > 0 && !this.#retried) {
|
|
216
|
+
this.#retried = true
|
|
217
|
+
setTimeout(() => { this.#retried = false; this.connect().catch(() => {}) }, 500).unref?.()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#drain() {
|
|
222
|
+
let i
|
|
223
|
+
while ((i = this.#buf.indexOf('\n')) >= 0) {
|
|
224
|
+
const line = this.#buf.slice(0, i)
|
|
225
|
+
this.#buf = this.#buf.slice(i + 1)
|
|
226
|
+
if (!line.trim()) continue
|
|
227
|
+
let msg
|
|
228
|
+
try { msg = JSON.parse(line) } catch { continue }
|
|
229
|
+
if (msg.event === 'hello') { this.emit('hello', msg.result); continue }
|
|
230
|
+
const p = this.#pending.get(msg.id)
|
|
231
|
+
if (!p) continue
|
|
232
|
+
this.#pending.delete(msg.id)
|
|
233
|
+
msg.error ? p.reject(Object.assign(new Error(msg.error.message ?? String(msg.error)), msg.error)) : p.resolve(msg.result)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async call(op, args = {}, { signal } = {}) {
|
|
238
|
+
await this.connect()
|
|
239
|
+
if (signal?.aborted) throw new Error('aborted')
|
|
240
|
+
this.#disarmIdle()
|
|
241
|
+
try {
|
|
242
|
+
return await this.#roundTrip(op, args, signal)
|
|
243
|
+
} finally {
|
|
244
|
+
this.#armIdleIfIdle()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#roundTrip(op, args, signal) {
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
const id = ++this.#seq
|
|
251
|
+
const onAbort = () => { this.#pending.delete(id); reject(new Error('aborted')) }
|
|
252
|
+
this.#pending.set(id, {
|
|
253
|
+
resolve: (v) => { signal?.removeEventListener('abort', onAbort); resolve(v) },
|
|
254
|
+
reject: (e) => { signal?.removeEventListener('abort', onAbort); reject(e) },
|
|
255
|
+
})
|
|
256
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
257
|
+
try {
|
|
258
|
+
this.#child.stdin.write(JSON.stringify({ id, op, ...args }) + '\n')
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.#pending.delete(id)
|
|
261
|
+
reject(error)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async #resolveCleanPath() {
|
|
267
|
+
if (this.#cleanPath !== null) return
|
|
268
|
+
let raw = ''
|
|
269
|
+
try {
|
|
270
|
+
// `printenv` avoids `$` in wsl.exe's argv (it pre-expands `$VAR`).
|
|
271
|
+
raw = await this.#wsl('printenv PATH')
|
|
272
|
+
} catch {}
|
|
273
|
+
this.#cleanPath = cleanLoginPath(raw)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #ensureHelper() {
|
|
277
|
+
if (this.#nodePath && this.#helperPath) return { node: this.#nodePath, helper: this.#helperPath }
|
|
278
|
+
const hash = this.#hashOfSource()
|
|
279
|
+
if (!this.#forceProbe) {
|
|
280
|
+
const cached = this.#readCache(hash)
|
|
281
|
+
if (cached) {
|
|
282
|
+
this.#nodePath = cached.node
|
|
283
|
+
this.#helperPath = cached.helper
|
|
284
|
+
return { node: cached.node, helper: cached.helper }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
this.#forceProbe = false
|
|
288
|
+
const node = await this.#wsl('command -v node')
|
|
289
|
+
if (!node) throw new Error('WSL 发行版里没有 node(或只有 Windows 的 node.exe)。请安装 Node.js 22+,或等待 python3 兜底实现。')
|
|
290
|
+
if (isInteropBinary(node)) throw new Error(`node 解析到了 Windows 的二进制(${node}),拒绝使用`)
|
|
291
|
+
// Bootstrap through stdin: no /mnt, no package manager, no shared folder.
|
|
292
|
+
const helper = `$HOME/.dsh-wsl/broker-${hash}.mjs`
|
|
293
|
+
const present = await this.#wsl(`test -f ${helper} && echo yes`)
|
|
294
|
+
if (present !== 'yes') {
|
|
295
|
+
await this.#wslPipe(`mkdir -p $HOME/.dsh-wsl && cat > ${helper}`, this.#helperSource)
|
|
296
|
+
}
|
|
297
|
+
this.#nodePath = node
|
|
298
|
+
this.#helperPath = helper
|
|
299
|
+
this.#writeCache({ node, helper, hash })
|
|
300
|
+
return { node, helper }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#distroArgs() { return this.#distro ? ['-d', this.#distro] : [] }
|
|
304
|
+
#userArgs() { return this.#user ? ['-u', this.#user] : [] }
|
|
305
|
+
|
|
306
|
+
#cacheFile() {
|
|
307
|
+
return join(homedir(), '.dsh', 'wsl-broker-cache.json')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#hashOfSource() {
|
|
311
|
+
return createHash('sha256').update(`v${HELPER_VERSION}\n` + this.#helperSource).digest('hex').slice(0, 16)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#readCache(hash) {
|
|
315
|
+
try {
|
|
316
|
+
const all = JSON.parse(readFileSync(this.#cacheFile(), 'utf8'))
|
|
317
|
+
const entry = all[`${this.#distro}|${this.#user}`]
|
|
318
|
+
if (entry?.v === HELPER_VERSION && entry.h === hash && entry.node && entry.helper) return entry
|
|
319
|
+
} catch {}
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#writeCache({ node, helper, hash }) {
|
|
324
|
+
try {
|
|
325
|
+
const file = this.#cacheFile()
|
|
326
|
+
mkdirSync(join(homedir(), '.dsh'), { recursive: true })
|
|
327
|
+
let all = {}
|
|
328
|
+
try { all = JSON.parse(readFileSync(file, 'utf8')) } catch {}
|
|
329
|
+
all[`${this.#distro}|${this.#user}`] = { v: HELPER_VERSION, h: hash, node, helper, at: new Date().toISOString() }
|
|
330
|
+
writeFileSync(file, JSON.stringify(all, null, 2))
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#wsl(script) {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const args = [...this.#distroArgs(), ...this.#userArgs()]
|
|
337
|
+
args.push('--', 'bash', '-lc', script)
|
|
338
|
+
const child = spawn('wsl.exe', args, { windowsHide: true })
|
|
339
|
+
let out = ''
|
|
340
|
+
child.stdout.on('data', (d) => (out += d.toString('utf8')))
|
|
341
|
+
child.on('error', reject)
|
|
342
|
+
child.on('close', () => resolve(out.trim()))
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#wslPipe(script, input) {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
const args = [...this.#distroArgs(), ...this.#userArgs()]
|
|
349
|
+
args.push('--', 'bash', '-lc', script)
|
|
350
|
+
const child = spawn('wsl.exe', args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] })
|
|
351
|
+
let err = ''
|
|
352
|
+
child.stderr.on('data', (d) => (err += d.toString('utf8')))
|
|
353
|
+
child.on('error', reject)
|
|
354
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`bootstrap failed (${code}): ${err}`))))
|
|
355
|
+
child.stdin.end(input)
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async disconnect(reason = 'explicit') {
|
|
360
|
+
if (this.#state === 'detached' || this.#state === 'stopping') return
|
|
361
|
+
this.#setState('stopping', reason)
|
|
362
|
+
this.#disarmIdle()
|
|
363
|
+
const child = this.#child
|
|
364
|
+
if (child) {
|
|
365
|
+
try { child.stdin.write(JSON.stringify({ id: ++this.#seq, op: 'shutdown' }) + '\n') } catch {}
|
|
366
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
367
|
+
try { child.kill() } catch {}
|
|
368
|
+
}
|
|
369
|
+
this.#child = null
|
|
370
|
+
this.#hello = null
|
|
371
|
+
this.#setState('detached', reason)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Keep the diff card's Host-facing paths intact, but never show a Windows UNC
|
|
2
|
+
// string in a WSL session's labels. The summary's `path` and `cwd` are used by
|
|
3
|
+
// `/api/changes.open` to resolve the file on the Host, so only `display` is
|
|
4
|
+
// translated; relative display paths are already Linux-neutral.
|
|
5
|
+
import { linuxPathFromUnc } from './preset-default.mjs'
|
|
6
|
+
|
|
7
|
+
export const WSL_PRESET = 'wsl'
|
|
8
|
+
const PATCH = Symbol.for('dsh-wsl/changes-paths')
|
|
9
|
+
|
|
10
|
+
/** `/home/...` for an absolute WSL UNC display path; otherwise the input. */
|
|
11
|
+
export function mapDisplayPath(value) {
|
|
12
|
+
const linux = linuxPathFromUnc(value)
|
|
13
|
+
return linux ?? value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function unwrap(value) {
|
|
17
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
|
|
18
|
+
try { return value[Symbol.for('cordis.original')] ?? value } catch { return value }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Install the display-path translation on the Host `workspaceChanges` service.
|
|
23
|
+
* Only summaries/diffs owned by a WSL-preset Session are touched.
|
|
24
|
+
*
|
|
25
|
+
* @returns `{ installed, dispose }`; `installed` is a live getter.
|
|
26
|
+
*/
|
|
27
|
+
export function installWorkspaceChangesPaths(ctx) {
|
|
28
|
+
const disposers = []
|
|
29
|
+
let installed = false
|
|
30
|
+
|
|
31
|
+
const presetOfSession = (sessionId) => {
|
|
32
|
+
try {
|
|
33
|
+
const agent = ctx.get('agents')?.get?.(sessionId)
|
|
34
|
+
return ctx.get('agentPresets')?.presetForSession?.(agent?.session)
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const install = (service) => {
|
|
41
|
+
if (installed) return true
|
|
42
|
+
const raw = unwrap(service)
|
|
43
|
+
if (!raw || typeof raw.summary !== 'function' || typeof raw.diff !== 'function') return false
|
|
44
|
+
if (raw[PATCH]) { installed = true; return true }
|
|
45
|
+
|
|
46
|
+
const originalSummary = raw.summary
|
|
47
|
+
const originalDiff = raw.diff
|
|
48
|
+
const summaryDescriptor = Object.getOwnPropertyDescriptor(raw, 'summary')
|
|
49
|
+
const diffDescriptor = Object.getOwnPropertyDescriptor(raw, 'diff')
|
|
50
|
+
const summary = function summaryPatched(sessionId, seq) {
|
|
51
|
+
const value = originalSummary.call(raw, sessionId, seq)
|
|
52
|
+
if (!value || presetOfSession(sessionId) !== WSL_PRESET || !Array.isArray(value.files)) return value
|
|
53
|
+
return {
|
|
54
|
+
...value,
|
|
55
|
+
files: value.files.map((file) => ({ ...file, display: mapDisplayPath(file.display) })),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const diff = async function diffPatched(sessionId, seq, index, signal) {
|
|
59
|
+
const value = await originalDiff.call(raw, sessionId, seq, index, signal)
|
|
60
|
+
if (!value || presetOfSession(sessionId) !== WSL_PRESET) return value
|
|
61
|
+
return { ...value, display: mapDisplayPath(value.display) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
Object.defineProperty(raw, 'summary', { configurable: true, enumerable: false, writable: true, value: summary })
|
|
66
|
+
Object.defineProperty(raw, 'diff', { configurable: true, enumerable: false, writable: true, value: diff })
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
const dispose = () => {
|
|
71
|
+
try { if (summaryDescriptor) Object.defineProperty(raw, 'summary', summaryDescriptor) } catch {}
|
|
72
|
+
try { if (diffDescriptor) Object.defineProperty(raw, 'diff', diffDescriptor) } catch {}
|
|
73
|
+
try { delete raw[PATCH] } catch {}
|
|
74
|
+
}
|
|
75
|
+
Object.defineProperty(raw, PATCH, { configurable: true, enumerable: false, writable: true, value: dispose })
|
|
76
|
+
disposers.push(dispose)
|
|
77
|
+
installed = true
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const current = ctx.get('workspaceChanges')
|
|
82
|
+
if (current) install(current)
|
|
83
|
+
else ctx.on('internal/service', (name, value) => { if (name === 'workspaceChanges') install(value) })
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get installed() { return installed },
|
|
87
|
+
dispose() {
|
|
88
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
89
|
+
installed = false
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bounded, offset-based output collectors — matches the ctx.subprocess
|
|
2
|
+
// "reads are non-consuming" contract: independent readers see the same bytes.
|
|
3
|
+
export class Collector {
|
|
4
|
+
#chunks = []
|
|
5
|
+
#bytes = 0
|
|
6
|
+
#maxBytes
|
|
7
|
+
#lossy = false
|
|
8
|
+
#text = null
|
|
9
|
+
|
|
10
|
+
constructor(maxBytes) {
|
|
11
|
+
this.#maxBytes = maxBytes
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get lossy() { return this.#lossy }
|
|
15
|
+
|
|
16
|
+
write(chunk) {
|
|
17
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
18
|
+
this.#text = null
|
|
19
|
+
const room = this.#maxBytes - this.#bytes
|
|
20
|
+
if (buf.length <= room) {
|
|
21
|
+
this.#chunks.push(buf)
|
|
22
|
+
this.#bytes += buf.length
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (room > 0) {
|
|
26
|
+
this.#chunks.push(buf.subarray(0, room))
|
|
27
|
+
this.#bytes += room
|
|
28
|
+
}
|
|
29
|
+
this.#lossy = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#string() {
|
|
33
|
+
if (this.#text === null) this.#text = Buffer.concat(this.#chunks).toString('utf8')
|
|
34
|
+
return this.#text
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
readFrom(fromByte) {
|
|
38
|
+
const text = this.#string()
|
|
39
|
+
const total = Buffer.byteLength(text)
|
|
40
|
+
if (fromByte >= total) return { text: '', nextOffset: total, lossy: this.#lossy }
|
|
41
|
+
// Offsets are byte offsets; slice by bytes then decode, so multi-byte
|
|
42
|
+
// characters never split the returned window at the wrong place.
|
|
43
|
+
const buf = Buffer.from(text)
|
|
44
|
+
const slice = buf.subarray(fromByte).toString('utf8')
|
|
45
|
+
return { text: slice, nextOffset: total, lossy: this.#lossy }
|
|
46
|
+
}
|
|
47
|
+
}
|