dsh-wsl-workspace 0.2.2 → 0.2.3

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.
@@ -1,194 +1,233 @@
1
- /**
2
- * Browser half of dsh-wsl-workspace. Registers the "Add WSL workspace…"
3
- * action beside Settings at the sidebar foot (the official
4
- * `sidebar.footer.action` slot), and keeps every blank session whose
5
- * workspace is a WSL UNC path composed from the WSL VARIANT of the mode it
6
- * currently runs (`standard` → `wsl-standard`, PTC → `wsl-code`, …) — so the
7
- * WSL execution world composes with any mode instead of being a mode itself.
8
- *
9
- * The binding is a watching effect rather than a one-shot dialog action so
10
- * EVERY creation path (this dialog, the workspace row's New Session, the
11
- * hero picker) converges on the WSL-backed composition automatically.
12
- */
13
-
14
- import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
15
- // Type-only: pulls the locale plugin's Context merge (ctx.locale), the
16
- // runtime's ClientContext, and the ui-sidebar SlotMap merge (the
17
- // 'sidebar.footer.action' entry) into this program.
18
- import type {} from '@deepseek-ai/dsh-client-locale/client'
19
- import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
20
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
- import { check as checkApi, listDir as listDirApi, listDistros as listDistrosApi, setWorkspaceUser as setWorkspaceUserApi } from './api.ts'
22
- import { AddWslWorkspace, type AddWslWorkspaceInjected } from './AddWslWorkspace.tsx'
23
- import { ensureStyles } from './styles.ts'
24
- import { zh, en } from './locales.ts'
25
- import { isWslUnc } from '../shared/paths.ts'
26
-
27
- /** Required services (cordis fiber inject). */
28
- export const inject = ['slots', 'locale', 'connection', 'sessions', 'workspaces']
29
-
30
- /** The legacy standalone WSL preset id (folded into the mode variants). */
31
- const LEGACY_WSL_PRESET_ID = 'wsl'
32
-
33
- /**
34
- * Minimal sessions-service face. The renderer-host ctx merge types
35
- * `ctx.sessions` as its own SessionStore; the service the runtime actually
36
- * registers under that key satisfies this narrower contract, so the cast is
37
- * the documented boundary for a third-party plugin.
38
- */
39
- interface WslSessionsFace {
40
- list: {
41
- getSnapshot(): { ids: string[]; byId: Record<string, { blank: boolean; cwd?: string; agentPreset?: string }> }
42
- subscribe(fn: () => void): () => void
43
- }
44
- noteAgentPreset(sessionId: string, agentPreset: string): void
45
- }
46
-
47
- /** Minimal workspaces-service face (create + start-session only). */
48
- interface WslWorkspacesFace {
49
- create(input: { path: string }): Promise<{ workspaceId: string }>
50
- startSession(workspaceId?: string): void
51
- }
52
-
53
- /**
54
- * Mount the sidebar action and the auto-binding effect.
55
- * @param ctx - the browser plugin context.
56
- */
57
- export function apply(ctx: ClientContext): void {
58
- const { api } = ctx.get('connection') as ConnectionHandle
59
- const workspaces = ctx.get('workspaces') as unknown as WslWorkspacesFace
60
- const sessions = ctx.get('sessions') as unknown as WslSessionsFace
61
-
62
- ensureStyles()
63
-
64
- ctx.effect(
65
- () => ctx.locale.register('wslWorkspace' as never, { zh, en }),
66
- 'dsh-wsl-workspace: locale dictionaries',
67
- )
68
-
69
- // The injected translate function reads the live DeepSeek Harness locale,
70
- // so the dialog copy follows the app language setting automatically.
71
- const t = ctx.locale.bind('wslWorkspace' as never) as unknown as (key: string, params?: Record<string, unknown>) => string
72
-
73
- const injected = (): AddWslWorkspaceInjected => ({
74
- t,
75
- checkPreset: async (): Promise<string | undefined> => {
76
- let roster
77
- try {
78
- const response = await api.agentPresets.list({})
79
- roster = response.result
80
- } catch (error) {
81
- return error instanceof Error ? error.message : String(error)
82
- }
83
- if (!roster.ok) return roster.error.message
84
- const healthy = roster.value.presets.find((entry: { id: string; broken?: string }) =>
85
- entry.id.startsWith('wsl-') && entry.broken === undefined)
86
- if (healthy === undefined) return t('error.presetMissing')
87
- return undefined
88
- },
89
- listDistros: () => listDistrosApi(),
90
- listDir: (distro, path) => listDirApi(distro, path),
91
- check: (distro, path) => checkApi(distro, path),
92
- createWorkspace: async (path, username): Promise<string | undefined> => {
93
- try {
94
- const view = await workspaces.create({ path })
95
- await setWorkspaceUserApi(path, username)
96
- workspaces.startSession(view.workspaceId)
97
- return undefined
98
- } catch (error) {
99
- return error instanceof Error ? error.message : String(error)
100
- }
101
- },
102
- })
103
-
104
- ctx.effect(
105
- () => ctx.slots.inject(
106
- 'sidebar.footer.action',
107
- () => ctx.slots.register(
108
- { name: 'sidebar.footer.action', id: 'wsl-workspace', inject: injected },
109
- AddWslWorkspace,
110
- ),
111
- ),
112
- 'dsh-wsl-workspace: sidebar footer action',
113
- )
114
-
115
- // Mode-variant binding: a blank session whose workspace is a WSL UNC path
116
- // is recomposed to the WSL variant of the mode it currently runs — plain
117
- // 标准 becomes `wsl-standard`, PTC becomes `wsl-code`, and so on — so the
118
- // WSL execution world composes with ANY mode instead of replacing it. The
119
- // host refuses non-blank sessions (agent-preset-locked), so the swap is
120
- // attempted at most a few times per session.
121
- ctx.effect(() => {
122
- const inFlight = new Set<string>()
123
- const attempts = new Map<string, number>()
124
- const MAX_ATTEMPTS = 3
125
- // Healthy `wsl-<mode>` variant ids plus the roster's default preset id
126
- // (what a session with no explicit choice gets). Refreshed periodically
127
- // so variants generated after this page loaded are picked up.
128
- let variants = new Set<string>()
129
- let defaultPreset: string | undefined
130
- const refreshRoster = (): void => {
131
- void api.agentPresets.list({}).then((response: {
132
- result: { ok: boolean; value: { presets: { id: string; broken?: string; isDefault?: boolean }[] } }
133
- }) => {
134
- const result = response.result
135
- if (!result.ok) return
136
- variants = new Set(result.value.presets
137
- .filter((entry: { id: string; broken?: string }) =>
138
- entry.broken === undefined && entry.id.startsWith('wsl-'))
139
- .map((entry: { id: string }) => entry.id))
140
- defaultPreset = result.value.presets.find(
141
- (entry: { id: string; isDefault?: boolean }) => entry.isDefault === true,
142
- )?.id
143
- }).catch(() => {
144
- // A failed roster read leaves the previous mapping; sessions stay on
145
- // their current composition until the next refresh.
146
- })
147
- }
148
- refreshRoster()
149
- const maybeBind = (): void => {
150
- const state = sessions.list.getSnapshot()
151
- for (const id of state.ids) {
152
- const summary = state.byId[id]
153
- if (summary === undefined || !summary.blank || summary.cwd === undefined) continue
154
- if (!isWslUnc(summary.cwd)) continue
155
- const current = summary.agentPreset
156
- if (current !== undefined && current.startsWith('wsl-')) continue
157
- // Legacy standalone `wsl` (now folded into the variants): remap it to
158
- // the default mode's variant, since the standalone preset no longer
159
- // exists in the roster.
160
- const base = current === LEGACY_WSL_PRESET_ID
161
- ? (defaultPreset ?? 'standard')
162
- : (current ?? defaultPreset)
163
- if (base === undefined || base === LEGACY_WSL_PRESET_ID || base.startsWith('wsl-')) continue
164
- const target = `wsl-${base.toLowerCase()}`
165
- if (!variants.has(target)) continue
166
- if (inFlight.has(id) || (attempts.get(id) ?? 0) >= MAX_ATTEMPTS) continue
167
- inFlight.add(id)
168
- void api.agentPresets.select({ sessionId: id, agentPreset: target })
169
- .then((response: { result: { ok: boolean } }) => {
170
- if (response.result.ok) sessions.noteAgentPreset(id, target)
171
- })
172
- .catch(() => {
173
- // A refused or aborted swap (session already produced output,
174
- // roster churn, reconnect) leaves the session on its current
175
- // composition; count the attempt so a stuck session stops
176
- // retrying after MAX_ATTEMPTS.
177
- attempts.set(id, (attempts.get(id) ?? 0) + 1)
178
- })
179
- .finally(() => {
180
- inFlight.delete(id)
181
- })
182
- }
183
- }
184
- maybeBind()
185
- const unsubscribe = sessions.list.subscribe(() => maybeBind())
186
- // Variants are generated at host boot; a page loaded before that would
187
- // never see them without a periodic refresh.
188
- const timer = window.setInterval(refreshRoster, 60_000)
189
- return () => {
190
- unsubscribe()
191
- window.clearInterval(timer)
192
- }
193
- }, 'dsh-wsl-workspace: WSL mode-variant binding')
194
- }
1
+ /**
2
+ * Browser half of dsh-wsl-workspace. Registers the "Add WSL workspace…"
3
+ * action beside Settings at the sidebar foot (the official
4
+ * `sidebar.footer.action` slot), and keeps every blank session whose
5
+ * workspace is a WSL UNC path composed from the WSL VARIANT of the mode it
6
+ * currently runs (`standard` → `wsl-standard`, PTC → `wsl-code`, …) — so the
7
+ * WSL execution world composes with any mode instead of being a mode itself.
8
+ *
9
+ * The binding is a watching effect rather than a one-shot dialog action so
10
+ * EVERY creation path (this dialog, the workspace row's New Session, the
11
+ * hero picker) converges on the WSL-backed composition automatically.
12
+ */
13
+
14
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
15
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale), the
16
+ // runtime's ClientContext, and the ui-sidebar SlotMap merge (the
17
+ // 'sidebar.footer.action' entry) into this program.
18
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
19
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
20
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
+ import { check as checkApi, listDir as listDirApi, listDistros as listDistrosApi, listWorkspaces as listWorkspacesApi, registerWindows as registerWindowsApi, setWorkspaceUser as setWorkspaceUserApi } from './api.ts'
22
+ import { AddWslWorkspace, type AddWslWorkspaceInjected } from './AddWslWorkspace.tsx'
23
+ import { ensureStyles } from './styles.ts'
24
+ import { zh, en } from './locales.ts'
25
+ import { canonicalWindowsPath, isWslUnc, joinUnc, mntToWindowsPath } from '../shared/paths.ts'
26
+
27
+ /** Required services (cordis fiber inject). */
28
+ export const inject = ['slots', 'locale', 'connection', 'sessions', 'workspaces']
29
+
30
+ /** The legacy standalone WSL preset id (folded into the mode variants). */
31
+ const LEGACY_WSL_PRESET_ID = 'wsl'
32
+
33
+ /**
34
+ * Minimal sessions-service face. The renderer-host ctx merge types
35
+ * `ctx.sessions` as its own SessionStore; the service the runtime actually
36
+ * registers under that key satisfies this narrower contract, so the cast is
37
+ * the documented boundary for a third-party plugin.
38
+ */
39
+ interface WslSessionsFace {
40
+ list: {
41
+ getSnapshot(): { ids: string[]; byId: Record<string, { blank: boolean; cwd?: string; agentPreset?: string }> }
42
+ subscribe(fn: () => void): () => void
43
+ }
44
+ noteAgentPreset(sessionId: string, agentPreset: string): void
45
+ }
46
+
47
+ /** Minimal workspaces-service face (create + start-session only). */
48
+ interface WslWorkspacesFace {
49
+ create(input: { path: string }): Promise<{ workspaceId: string }>
50
+ startSession(workspaceId?: string): void
51
+ }
52
+
53
+ /**
54
+ * Mount the sidebar action and the auto-binding effect.
55
+ * @param ctx - the browser plugin context.
56
+ */
57
+ export function apply(ctx: ClientContext): void {
58
+ const { api } = ctx.get('connection') as ConnectionHandle
59
+ const workspaces = ctx.get('workspaces') as unknown as WslWorkspacesFace
60
+ const sessions = ctx.get('sessions') as unknown as WslSessionsFace
61
+
62
+ ensureStyles()
63
+
64
+ ctx.effect(
65
+ () => ctx.locale.register('wslWorkspace' as never, { zh, en }),
66
+ 'dsh-wsl-workspace: locale dictionaries',
67
+ )
68
+
69
+ // The injected translate function reads the live DeepSeek Harness locale,
70
+ // so the dialog copy follows the app language setting automatically.
71
+ const t = ctx.locale.bind('wslWorkspace' as never) as unknown as (key: string, params?: Record<string, unknown>) => string
72
+
73
+ // Canonical Windows drive keys of every registered `/mnt/<drive>` workspace
74
+ // (refreshed from the host store; see refreshRoster). A blank session whose
75
+ // cwd is one of these binds to the WSL variant like a UNC-cwd session.
76
+ let wslWindowsPaths = new Set<string>()
77
+
78
+ const injected = (): AddWslWorkspaceInjected => ({
79
+ t,
80
+ checkPreset: async (): Promise<string | undefined> => {
81
+ let roster
82
+ try {
83
+ const response = await api.agentPresets.list({})
84
+ roster = response.result
85
+ } catch (error) {
86
+ return error instanceof Error ? error.message : String(error)
87
+ }
88
+ if (!roster.ok) return roster.error.message
89
+ const healthy = roster.value.presets.find((entry: { id: string; broken?: string }) =>
90
+ entry.id.startsWith('wsl-') && entry.broken === undefined)
91
+ if (healthy === undefined) return t('error.presetMissing')
92
+ return undefined
93
+ },
94
+ listDistros: () => listDistrosApi(),
95
+ listDir: (distro, path) => listDirApi(distro, path),
96
+ check: (distro, path) => checkApi(distro, path),
97
+ createWorkspace: async (linuxPath, username, distro): Promise<string | undefined> => {
98
+ try {
99
+ const winPath = mntToWindowsPath(linuxPath)
100
+ if (winPath !== null) {
101
+ // `/mnt/<drive>` workspace: the workspace registry realpath/stats
102
+ // the path and 9P cannot serve drvfs mounts, so register under the
103
+ // drive spelling and store distro/username for the session env.
104
+ // The browser binding below recognizes the drive cwd as WSL.
105
+ const view = await workspaces.create({ path: winPath })
106
+ await registerWindowsApi(linuxPath, distro, username)
107
+ const canonical = canonicalWindowsPath(winPath)
108
+ if (canonical !== null) wslWindowsPaths = new Set(wslWindowsPaths).add(canonical)
109
+ workspaces.startSession(view.workspaceId)
110
+ return undefined
111
+ }
112
+ const uncPath = joinUnc(distro, linuxPath)
113
+ const view = await workspaces.create({ path: uncPath })
114
+ await setWorkspaceUserApi(uncPath, username)
115
+ workspaces.startSession(view.workspaceId)
116
+ return undefined
117
+ } catch (error) {
118
+ return error instanceof Error ? error.message : String(error)
119
+ }
120
+ },
121
+ })
122
+
123
+ ctx.effect(
124
+ () => ctx.slots.inject(
125
+ 'sidebar.footer.action',
126
+ () => ctx.slots.register(
127
+ { name: 'sidebar.footer.action', id: 'wsl-workspace', inject: injected },
128
+ AddWslWorkspace,
129
+ ),
130
+ ),
131
+ 'dsh-wsl-workspace: sidebar footer action',
132
+ )
133
+
134
+ // Mode-variant binding: a blank session whose workspace is a WSL UNC path
135
+ // is recomposed to the WSL variant of the mode it currently runs — plain
136
+ // 标准 becomes `wsl-standard`, PTC becomes `wsl-code`, and so on — so the
137
+ // WSL execution world composes with ANY mode instead of replacing it. The
138
+ // host refuses non-blank sessions (agent-preset-locked), so the swap is
139
+ // attempted at most a few times per session.
140
+ ctx.effect(() => {
141
+ const inFlight = new Set<string>()
142
+ const attempts = new Map<string, number>()
143
+ const MAX_ATTEMPTS = 3
144
+ // Healthy `wsl-<mode>` variant ids plus the roster's default preset id
145
+ // (what a session with no explicit choice gets). Refreshed periodically
146
+ // so variants generated after this page loaded are picked up.
147
+ let variants = new Set<string>()
148
+ let defaultPreset: string | undefined
149
+ const refreshRoster = (): void => {
150
+ void api.agentPresets.list({}).then((response: {
151
+ result: { ok: boolean; value: { presets: { id: string; broken?: string; isDefault?: boolean }[] } }
152
+ }) => {
153
+ const result = response.result
154
+ if (!result.ok) return
155
+ variants = new Set(result.value.presets
156
+ .filter((entry: { id: string; broken?: string }) =>
157
+ entry.broken === undefined && entry.id.startsWith('wsl-'))
158
+ .map((entry: { id: string }) => entry.id))
159
+ defaultPreset = result.value.presets.find(
160
+ (entry: { id: string; isDefault?: boolean }) => entry.isDefault === true,
161
+ )?.id
162
+ }).catch(() => {
163
+ // A failed roster read leaves the previous mapping; sessions stay on
164
+ // their current composition until the next refresh.
165
+ })
166
+ }
167
+ refreshRoster()
168
+ const refreshWorkspaces = (): void => {
169
+ void listWorkspacesApi().then((keys: string[]) => {
170
+ const next = new Set<string>()
171
+ for (const key of keys) {
172
+ const canonical = canonicalWindowsPath(key)
173
+ if (canonical !== null) next.add(canonical)
174
+ }
175
+ wslWindowsPaths = next
176
+ }).catch(() => {
177
+ // A failed store read leaves the previous set; sessions stay on
178
+ // their current composition until the next refresh.
179
+ })
180
+ }
181
+ refreshWorkspaces()
182
+ const maybeBind = (): void => {
183
+ const state = sessions.list.getSnapshot()
184
+ for (const id of state.ids) {
185
+ const summary = state.byId[id]
186
+ if (summary === undefined || !summary.blank || summary.cwd === undefined) continue
187
+ // A session belongs to the WSL world when its cwd is a WSL UNC path,
188
+ // or a Windows drive path registered as a `/mnt/<drive>` workspace
189
+ // (9P cannot serve drvfs, so those workspaces carry drive cwds).
190
+ const canonical = canonicalWindowsPath(summary.cwd)
191
+ const isWsl = isWslUnc(summary.cwd)
192
+ || (canonical !== null && wslWindowsPaths.has(canonical))
193
+ if (!isWsl) continue
194
+ const current = summary.agentPreset
195
+ if (current !== undefined && current.startsWith('wsl-')) continue
196
+ // Legacy standalone `wsl` (now folded into the variants): remap it to
197
+ // the default mode's variant, since the standalone preset no longer
198
+ // exists in the roster.
199
+ const base = current === LEGACY_WSL_PRESET_ID
200
+ ? (defaultPreset ?? 'standard')
201
+ : (current ?? defaultPreset)
202
+ if (base === undefined || base === LEGACY_WSL_PRESET_ID || base.startsWith('wsl-')) continue
203
+ const target = `wsl-${base.toLowerCase()}`
204
+ if (!variants.has(target)) continue
205
+ if (inFlight.has(id) || (attempts.get(id) ?? 0) >= MAX_ATTEMPTS) continue
206
+ inFlight.add(id)
207
+ void api.agentPresets.select({ sessionId: id, agentPreset: target })
208
+ .then((response: { result: { ok: boolean } }) => {
209
+ if (response.result.ok) sessions.noteAgentPreset(id, target)
210
+ })
211
+ .catch(() => {
212
+ // A refused or aborted swap (session already produced output,
213
+ // roster churn, reconnect) leaves the session on its current
214
+ // composition; count the attempt so a stuck session stops
215
+ // retrying after MAX_ATTEMPTS.
216
+ attempts.set(id, (attempts.get(id) ?? 0) + 1)
217
+ })
218
+ .finally(() => {
219
+ inFlight.delete(id)
220
+ })
221
+ }
222
+ }
223
+ maybeBind()
224
+ const unsubscribe = sessions.list.subscribe(() => maybeBind())
225
+ // Variants are generated at host boot; a page loaded before that would
226
+ // never see them without a periodic refresh.
227
+ const timer = window.setInterval(refreshRoster, 60_000)
228
+ return () => {
229
+ unsubscribe()
230
+ window.clearInterval(timer)
231
+ }
232
+ }, 'dsh-wsl-workspace: WSL mode-variant binding')
233
+ }
package/src/index.ts CHANGED
@@ -25,13 +25,13 @@
25
25
 
