@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
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Doctor: read-only WSL/distro checks and the few guided fixes we can run
|
|
2
|
+
// without a TTY (Node install, SSH key/config). Everything runs through
|
|
3
|
+
// wsl.exe with base64 scripts so `$` never reaches wsl.exe's argv.
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { defaultDistro, readSettings, userFor, writeSettings } from './settings.mjs'
|
|
9
|
+
|
|
10
|
+
const WSL_EXE = `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\wsl.exe`
|
|
11
|
+
|
|
12
|
+
function runWsl(args, { timeoutMs = 20_000, utf16 = false } = {}) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const child = spawn(WSL_EXE, args, { windowsHide: true })
|
|
15
|
+
let out = '', err = ''
|
|
16
|
+
let settled = false
|
|
17
|
+
const finish = (code) => {
|
|
18
|
+
if (settled) return
|
|
19
|
+
settled = true
|
|
20
|
+
clearTimeout(timer)
|
|
21
|
+
resolve({ code, out: out.trim(), err: err.trim() })
|
|
22
|
+
}
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
try { child.kill() } catch {}
|
|
25
|
+
finish(-1)
|
|
26
|
+
}, timeoutMs)
|
|
27
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString(utf16 ? 'utf16le' : 'utf8') })
|
|
28
|
+
child.stderr.on('data', (chunk) => { err += chunk.toString(utf16 ? 'utf16le' : 'utf8') })
|
|
29
|
+
child.on('error', (error) => { err += String(error); finish(-1) })
|
|
30
|
+
child.on('close', (code) => finish(code))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function base64Script(script) {
|
|
35
|
+
return Buffer.from(script, 'utf8').toString('base64')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function bashScript(distro, user, script, options) {
|
|
39
|
+
const args = ['-d', distro]
|
|
40
|
+
if (user) args.push('-u', user)
|
|
41
|
+
args.push('--', 'bash', '-lc', `printf %s ${base64Script(script)} | base64 -d | bash`)
|
|
42
|
+
return runWsl(args, options)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Distros installed for this Windows user, in `wsl -l -q` order. */
|
|
46
|
+
export async function listWslDistros() {
|
|
47
|
+
const result = await runWsl(['-l', '-q'], { utf16: true })
|
|
48
|
+
return [...new Set(result.out.replace(/\0/g, '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean))]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function wslVersion() {
|
|
52
|
+
const result = await runWsl(['--version'], { utf16: true })
|
|
53
|
+
const lines = result.out.replace(/\0/g, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
54
|
+
return { ok: result.code === 0, lines, raw: lines.join(' | ') }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readWslConfig() {
|
|
58
|
+
const file = join(homedir(), '.wslconfig')
|
|
59
|
+
try {
|
|
60
|
+
const text = readFileSync(file, 'utf8')
|
|
61
|
+
const mode = /^\s*networkingMode\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
|
|
62
|
+
const localhost = /^\s*localhostForwarding\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
|
|
63
|
+
const memory = /^\s*memory\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
|
|
64
|
+
return { present: true, file, networkingMode: mode || 'nat (default)', localhostForwarding: localhost || 'true (default)', memory }
|
|
65
|
+
} catch {
|
|
66
|
+
return { present: false, file, networkingMode: 'nat (default)', localhostForwarding: 'true (default)', memory: '' }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseKeyValues(output) {
|
|
71
|
+
const values = {}
|
|
72
|
+
for (const line of output.split(/\r?\n/)) {
|
|
73
|
+
const at = line.indexOf('=')
|
|
74
|
+
if (at <= 0) continue
|
|
75
|
+
values[line.slice(0, at)] = line.slice(at + 1)
|
|
76
|
+
}
|
|
77
|
+
return values
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function checkDistro(distro, user) {
|
|
81
|
+
const script = [
|
|
82
|
+
'echo "USER=$(id -un)"',
|
|
83
|
+
'echo "DEFAULT_USER=$(id -un)"',
|
|
84
|
+
'echo "UID=$(id -u)"',
|
|
85
|
+
'echo "HOME=$HOME"',
|
|
86
|
+
'echo "SHELL=$SHELL"',
|
|
87
|
+
'echo "NODE=$(command -v node || true)"',
|
|
88
|
+
'echo "NODE_VERSION=$(node -v 2>/dev/null || true)"',
|
|
89
|
+
'echo "GIT=$(command -v git || true)"',
|
|
90
|
+
'echo "GIT_VERSION=$(git --version 2>/dev/null || true)"',
|
|
91
|
+
'echo "SSH=$(command -v ssh || true)"',
|
|
92
|
+
'echo "SSH_VERSION=$(ssh -V 2>&1 | head -1)"',
|
|
93
|
+
'echo "BASH=$(command -v bash || true)"',
|
|
94
|
+
'echo "KEYS=$(ls ~/.ssh/*.pub 2>/dev/null | wc -l)"',
|
|
95
|
+
'echo "PRIVATE_KEYS=$(ls ~/.ssh/id_* 2>/dev/null | grep -v "\\.pub$" | wc -l)"',
|
|
96
|
+
'echo "SSH_CONFIG=$(test -f ~/.ssh/config && echo yes || echo no)"',
|
|
97
|
+
'echo "LOGIN_PATH_MNT=$(printf "%s" "$PATH" | tr ":" "\\n" | grep -c "^/mnt/")"',
|
|
98
|
+
'CLEAN_PATH=$(printf "%s" "$PATH" | tr ":" "\\n" | grep -v "^/mnt/" | paste -sd: -)',
|
|
99
|
+
'echo "CLEAN_PATH=$CLEAN_PATH"',
|
|
100
|
+
'echo "CLEAN_PATH_MNT=$(printf "%s" "$CLEAN_PATH" | tr ":" "\\n" | grep -c "^/mnt/")"',
|
|
101
|
+
'echo "PATH=$PATH"',
|
|
102
|
+
"getent passwd | awk -F: '$3>=1000 && $3<65534 {print \"USER_ENTRY=\"$1\":\"$3\":\"$6\":\"$7}'",
|
|
103
|
+
].join('\n')
|
|
104
|
+
const result = await bashScript(distro, user, script, { timeoutMs: 20_000 })
|
|
105
|
+
const values = parseKeyValues(result.out)
|
|
106
|
+
const users = []
|
|
107
|
+
for (const line of result.out.split(/\r?\n/)) {
|
|
108
|
+
if (!line.startsWith('USER_ENTRY=')) continue
|
|
109
|
+
const [name, uid, home, shell] = line.slice('USER_ENTRY='.length).split(':')
|
|
110
|
+
if (!name || !Number.isFinite(Number(uid))) continue
|
|
111
|
+
users.push({ name, uid: Number(uid), home: home ?? '', shell: shell ?? '' })
|
|
112
|
+
}
|
|
113
|
+
users.sort((left, right) => left.uid - right.uid || left.name.localeCompare(right.name))
|
|
114
|
+
const uid = Number(values.UID ?? -1)
|
|
115
|
+
return {
|
|
116
|
+
distro,
|
|
117
|
+
user: values.USER ?? '',
|
|
118
|
+
defaultUser: values.DEFAULT_USER ?? values.USER ?? '',
|
|
119
|
+
requestedUser: user ?? '',
|
|
120
|
+
uid,
|
|
121
|
+
root: uid === 0,
|
|
122
|
+
home: values.HOME ?? '',
|
|
123
|
+
shell: values.SHELL ?? '',
|
|
124
|
+
users,
|
|
125
|
+
node: { path: values.NODE ?? '', version: values.NODE_VERSION ?? '' },
|
|
126
|
+
git: { path: values.GIT ?? '', version: values.GIT_VERSION ?? '' },
|
|
127
|
+
ssh: { path: values.SSH ?? '', version: values.SSH_VERSION ?? '' },
|
|
128
|
+
bash: values.BASH ?? '',
|
|
129
|
+
keys: Number(values.KEYS ?? 0),
|
|
130
|
+
privateKeys: Number(values.PRIVATE_KEYS ?? 0),
|
|
131
|
+
sshConfig: values.SSH_CONFIG === 'yes',
|
|
132
|
+
path: {
|
|
133
|
+
value: values.CLEAN_PATH ?? values.PATH ?? '',
|
|
134
|
+
loginMntEntries: Number(values.LOGIN_PATH_MNT ?? 0),
|
|
135
|
+
mntEntries: Number(values.CLEAN_PATH_MNT ?? 0),
|
|
136
|
+
},
|
|
137
|
+
error: result.code === 0 ? '' : (result.err || `wsl exited ${result.code}`),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Lightweight user discovery used at Host boot and by the doctor route. */
|
|
142
|
+
export async function detectUsers(distro) {
|
|
143
|
+
const check = await checkDistro(distro, '')
|
|
144
|
+
return { defaultUser: check.defaultUser, root: check.root, users: check.users }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read-only doctor snapshot; the panel renders it. */
|
|
148
|
+
export async function runDoctor(ctx, plugin) {
|
|
149
|
+
const [version, distros] = await Promise.all([wslVersion(), listWslDistros()])
|
|
150
|
+
const settings = readSettings()
|
|
151
|
+
const checks = []
|
|
152
|
+
for (const distro of distros) {
|
|
153
|
+
const user = settings.users[distro] ?? ''
|
|
154
|
+
checks.push(await checkDistro(distro, user))
|
|
155
|
+
}
|
|
156
|
+
// A distro whose default user is root should not silently drive the Agent as
|
|
157
|
+
// root: prefer its first normal user once we know one.
|
|
158
|
+
const users = { ...settings.users }
|
|
159
|
+
let changed = false
|
|
160
|
+
for (const check of checks) {
|
|
161
|
+
if (check.root && check.users.length > 0 && !users[check.distro]) {
|
|
162
|
+
users[check.distro] = check.users[0].name
|
|
163
|
+
changed = true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (changed) {
|
|
167
|
+
writeSettings({ users })
|
|
168
|
+
for (const check of checks) {
|
|
169
|
+
if (users[check.distro] && users[check.distro] !== check.requestedUser) {
|
|
170
|
+
Object.assign(check, await checkDistro(check.distro, users[check.distro]))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
at: new Date().toISOString(),
|
|
176
|
+
wsl: { version, distros },
|
|
177
|
+
network: { ...readWslConfig(), defaultDistro: defaultDistro() },
|
|
178
|
+
settings: changed ? readSettings() : settings,
|
|
179
|
+
checks,
|
|
180
|
+
plugin,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Guided fixes that need no interactive password. */
|
|
185
|
+
export async function runFix(distro, action, requestedUser, requestedName = '') {
|
|
186
|
+
const user = requestedUser || userFor(distro)
|
|
187
|
+
if (action === 'node') {
|
|
188
|
+
// System packages belong to the distro default/root identity; the Agent's
|
|
189
|
+
// normal user usually has no passwordless sudo.
|
|
190
|
+
const nodeUser = requestedUser || ''
|
|
191
|
+
const script = [
|
|
192
|
+
'if command -v node >/dev/null 2>&1; then echo "ALREADY"; command -v node; node -v; exit 0; fi',
|
|
193
|
+
'if ! command -v apt-get >/dev/null 2>&1; then echo "NO_APT"; exit 2; fi',
|
|
194
|
+
'if [ "$(id -u)" = 0 ]; then SUDO=""; else if ! sudo -n true 2>/dev/null; then echo "NO_SUDO"; exit 3; fi; SUDO="sudo -n"; fi',
|
|
195
|
+
'$SUDO apt-get update -qq && DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq nodejs',
|
|
196
|
+
'command -v node; node -v',
|
|
197
|
+
].join('\n')
|
|
198
|
+
const result = await bashScript(distro, nodeUser, script, { timeoutMs: 300_000 })
|
|
199
|
+
return { action, distro, user: nodeUser, code: result.code, out: result.out, err: result.err }
|
|
200
|
+
}
|
|
201
|
+
if (action === 'ssh-key') {
|
|
202
|
+
const script = [
|
|
203
|
+
'mkdir -p ~/.ssh && chmod 700 ~/.ssh',
|
|
204
|
+
'if [ ! -f ~/.ssh/id_ed25519 ]; then ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 -C "dsh-wsl" >/dev/null; fi',
|
|
205
|
+
'cat ~/.ssh/id_ed25519.pub',
|
|
206
|
+
].join('\n')
|
|
207
|
+
const result = await bashScript(distro, user, script, { timeoutMs: 30_000 })
|
|
208
|
+
return { action, distro, user, code: result.code, out: result.out, err: result.err }
|
|
209
|
+
}
|
|
210
|
+
if (action === 'ssh-config') {
|
|
211
|
+
const script = [
|
|
212
|
+
'mkdir -p ~/.ssh && chmod 700 ~/.ssh',
|
|
213
|
+
'touch ~/.ssh/config && chmod 600 ~/.ssh/config',
|
|
214
|
+
'if ! grep -qE "ControlMaster|dsh-wsl" ~/.ssh/config; then',
|
|
215
|
+
" printf '%s\\n' '# dsh-wsl: reuse one SSH connection across Agent calls' 'Host *' ' ServerAliveInterval 30' ' ServerAliveCountMax 3' ' ConnectTimeout 10' ' ControlMaster auto' ' ControlPath ~/.ssh/cm-%r@%h:%p' ' ControlPersist 10m' >> ~/.ssh/config",
|
|
216
|
+
'fi',
|
|
217
|
+
'cat ~/.ssh/config',
|
|
218
|
+
].join('\n')
|
|
219
|
+
const result = await bashScript(distro, user, script, { timeoutMs: 30_000 })
|
|
220
|
+
return { action, distro, user, code: result.code, out: result.out, err: result.err }
|
|
221
|
+
}
|
|
222
|
+
if (action === 'user') {
|
|
223
|
+
const name = /^[a-z_][a-z0-9_-]{0,31}$/.test(requestedName) ? requestedName : 'chronos'
|
|
224
|
+
const createAs = requestedUser || ''
|
|
225
|
+
const script = [
|
|
226
|
+
`NAME=${name}`,
|
|
227
|
+
'if getent passwd "$NAME" >/dev/null 2>&1; then id "$NAME"; exit 0; fi',
|
|
228
|
+
'if [ "$(id -u)" = 0 ]; then SUDO=""; else if ! sudo -n true 2>/dev/null; then echo "NO_SUDO"; exit 3; fi; SUDO="sudo -n"; fi',
|
|
229
|
+
'$SUDO useradd -m -s /bin/bash "$NAME"',
|
|
230
|
+
'$SUDO passwd -l "$NAME" >/dev/null 2>&1 || true',
|
|
231
|
+
'id "$NAME"',
|
|
232
|
+
].join('\n')
|
|
233
|
+
const result = await bashScript(distro, createAs, script, { timeoutMs: 60_000 })
|
|
234
|
+
return { action, distro, user: createAs || 'root', name, code: result.code, out: result.out, err: result.err }
|
|
235
|
+
}
|
|
236
|
+
throw Object.assign(new Error(`unknown doctor action: ${action}`), { code: 'DOCTOR_UNKNOWN_ACTION' })
|
|
237
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// WSL-backed `@file` discovery for WSL-preset Agents.
|
|
2
|
+
//
|
|
3
|
+
// The shipped `file-reference-local` provider walks the Host filesystem at the
|
|
4
|
+
// session's UNC cwd (Node fs over 9P) and only knows Windows paths. This patch
|
|
5
|
+
// keeps that provider for every other preset and answers WSL Agents from the
|
|
6
|
+
// distribution: a bounded `tree` RPC through the broker, ranked exactly like
|
|
7
|
+
// the host provider, with paths relative to the Linux cwd. A relative `@path`
|
|
8
|
+
// then means the same file to the model and to the Host UI.
|
|
9
|
+
import { registry } from './registry.mjs'
|
|
10
|
+
import { linuxPathFromUnc } from './preset-default.mjs'
|
|
11
|
+
import { userFor } from './settings.mjs'
|
|
12
|
+
import { userFromLinuxPath } from './path-user.mjs'
|
|
13
|
+
|
|
14
|
+
export const WSL_PRESET = 'wsl'
|
|
15
|
+
const MAX_RESULTS = 20
|
|
16
|
+
const MAX_ENTRIES = 50_000
|
|
17
|
+
const CACHE_MS = 5_000
|
|
18
|
+
const PATCH = Symbol.for('dsh-wsl/file-references')
|
|
19
|
+
|
|
20
|
+
const EXCLUDED = new Set([
|
|
21
|
+
'.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
|
|
22
|
+
'.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
|
|
23
|
+
'.mypy_cache', '.gradle',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
function visibleForGlobalQuery(path, query) {
|
|
27
|
+
if (query.startsWith('.') || query.includes('/.')) return true
|
|
28
|
+
return !path.split('/').some((segment) => segment.startsWith('.'))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function subsequenceScore(target, query) {
|
|
32
|
+
let targetIndex = 0
|
|
33
|
+
let gap = 0
|
|
34
|
+
for (const character of query) {
|
|
35
|
+
const found = target.indexOf(character, targetIndex)
|
|
36
|
+
if (found < 0) return undefined
|
|
37
|
+
gap += found - targetIndex
|
|
38
|
+
targetIndex = found + 1
|
|
39
|
+
}
|
|
40
|
+
return Math.max(0, 100 - gap)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scoreCandidate(candidate, query) {
|
|
44
|
+
if (query === '') return 0
|
|
45
|
+
const path = candidate.path.toLowerCase()
|
|
46
|
+
const name = path.slice(path.lastIndexOf('/') + 1)
|
|
47
|
+
const needle = query.toLowerCase()
|
|
48
|
+
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
|
49
|
+
if (name === needle) return 1_000 + directoryBonus
|
|
50
|
+
if (name.startsWith(needle)) return 900 + directoryBonus
|
|
51
|
+
if (name.includes(needle)) return 700 + directoryBonus
|
|
52
|
+
if (path.includes(needle)) return 500 + directoryBonus
|
|
53
|
+
const subsequence = subsequenceScore(path, needle)
|
|
54
|
+
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function kindRank(kind) { return kind === 'directory' ? 0 : 1 }
|
|
58
|
+
function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0 }
|
|
59
|
+
|
|
60
|
+
function rankCandidates(candidates, query, limit) {
|
|
61
|
+
const ranked = []
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const score = scoreCandidate(candidate, query)
|
|
64
|
+
if (score !== undefined) ranked.push({ candidate, score })
|
|
65
|
+
}
|
|
66
|
+
ranked.sort((left, right) => right.score - left.score
|
|
67
|
+
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
|
68
|
+
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
|
69
|
+
|| compareText(left.candidate.path, right.candidate.path))
|
|
70
|
+
return ranked.slice(0, limit).map((entry) => entry.candidate)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Rank bounded tree entries for one `@` token; pure, used by tests. */
|
|
74
|
+
export function candidatesFromEntries(entries, rawQuery, limit = MAX_RESULTS) {
|
|
75
|
+
const query = String(rawQuery ?? '').replaceAll('\\', '/')
|
|
76
|
+
const slash = query.lastIndexOf('/')
|
|
77
|
+
if (query === '' || slash >= 0) {
|
|
78
|
+
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
|
79
|
+
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
|
80
|
+
const direct = entries
|
|
81
|
+
.filter((entry) => entry.path.startsWith(directory)
|
|
82
|
+
&& entry.path !== directory.replace(/\/$/, '')
|
|
83
|
+
&& !entry.path.slice(directory.length).includes('/'))
|
|
84
|
+
.map((entry) => ({ path: entry.path, kind: entry.kind }))
|
|
85
|
+
return rankCandidates(direct, fragment, limit)
|
|
86
|
+
}
|
|
87
|
+
const visible = entries
|
|
88
|
+
.filter((entry) => visibleForGlobalQuery(entry.path, query))
|
|
89
|
+
.filter((entry) => !entry.path.split('/').some((segment) => EXCLUDED.has(segment)))
|
|
90
|
+
.map((entry) => ({ path: entry.path, kind: entry.kind }))
|
|
91
|
+
return rankCandidates(visible, query, limit)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unwrap(value) {
|
|
95
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
|
|
96
|
+
try { return value[Symbol.for('cordis.original')] ?? value } catch { return value }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One broker call to the distro embedded in a WSL UNC cwd. */
|
|
100
|
+
function brokerFor(cwd) {
|
|
101
|
+
const match = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\/i.exec(String(cwd ?? ''))
|
|
102
|
+
const distro = match ? match[1] : ''
|
|
103
|
+
const linux = linuxPathFromUnc(cwd) ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : '')
|
|
104
|
+
return registry.get(distro, userFromLinuxPath(linux) || userFor(distro))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Install the WSL `@file` provider over the Host `fileReferences` service.
|
|
109
|
+
* Non-WSL Agents keep the shipped provider; a broker failure falls back to it.
|
|
110
|
+
*
|
|
111
|
+
* @returns `{ installed, dispose }`; `installed` is a live getter.
|
|
112
|
+
*/
|
|
113
|
+
export function installWslFileReferences(ctx) {
|
|
114
|
+
const disposers = []
|
|
115
|
+
const caches = new Map()
|
|
116
|
+
let installed = false
|
|
117
|
+
|
|
118
|
+
const presetOf = (agent) => {
|
|
119
|
+
try {
|
|
120
|
+
return ctx.get('agentPresets')?.presetForSession?.(agent?.session)
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const cwdOf = (agent) => {
|
|
126
|
+
try {
|
|
127
|
+
return ctx.get('workingDirectory')?.get?.(agent?.session)
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const listFromWsl = async (agent, query, signal) => {
|
|
134
|
+
if (presetOf(agent) !== WSL_PRESET) return undefined
|
|
135
|
+
const cwd = cwdOf(agent)
|
|
136
|
+
const linux = linuxPathFromUnc(cwd) ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : undefined)
|
|
137
|
+
if (linux === undefined) return undefined
|
|
138
|
+
const key = String(agent?.session?.id ?? agent?.session ?? '')
|
|
139
|
+
let cached = caches.get(key)
|
|
140
|
+
if (cached === undefined || cached.cwd !== linux || Date.now() - cached.at > CACHE_MS) {
|
|
141
|
+
signal?.throwIfAborted?.()
|
|
142
|
+
// `glob` already exists in every helper version and respects the Git
|
|
143
|
+
// worktree on 1.0.8+; directories are derived from the file paths.
|
|
144
|
+
const found = await brokerFor(cwd).call('glob', { path: linux, pattern: '**/*', max: MAX_ENTRIES }, { signal })
|
|
145
|
+
const entries = new Map()
|
|
146
|
+
const base = linux.replace(/\/+$/, '')
|
|
147
|
+
for (const full of found.paths ?? []) {
|
|
148
|
+
const rel = String(full).slice(base.length + 1).replace(/\\/g, '/')
|
|
149
|
+
if (rel === '' || rel.startsWith('/')) continue
|
|
150
|
+
const parts = rel.split('/')
|
|
151
|
+
if (parts.some((part) => EXCLUDED.has(part))) continue
|
|
152
|
+
entries.set(rel, 'file')
|
|
153
|
+
for (let i = 1; i < parts.length; i += 1) entries.set(parts.slice(0, i).join('/'), 'directory')
|
|
154
|
+
}
|
|
155
|
+
cached = { cwd: linux, entries: [...entries].map(([path, kind]) => ({ path, kind })), at: Date.now() }
|
|
156
|
+
caches.set(key, cached)
|
|
157
|
+
}
|
|
158
|
+
return candidatesFromEntries(cached.entries, query, MAX_RESULTS)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const install = (service) => {
|
|
162
|
+
if (installed) return true
|
|
163
|
+
const raw = unwrap(service)
|
|
164
|
+
if (!raw || typeof raw.list !== 'function') return false
|
|
165
|
+
if (raw[PATCH]) { installed = true; return true }
|
|
166
|
+
const original = raw.list
|
|
167
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, 'list')
|
|
168
|
+
const patched = async function list(agent, query, signal) {
|
|
169
|
+
try {
|
|
170
|
+
const result = await listFromWsl(agent, query, signal)
|
|
171
|
+
if (result !== undefined) return result
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (signal?.aborted) throw error
|
|
174
|
+
}
|
|
175
|
+
return original.call(raw, agent, query, signal)
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
Object.defineProperty(raw, 'list', { configurable: true, enumerable: false, writable: true, value: patched })
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
const dispose = () => {
|
|
183
|
+
try {
|
|
184
|
+
if (descriptor) Object.defineProperty(raw, 'list', descriptor)
|
|
185
|
+
else delete raw.list
|
|
186
|
+
} catch {}
|
|
187
|
+
try { delete raw[PATCH] } catch {}
|
|
188
|
+
}
|
|
189
|
+
Object.defineProperty(raw, PATCH, { configurable: true, enumerable: false, writable: true, value: dispose })
|
|
190
|
+
disposers.push(dispose)
|
|
191
|
+
installed = true
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const current = ctx.get('fileReferences')
|
|
196
|
+
if (current) install(current)
|
|
197
|
+
else ctx.on('internal/service', (name, value) => { if (name === 'fileReferences') install(value) })
|
|
198
|
+
|
|
199
|
+
// A tool result can add/remove files; the next @ rebuilds the bounded tree.
|
|
200
|
+
ctx.on('session/event', (session, event) => {
|
|
201
|
+
if (event?.type !== 'tool/result') return
|
|
202
|
+
const key = String(session?.id ?? '')
|
|
203
|
+
if (key) caches.delete(key)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
get installed() { return installed },
|
|
208
|
+
dispose() {
|
|
209
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
210
|
+
caches.clear()
|
|
211
|
+
installed = false
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|