26
26
  import { Context } from '@deepseek-ai/cordis'
27
27
  import z from '@deepseek-ai/schemastery'
28
- import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
28
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
29
29
  import { fileURLToPath } from 'node:url'
30
30
  import { dirname, join } from 'node:path'
31
31
  import type { IncomingMessage, ServerResponse } from 'node:http'
32
32
  import { homedir } from 'node:os'
33
- import { joinUnc, normalizeLinuxPath, isAbsoluteLinuxPath, isValidWslUsername, parseWslUnc } from './shared/paths.ts'
34
- import { canonicalWslUnc, getWorkspaceUsername, setWorkspaceUsername } from './shared/wsl-credentials.ts'
33
+ import { joinUnc, mntToWindowsPath, normalizeLinuxPath, isAbsoluteLinuxPath, isValidWslUsername, parseWslUnc } from './shared/paths.ts'
34
+ import { canonicalWslUnc, getWindowsWorkspace, getWorkspaceUsername, listWorkspaceKeys, registerWindowsWorkspace, setWorkspaceUsername } from './shared/wsl-credentials.ts'
35
35
  import { defaultDistro, listDistros } from './shared/wsl.ts'
36
36
  import { isWslVariantId, transformPresetForWsl, variantIdFor } from './host/variants.ts'
37
37
 
@@ -213,10 +213,29 @@ function requireWslUnc(value: unknown): string {
213
213
  return canonical
214
214
  }
215
215
 
216
- /** Resolve one directory listing over the 9P share. */
216
+ /**
217
+ * Resolve one directory listing for the dialog. The 9P share (`\\wsl.localhost\…`)
218
+ * serves only the ext4 volume: `/mnt/<drive>` (drvfs) reads return Access
219
+ * denied, so drvfs paths are read through their Windows drive spelling and
220
+ * `/mnt` itself is synthesized from the drives present on the host.
221
+ */
217
222
  function listWslDir(distro: string, linuxPath: string): WslDirListingWire {
218
- const unc = joinUnc(distro, linuxPath)
219
- const dirents = readdirSync(unc, { withFileTypes: true })
223
+ if (linuxPath === '/mnt') {
224
+ const entries: WslDirEntryWire[] = []
225
+ for (let i = 0; i < 26; i++) {
226
+ const letter = String.fromCharCode(65 + i)
227
+ try {
228
+ const info = statSync(`${letter}:\\`)
229
+ if (info.isDirectory()) entries.push({ name: letter.toLowerCase(), kind: 'directory' })
230
+ } catch {
231
+ // Absent drive: skip.
232
+ }
233
+ }
234
+ return { path: '/mnt', parent: '/', entries }
235
+ }
236
+ const winPath = mntToWindowsPath(linuxPath)
237
+ const readPath = winPath !== null ? winPath : joinUnc(distro, linuxPath)
238
+ const dirents = readdirSync(readPath, { withFileTypes: true })
220
239
  const entries: WslDirEntryWire[] = dirents
221
240
  .slice(0, 1000)
222
241
  .map((dirent): WslDirEntryWire => {
@@ -253,14 +272,33 @@ async function dispatch(method: string, params: Record<string, unknown>): Promis
253
272
  case 'check': {
254
273
  const distro = requireDistro(params.distro)
255
274
  const path = requireLinuxPath(params.path, 'path')
256
- const unc = joinUnc(distro, path)
275
+ // drvfs paths (Access denied over 9P) are checked through their drive spelling.
276
+ const winPath = mntToWindowsPath(path)
277
+ const readPath = winPath !== null ? winPath : joinUnc(distro, path)
257
278
  try {
258
- const info = statSync(unc)
279
+ const info = statSync(readPath)
259
280
  return { exists: true, isDirectory: info.isDirectory() }
260
281
  } catch {
261
282
  return { exists: false, isDirectory: false }
262
283
  }
263
284
  }
285
+ case 'registerWindows': {
286
+ // A `/mnt/<drive>` workspace registers under its Windows drive path
287
+ // (the registry realpath/stats it, and 9P cannot serve drvfs) with the
288
+ // distro stored for the per-session env contributor.
289
+ const distro = requireDistro(params.distro)
290
+ const linuxPath = requireLinuxPath(params.linuxPath, 'path')
291
+ const winPath = mntToWindowsPath(linuxPath)
292
+ if (winPath === null) throw new Error('registerWindows requires a /mnt/<drive> Linux path')
293
+ const username = typeof params.username === 'string' ? params.username : undefined
294
+ registerWindowsWorkspace(winPath, distro, username)
295
+ return null
296
+ }
297
+ case 'listWorkspaces': {
298
+ // Every registered WSL workspace key (UNC and Windows drive spellings):
299
+ // the client uses the drive keys to recognize `/mnt` workspaces.
300
+ return listWorkspaceKeys()
301
+ }
264
302
  case 'setUser': {
265
303
  const path = requireWslUnc(params.path)
266
304
  const username = params.username
@@ -296,6 +334,21 @@ interface AgentPresetsService {
296
334
  * @param shellPath - absolute path of the plugin's built WSL shell provider.
297
335
  * @param fsPath - absolute path of the plugin's built WSL fs provider.
298
336
  */
337
+ /**
338
+ * Relative './x.mjs' row files a composition references. Local function-plugin
339
+ * rows travel with their preset directory; generated variants must copy them
340
+ * along or the variant composition fails to load.
341
+ * @param composition - the composition text.
342
+ */
343
+ function localRowFiles(composition: string): Set<string> {
344
+ const files = new Set<string>()
345
+ for (const line of composition.split('\n')) {
346
+ const match = /^\s*name:\s*['"]?(\.\/[^'"]+\.m?js)['"]?\s*$/.exec(line)
347
+ if (match?.[1] !== undefined) files.add(match[1])
348
+ }
349
+ return files
350
+ }
351
+
299
352
  async function materializeVariants(
300
353
  agentPresets: AgentPresetsService,
301
354
  dshHome: string,
@@ -314,6 +367,35 @@ async function materializeVariants(
314
367
  const dir = join(userRoot, variantId)
315
368
  mkdirSync(dir, { recursive: true })
316
369
  writeFileSync(join(dir, 'agent.cordis.yml'), transformed, 'utf8')
370
+ // Copy local function-plugin row files (name: './x.mjs') from the source
371
+ // preset into the variant directory, including their transitive relative
372
+ // imports — the variant composition references them relatively and they
373
+ // must travel with it, or the variant fails to load at mount time.
374
+ const pending = [...localRowFiles(transformed)]
375
+ const visited = new Set<string>()
376
+ while (pending.length > 0) {
377
+ const rowFile = pending.pop()
378
+ if (rowFile === undefined || visited.has(rowFile)) continue
379
+ visited.add(rowFile)
380
+ const srcFile = join(dirname(preset.path), rowFile)
381
+ if (!existsSync(srcFile)) continue
382
+ mkdirSync(dirname(join(dir, rowFile)), { recursive: true })
383
+ copyFileSync(srcFile, join(dir, rowFile))
384
+ try {
385
+ const content = readFileSync(srcFile, 'utf8')
386
+ const importRe = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](\.\/[^'"]+)['"]/g
387
+ let match: RegExpExecArray | null
388
+ while ((match = importRe.exec(content)) !== null) {
389
+ let spec = match[1]
390
+ if (spec === undefined) continue
391
+ if (!/\.m?js$/.test(spec)) spec += '.mjs'
392
+ pending.push(spec)
393
+ }
394
+ } catch {
395
+ // Unreadable row file: keep whatever was copied; the variant reports
396
+ // the failure through the roster when mounted.
397
+ }
398
+ }
317
399
  const labels = MODE_DISPLAY_LABELS[preset.id]
318
400
  let name = variantName(preset.id, preset.id)
319
401
  let orderLine = ''
@@ -410,11 +492,24 @@ export function apply(ctx: Context, config: Config): void {
410
492
  resolve(execution) {
411
493
  const cwd = execution.agent?.session.header.cwd
412
494
  const unc = cwd === undefined ? null : parseWslUnc(cwd)
413
- if (unc === null) return {}
414
- const username = getWorkspaceUsername(joinUnc(unc.distro, unc.linuxPath))
415
- return username === undefined || username === ''
416
- ? { DSH_WSL_DISTRO: unc.distro }
417
- : { DSH_WSL_DISTRO: unc.distro, DSH_WSL_USER: username }
495
+ if (unc !== null) {
496
+ const username = getWorkspaceUsername(joinUnc(unc.distro, unc.linuxPath))
497
+ return username === undefined || username === ''
498
+ ? { DSH_WSL_DISTRO: unc.distro }
499
+ : { DSH_WSL_DISTRO: unc.distro, DSH_WSL_USER: username }
500
+ }
501
+ // A Windows-drive cwd belongs to a `/mnt/<drive>` WSL workspace (9P
502
+ // cannot serve drvfs, so those register under their drive path): the
503
+ // stored distro drives `wsl.exe -d` for bash.
504
+ if (cwd !== undefined && /^[A-Za-z]:[\\/]/.test(cwd)) {
505
+ const entry = getWindowsWorkspace(cwd)
506
+ if (entry !== undefined && entry.distro !== undefined && entry.distro !== '') {
507
+ return entry.username === undefined || entry.username === ''
508
+ ? { DSH_WSL_DISTRO: entry.distro }
509
+ : { DSH_WSL_DISTRO: entry.distro, DSH_WSL_USER: entry.username }
510
+ }
511
+ }
512
+ return {}
418
513
  },
419
514
  }), 'dsh-wsl-workspace: per-session distro env fact')
420
515
  }